diff --git a/H3_LONG_VIDEOS_GUIDE.md b/H3_LONG_VIDEOS_GUIDE.md index 10a78b6..c6b3993 100644 --- a/H3_LONG_VIDEOS_GUIDE.md +++ b/H3_LONG_VIDEOS_GUIDE.md @@ -1162,13 +1162,14 @@ What this really means: - the node renders the beat once - the sampled latent is upscaled in latent space to the target size - the conditioning is rebuilt at that target size -- the node then runs a hard-coded 2-step refinement pass over the upscaled latent +- the node then runs a short refinement pass over the upscaled latent with the sampler, scheduler, step count, denoise, and megapixel target you picked on the latent-upscale params node Good starting point: - use the `model` mode when you want the strongest latent detail recovery - use the interpolation mode when you want a cheaper resize-only path -- keep the refinement denoise low to medium +- start with `euler_ancestral`, `simple`, `2` steps, and `0.2` denoise +- leave width and height at `0` unless you want an exact override; otherwise `megapixels` drives the target size The important part is that this stage is still a latent pass, not a pixel-space resize: diff --git a/README.md b/README.md index 04dad5e..521174c 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,9 @@ - Per-shot directives now support `continuity:`, `ref_mode:`, `ref_noise_aug:`, `anchor_add:`, `soundscape:`, and `music:` in addition to the existing timing and wardrobe directives. - `Dumas H3 Latent Upscale Params` - - Inputs: `mode`, `model_name`, `method`, `width`, `height`, `device`, `precision`, `refine_denoise` + - Inputs: `mode`, `model_name`, `method`, `width`, `height`, `device`, `precision`, `sampler_name`, `scheduler`, `steps`, `denoise`, `megapixels` - Output: `latent_upscale_param` - - Bundles the optional latent-space upscaler settings used by `Dumas H3 Long Videos` before decode, so the main node can rebuild conditioning at the target size and run a short refinement pass. + - Bundles the optional latent-space upscaler settings used by `Dumas H3 Long Videos` before decode, so the main node can rebuild conditioning at the target size and run a short refinement pass with your chosen sampler, scheduler, step count, and denoise. - `Dumas H3 Beat Prompt` - Inputs: authored through the custom front-end beat editor diff --git a/dumas_h3_latent_upscale.py b/dumas_h3_latent_upscale.py index e901b37..0d73b89 100644 --- a/dumas_h3_latent_upscale.py +++ b/dumas_h3_latent_upscale.py @@ -4,6 +4,7 @@ import math import os import re +import comfy.samplers import folder_paths import torch @@ -33,6 +34,8 @@ LATENTS_STD = [ ] _LATENT_UPSCALE_FOLDER = "latent_upscale_models" +MP_UNIT = 1024 * 1024 +RES_MULTIPLE = 32 def _models_dir(): @@ -347,10 +350,29 @@ def _compute_upscale_target(width, height, h_in, w_in): return h_out, w_out, eff +def _scale_to_megapixels(w, h, mp, multiple=RES_MULTIPLE): + if not mp or mp <= 0 or w <= 0 or h <= 0: + return int(h), int(w) + multiple = max(1, int(multiple)) + scale = math.sqrt((float(mp) * MP_UNIT) / float(w * h)) + nw = max(multiple, int(round(w * scale / multiple)) * multiple) + nh = max(multiple, int(round(h * scale / multiple)) * multiple) + return nh, nw + + +def _resolve_target_size(param, h_in, w_in): + width = int(param.get("width", 0) or 0) + height = int(param.get("height", 0) or 0) + megapixels = float(param.get("megapixels", 0.0) or 0.0) + if width > 0 and height > 0: + return height, width + if megapixels > 0: + return _scale_to_megapixels(w_in, h_in, megapixels) + return int(h_in), int(w_in) + + def upscale_video_model(video, param): model_name = param["model_name"] - width = int(param["width"]) - height = int(param["height"]) device = param.get("device", "cuda") precision = param.get("precision", "fp16") @@ -359,7 +381,8 @@ def upscale_video_model(video, param): compute_dtype = {"fp32": torch.float32, "fp16": torch.float16, "bf16": torch.bfloat16}[precision] _, c, t, h_in, w_in = video.shape - h_out, w_out, eff = _compute_upscale_target(width, height, h_in, w_in) + h_out, w_out = _resolve_target_size(param, h_in, w_in) + eff = (w_out / float(w_in) + h_out / float(h_in)) / 2.0 if w_in and h_in else 1.0 if eff < 1.0 and (w_out < w_in or h_out < h_in): raise ValueError("This model only supports upscaling (effective scale >= 1.0).") @@ -386,10 +409,8 @@ def upscale_video_model(video, param): def upscale_video_interp(video, param): method = str(param.get("method") or "bilinear") - width = int(param.get("width", 0) or 0) - height = int(param.get("height", 0) or 0) _, c, t, h_in, w_in = video.shape - h_out, w_out, _ = _compute_upscale_target(width, height, h_in, w_in) + h_out, w_out = _resolve_target_size(param, h_in, w_in) if h_out == h_in and w_out == w_in: return video, h_in, w_in video_bt = video.permute(0, 2, 1, 3, 4).reshape(-1, c, h_in, w_in) @@ -425,23 +446,42 @@ class H3LatentUpscaleParams: "method": (["nearest-exact", "bilinear", "area", "bicubic"], {"default": "bilinear", "tooltip": "Interpolation method used when mode = interp."}), "width": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 32, - "tooltip": "Target upscaled pixel width for the latent refinement stage. 0 keeps the original width and effectively disables the resize."}), + "tooltip": "Explicit target width for the latent refinement stage. Leave at 0 to let megapixels choose the size instead."}), "height": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 32, - "tooltip": "Target upscaled pixel height for the latent refinement stage. 0 keeps the original height and effectively disables the resize."}), + "tooltip": "Explicit target height for the latent refinement stage. Leave at 0 to let megapixels choose the size instead."}), "device": (["cuda", "cpu"], {"default": "cuda", "tooltip": "Device used by the H3 latent upscaler model when mode = model."}), "precision": (["fp16", "fp32", "bf16"], {"default": "fp16", "tooltip": "Computation precision used by the H3 latent upscaler model when mode = model."}), - "refine_denoise": ("FLOAT", {"default": 0.25, "min": 0.0, "max": 1.0, "step": 0.01, - "tooltip": "How much the 2-step refinement pass may rewrite the upscaled latent. Lower = safer, higher = freer."}), + "sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"default": "euler_ancestral", + "tooltip": "Sampler used for the latent refinement pass. Default matches the current H3 preference."}), + "scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"default": "simple", + "tooltip": "Scheduler used for the latent refinement pass."}), + "steps": ("INT", {"default": 2, "min": 1, "max": 50, "step": 1, + "tooltip": "Number of refinement steps applied after the latent upscaler stage."}), + "denoise": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01, + "tooltip": "How much the refinement pass may rewrite the upscaled latent. Lower = safer, higher = freer."}), + "megapixels": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 4.0, "step": 0.01, + "tooltip": "Primary target size for the latent refinement stage. If width and height are both set, they win; otherwise the node scales the current shot to this pixel budget while preserving aspect ratio. 0 keeps the incoming latent size."}), } } - def build(self, mode, model_name, method, width, height, device, precision, refine_denoise): + def build(self, mode, model_name, method, width, height, device, precision, sampler_name, scheduler, steps, denoise, megapixels): width = int(width) height = int(height) + steps = int(steps) if mode == "off": - return ({"mode": "off", "width": width, "height": height, "refine_denoise": float(refine_denoise)},) + return ({ + "mode": "off", + "width": width, + "height": height, + "sampler_name": sampler_name, + "scheduler": scheduler, + "steps": steps, + "denoise": float(denoise), + "refine_denoise": float(denoise), + "megapixels": float(megapixels), + },) if width > 0: width = int(round(width / 32.0)) * 32 if height > 0: @@ -454,7 +494,12 @@ class H3LatentUpscaleParams: "height": height, "device": device, "precision": precision, - "refine_denoise": float(refine_denoise), + "sampler_name": sampler_name, + "scheduler": scheduler, + "steps": steps, + "denoise": float(denoise), + "refine_denoise": float(denoise), + "megapixels": float(megapixels), },) diff --git a/dumas_h3_longvideos.py b/dumas_h3_longvideos.py index b13330b..3e636c0 100644 --- a/dumas_h3_longvideos.py +++ b/dumas_h3_longvideos.py @@ -33,9 +33,9 @@ and your VRAM, chains them, and returns the finished video + audio. Requirements: H3 is CFG-free (cfg 1) and needs no negative prompt -- the node makes an empty one internally. The main pass keeps denoise fixed at 1.0: a partial denoise desyncs the joint audio/video schedule. An optional latent -upscale stage can rebuild the conditioning at a larger target size, run a short -refinement pass, and keep the output video-only before the final pixel-space -upscale options. +upscale stage can rebuild the conditioning at a target size, run a short +refinement pass with its own sampler controls, and keep the output video-only +before the final pixel-space upscale options. Verified against ComfyUI core (comfy_extras/nodes_minimax_h3.py, model_base.py, ldm/minimax/model.py, text_encoders/minimax.py, sd.py). @@ -4068,11 +4068,22 @@ def _video_only_refined_latent(base_latent, refined_latent): refined_parts = refined.unbind() if len(base_parts) >= 2 and len(refined_parts) >= 1: return {"samples": comfy.nested_tensor.NestedTensor((refined_parts[0], base_parts[-1]))} - except Exception: + except Exception: return refined_latent return refined_latent +def _latent_upscale_target_size(base_w, base_h, param): + width = int(param.get("width", 0) or 0) + height = int(param.get("height", 0) or 0) + if width > 0 and height > 0: + return width, height + megapixels = float(param.get("megapixels", 0.0) or 0.0) + if megapixels > 0: + return scale_to_megapixels(base_w, base_h, megapixels) + return int(base_w), int(base_h) + + def _nested_tensor_parts(samples): if samples is None: return () @@ -6107,8 +6118,9 @@ class H3LongVideos: "Try 256 on a tight card at 1344x768."}), "latent_upscale_param": ("DUMAS_H3_LATENT_UPSCALE_PARAM", { "tooltip": "Output of 'Dumas H3 Latent Upscale Params'. When connected, the first-pass " - "latent is upscaled and run through a hard-coded 2-step refinement pass before " - "decode. Leave unconnected to skip latent upscaling entirely."}), + "latent is upscaled and run through a short refinement pass before decode, " + "using the sampler, scheduler, steps, denoise, and megapixel target from that node. " + "Leave unconnected to skip latent upscaling entirely."}), "upscale": (["off", "rtx", "model", "lanczos"], {"default": "off", "tooltip": "Optional post-pass on the finished frames. 'rtx' = NVIDIA RTX Video Super " "Resolution (Tensor Cores -- fastest and best for video; needs the " @@ -6422,11 +6434,11 @@ class H3LongVideos: # Conditioning is built, so the text encoder and VAEs are dead weight for the # whole sampling loop -- evict them and keep only the DiT on the card. _evict_all_but(model) - try: - sample_start = time.perf_counter() - (out,) = nodes.common_ksampler(model, seed, steps, cfg, sn, sch, positive, negative, - latent, denoise=denoise) - timing["sample"] += time.perf_counter() - sample_start + try: + sample_start = time.perf_counter() + (out,) = nodes.common_ksampler(model, seed, steps, cfg, sn, sch, positive, negative, + latent, denoise=denoise) + timing["sample"] += time.perf_counter() - sample_start except Exception as e: # Mark WHERE this failed. `tiled` only affects the DECODE, so the caller's # OOM retry cannot help an OOM raised here -- it just re-runs the whole @@ -6440,8 +6452,6 @@ class H3LongVideos: if ( isinstance(latent_upscale_param, dict) and str(latent_upscale_param.get("mode", "off")) != "off" - and int(latent_upscale_param.get("width", 0) or 0) > 0 - and int(latent_upscale_param.get("height", 0) or 0) > 0 ): try: latent_start = time.perf_counter() @@ -6463,9 +6473,14 @@ class H3LongVideos: ref_noise_aug=ref_noise_aug, audio_vae=audio_vae, silent=silent) upscale_latent["samples"] = comfy.nested_tensor.NestedTensor( (upscaled_video, parts[1])) + refine_steps = int(latent_upscale_param.get("steps", 2) or 2) + refine_sampler = latent_upscale_param.get("sampler_name", sn) + refine_scheduler = latent_upscale_param.get("scheduler", sch) + refine_denoise_value = latent_upscale_param.get("denoise", latent_upscale_param.get("refine_denoise", 0.2)) + refine_denoise = 0.2 if refine_denoise_value is None else float(refine_denoise_value) (refined_out,) = nodes.common_ksampler( - model, seed, 2, cfg, sn, sch, upscale_cond, negative, upscale_latent, - denoise=float(latent_upscale_param.get("refine_denoise", 0.25) or 0.25)) + model, seed, refine_steps, cfg, refine_sampler, refine_scheduler, upscale_cond, negative, upscale_latent, + denoise=refine_denoise) timing["latent_upscale_sample"] += time.perf_counter() - latent_start refined_out = _video_only_refined_latent(out, refined_out) except Exception as e: @@ -6591,19 +6606,21 @@ class H3LongVideos: model, ms_note = apply_h3_model_sampling(model, shift_video, shift_audio) latent_upscale_note = "" if isinstance(latent_upscale_param, dict) and str(latent_upscale_param.get("mode", "off")) != "off": - target_w = int(latent_upscale_param.get("width", 0) or 0) - target_h = int(latent_upscale_param.get("height", 0) or 0) - if target_w > 0 and target_h > 0: - mode = str(latent_upscale_param.get("mode", "off")) - detail = f" via {mode}" - if mode == "model": - detail += f"/{latent_upscale_param.get('model_name', 'none')}" - else: - detail += f"/{latent_upscale_param.get('method', 'bilinear')}" - latent_upscale_note = ( - f" latent upscale: target {target_w}x{target_h}px{detail}; " - f"2-step refinement denoise {float(latent_upscale_param.get('refine_denoise', 0.25) or 0.25):.2f}" - ) + target_w, target_h = _latent_upscale_target_size(w, h, latent_upscale_param) + mode = str(latent_upscale_param.get("mode", "off")) + detail = f" via {mode}" + if mode == "model": + detail += f"/{latent_upscale_param.get('model_name', 'none')}" + else: + detail += f"/{latent_upscale_param.get('method', 'bilinear')}" + denoise_value = latent_upscale_param.get("denoise", latent_upscale_param.get("refine_denoise", 0.2)) + denoise = 0.2 if denoise_value is None else float(denoise_value) + latent_upscale_note = ( + f" latent upscale: target {target_w}x{target_h}px{detail}; " + f"{int(latent_upscale_param.get('steps', 2) or 2)}-step refinement " + f"{latent_upscale_param.get('sampler_name', sn)}/{latent_upscale_param.get('scheduler', sch)} " + f"denoise {denoise:.2f}" + ) paras = split_paragraphs(prompt, "##") if anchor_override.strip(): diff --git a/tests/test_dumas_h3_longvideos.py b/tests/test_dumas_h3_longvideos.py index 1aea54b..d3f33d5 100644 --- a/tests/test_dumas_h3_longvideos.py +++ b/tests/test_dumas_h3_longvideos.py @@ -306,18 +306,21 @@ class DumasH3LongVideosHelperTests(unittest.TestCase): latent_upscale_param={ "mode": "model", "model_name": "upscale.safetensors", - "width": 256, - "height": 128, "device": "cpu", "precision": "fp16", - "refine_denoise": 0.4, + "sampler_name": "euler_ancestral", + "scheduler": "simple", + "steps": 2, + "denoise": 0.4, + "megapixels": 1.5, }, ) self.assertEqual(len(calls), 2) self.assertIsNot(calls[1][0][8], first_out) self.assertEqual(calls[1][0][8]["samples"].unbind()[0].name, "upv") - self.assertEqual(calls[1][0][4], "res_multistep") + self.assertEqual(calls[1][0][2], 2) + self.assertEqual(calls[1][0][4], "euler_ancestral") self.assertEqual(calls[1][0][5], "simple") self.assertAlmostEqual(calls[1][1]["denoise"], 0.4) self.assertEqual(result[1], first_out) @@ -424,11 +427,13 @@ class DumasH3LongVideosHelperTests(unittest.TestCase): latent_upscale_param={ "mode": "model", "model_name": "upscale.safetensors", - "width": 256, - "height": 128, "device": "cpu", "precision": "fp16", - "refine_denoise": 0.4, + "sampler_name": "euler_ancestral", + "scheduler": "simple", + "steps": 2, + "denoise": 0.4, + "megapixels": 1.5, }, ) @@ -1096,6 +1101,7 @@ class DumasH3LongVideosHelperTests(unittest.TestCase): def test_latent_upscale_params_node_is_exposed(self): latent = importlib.import_module("dumas_h3_latent_upscale") + required = latent.H3LatentUpscaleParams.INPUT_TYPES()["required"] self.assertEqual( latent.NODE_CLASS_MAPPINGS, @@ -1105,6 +1111,11 @@ class DumasH3LongVideosHelperTests(unittest.TestCase): latent.NODE_DISPLAY_NAME_MAPPINGS, {"DumasH3LatentUpscaleParams": "Dumas H3 Latent Upscale Params"}, ) + self.assertEqual(required["sampler_name"][1]["default"], "euler_ancestral") + self.assertEqual(required["scheduler"][1]["default"], "simple") + self.assertEqual(required["steps"][1]["default"], 2) + self.assertEqual(required["denoise"][1]["default"], 0.2) + self.assertEqual(required["megapixels"][1]["default"], 1.0) def test_compose_persistent_does_not_expand_ambiguous_plural_to_full_cast(self): active = self.module.parse_wardrobe(