Expose latent upscale sampler controls

This commit is contained in:
2026-09-03 13:05:32 +00:00
parent c1d937e0e2
commit 5c12cd18a3
5 changed files with 126 additions and 52 deletions
+3 -2
View File
@@ -1162,13 +1162,14 @@ What this really means:
- the node renders the beat once - the node renders the beat once
- the sampled latent is upscaled in latent space to the target size - the sampled latent is upscaled in latent space to the target size
- the conditioning is rebuilt at that 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: Good starting point:
- use the `model` mode when you want the strongest latent detail recovery - use the `model` mode when you want the strongest latent detail recovery
- use the interpolation mode when you want a cheaper resize-only path - 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: The important part is that this stage is still a latent pass, not a pixel-space resize:
+2 -2
View File
@@ -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. - 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` - `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` - 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` - `Dumas H3 Beat Prompt`
- Inputs: authored through the custom front-end beat editor - Inputs: authored through the custom front-end beat editor
+58 -13
View File
@@ -4,6 +4,7 @@ import math
import os import os
import re import re
import comfy.samplers
import folder_paths import folder_paths
import torch import torch
@@ -33,6 +34,8 @@ LATENTS_STD = [
] ]
_LATENT_UPSCALE_FOLDER = "latent_upscale_models" _LATENT_UPSCALE_FOLDER = "latent_upscale_models"
MP_UNIT = 1024 * 1024
RES_MULTIPLE = 32
def _models_dir(): def _models_dir():
@@ -347,10 +350,29 @@ def _compute_upscale_target(width, height, h_in, w_in):
return h_out, w_out, eff 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): def upscale_video_model(video, param):
model_name = param["model_name"] model_name = param["model_name"]
width = int(param["width"])
height = int(param["height"])
device = param.get("device", "cuda") device = param.get("device", "cuda")
precision = param.get("precision", "fp16") 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] compute_dtype = {"fp32": torch.float32, "fp16": torch.float16, "bf16": torch.bfloat16}[precision]
_, c, t, h_in, w_in = video.shape _, 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): 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).") 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): def upscale_video_interp(video, param):
method = str(param.get("method") or "bilinear") 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 _, 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: if h_out == h_in and w_out == w_in:
return video, h_in, w_in return video, h_in, w_in
video_bt = video.permute(0, 2, 1, 3, 4).reshape(-1, c, 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", "method": (["nearest-exact", "bilinear", "area", "bicubic"], {"default": "bilinear",
"tooltip": "Interpolation method used when mode = interp."}), "tooltip": "Interpolation method used when mode = interp."}),
"width": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 32, "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, "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", "device": (["cuda", "cpu"], {"default": "cuda",
"tooltip": "Device used by the H3 latent upscaler model when mode = model."}), "tooltip": "Device used by the H3 latent upscaler model when mode = model."}),
"precision": (["fp16", "fp32", "bf16"], {"default": "fp16", "precision": (["fp16", "fp32", "bf16"], {"default": "fp16",
"tooltip": "Computation precision used by the H3 latent upscaler model when mode = model."}), "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, "sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"default": "euler_ancestral",
"tooltip": "How much the 2-step refinement pass may rewrite the upscaled latent. Lower = safer, higher = freer."}), "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) width = int(width)
height = int(height) height = int(height)
steps = int(steps)
if mode == "off": 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: if width > 0:
width = int(round(width / 32.0)) * 32 width = int(round(width / 32.0)) * 32
if height > 0: if height > 0:
@@ -454,7 +494,12 @@ class H3LatentUpscaleParams:
"height": height, "height": height,
"device": device, "device": device,
"precision": precision, "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),
},) },)
+30 -13
View File
@@ -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 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 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 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 upscale stage can rebuild the conditioning at a target size, run a short
refinement pass, and keep the output video-only before the final pixel-space refinement pass with its own sampler controls, and keep the output video-only
upscale options. before the final pixel-space upscale options.
Verified against ComfyUI core (comfy_extras/nodes_minimax_h3.py, model_base.py, Verified against ComfyUI core (comfy_extras/nodes_minimax_h3.py, model_base.py,
ldm/minimax/model.py, text_encoders/minimax.py, sd.py). ldm/minimax/model.py, text_encoders/minimax.py, sd.py).
@@ -4073,6 +4073,17 @@ def _video_only_refined_latent(base_latent, refined_latent):
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): def _nested_tensor_parts(samples):
if samples is None: if samples is None:
return () return ()
@@ -6107,8 +6118,9 @@ class H3LongVideos:
"Try 256 on a tight card at 1344x768."}), "Try 256 on a tight card at 1344x768."}),
"latent_upscale_param": ("DUMAS_H3_LATENT_UPSCALE_PARAM", { "latent_upscale_param": ("DUMAS_H3_LATENT_UPSCALE_PARAM", {
"tooltip": "Output of 'Dumas H3 Latent Upscale Params'. When connected, the first-pass " "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 " "latent is upscaled and run through a short refinement pass before decode, "
"decode. Leave unconnected to skip latent upscaling entirely."}), "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", "upscale": (["off", "rtx", "model", "lanczos"], {"default": "off",
"tooltip": "Optional post-pass on the finished frames. 'rtx' = NVIDIA RTX Video Super " "tooltip": "Optional post-pass on the finished frames. 'rtx' = NVIDIA RTX Video Super "
"Resolution (Tensor Cores -- fastest and best for video; needs the " "Resolution (Tensor Cores -- fastest and best for video; needs the "
@@ -6440,8 +6452,6 @@ class H3LongVideos:
if ( if (
isinstance(latent_upscale_param, dict) isinstance(latent_upscale_param, dict)
and str(latent_upscale_param.get("mode", "off")) != "off" 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: try:
latent_start = time.perf_counter() latent_start = time.perf_counter()
@@ -6463,9 +6473,14 @@ class H3LongVideos:
ref_noise_aug=ref_noise_aug, audio_vae=audio_vae, silent=silent) ref_noise_aug=ref_noise_aug, audio_vae=audio_vae, silent=silent)
upscale_latent["samples"] = comfy.nested_tensor.NestedTensor( upscale_latent["samples"] = comfy.nested_tensor.NestedTensor(
(upscaled_video, parts[1])) (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( (refined_out,) = nodes.common_ksampler(
model, seed, 2, cfg, sn, sch, upscale_cond, negative, upscale_latent, model, seed, refine_steps, cfg, refine_sampler, refine_scheduler, upscale_cond, negative, upscale_latent,
denoise=float(latent_upscale_param.get("refine_denoise", 0.25) or 0.25)) denoise=refine_denoise)
timing["latent_upscale_sample"] += time.perf_counter() - latent_start timing["latent_upscale_sample"] += time.perf_counter() - latent_start
refined_out = _video_only_refined_latent(out, refined_out) refined_out = _video_only_refined_latent(out, refined_out)
except Exception as e: except Exception as e:
@@ -6591,18 +6606,20 @@ class H3LongVideos:
model, ms_note = apply_h3_model_sampling(model, shift_video, shift_audio) model, ms_note = apply_h3_model_sampling(model, shift_video, shift_audio)
latent_upscale_note = "" latent_upscale_note = ""
if isinstance(latent_upscale_param, dict) and str(latent_upscale_param.get("mode", "off")) != "off": 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_w, target_h = _latent_upscale_target_size(w, h, latent_upscale_param)
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")) mode = str(latent_upscale_param.get("mode", "off"))
detail = f" via {mode}" detail = f" via {mode}"
if mode == "model": if mode == "model":
detail += f"/{latent_upscale_param.get('model_name', 'none')}" detail += f"/{latent_upscale_param.get('model_name', 'none')}"
else: else:
detail += f"/{latent_upscale_param.get('method', 'bilinear')}" 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 = ( latent_upscale_note = (
f" latent upscale: target {target_w}x{target_h}px{detail}; " 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}" 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, "##") paras = split_paragraphs(prompt, "##")
+18 -7
View File
@@ -306,18 +306,21 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
latent_upscale_param={ latent_upscale_param={
"mode": "model", "mode": "model",
"model_name": "upscale.safetensors", "model_name": "upscale.safetensors",
"width": 256,
"height": 128,
"device": "cpu", "device": "cpu",
"precision": "fp16", "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.assertEqual(len(calls), 2)
self.assertIsNot(calls[1][0][8], first_out) self.assertIsNot(calls[1][0][8], first_out)
self.assertEqual(calls[1][0][8]["samples"].unbind()[0].name, "upv") 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.assertEqual(calls[1][0][5], "simple")
self.assertAlmostEqual(calls[1][1]["denoise"], 0.4) self.assertAlmostEqual(calls[1][1]["denoise"], 0.4)
self.assertEqual(result[1], first_out) self.assertEqual(result[1], first_out)
@@ -424,11 +427,13 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
latent_upscale_param={ latent_upscale_param={
"mode": "model", "mode": "model",
"model_name": "upscale.safetensors", "model_name": "upscale.safetensors",
"width": 256,
"height": 128,
"device": "cpu", "device": "cpu",
"precision": "fp16", "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): def test_latent_upscale_params_node_is_exposed(self):
latent = importlib.import_module("dumas_h3_latent_upscale") latent = importlib.import_module("dumas_h3_latent_upscale")
required = latent.H3LatentUpscaleParams.INPUT_TYPES()["required"]
self.assertEqual( self.assertEqual(
latent.NODE_CLASS_MAPPINGS, latent.NODE_CLASS_MAPPINGS,
@@ -1105,6 +1111,11 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
latent.NODE_DISPLAY_NAME_MAPPINGS, latent.NODE_DISPLAY_NAME_MAPPINGS,
{"DumasH3LatentUpscaleParams": "Dumas H3 Latent Upscale Params"}, {"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): def test_compose_persistent_does_not_expand_ambiguous_plural_to_full_cast(self):
active = self.module.parse_wardrobe( active = self.module.parse_wardrobe(