Tidy H3 long videos input layout

This commit is contained in:
2026-08-28 11:36:39 +00:00
parent 9b862928ba
commit eeca789c27
2 changed files with 98 additions and 56 deletions
+86 -56
View File
@@ -264,10 +264,11 @@ def split_paragraphs(text, delimiter):
return [p.strip() for p in re.split(r"\n\s*\n", raw) if p.strip()] return [p.strip() for p in re.split(r"\n\s*\n", raw) if p.strip()]
# Widgets added after the node's original 36-widget layout. Kept LAST in # Widgets added after the node's original 36-widget layout. Kept LAST in
# INPUT_TYPES so a workflow saved before they existed still maps its stored values # INPUT_TYPES so a workflow saved before they existed still maps its stored values
# onto the right widgets (ComfyUI matches them by position, not by name). # onto the right widgets (ComfyUI matches them by position, not by name).
# APPEND to this tuple when adding a widget; never insert into the middle. # APPEND widget names to this tuple; never insert into the middle. Pure sockets
# that carry no widget value can stay grouped in INPUT_TYPES without being listed.
ADDED_WIDGETS = ( ADDED_WIDGETS = (
"beat_split", "per_beat_length", "beat_split", "per_beat_length",
"watermark_text", "watermark_position", "watermark_size", "watermark_opacity", "watermark_text", "watermark_position", "watermark_size", "watermark_opacity",
@@ -277,7 +278,6 @@ ADDED_WIDGETS = (
"exposed_terms", "anatomy_guard", "lock_restraints", "solidity_guard", "exposed_terms", "anatomy_guard", "lock_restraints", "solidity_guard",
"motion_guard", "contact_guard", "motion_guard", "contact_guard",
"auto_soundscape", "allow_nonspeech_vocals", "auto_soundscape", "allow_nonspeech_vocals",
"ref_5", "ref_6", "ref_7", "ref_8", "ref_9",
"detail_pass", "detail_sampler_name", "detail_scheduler", "detail_pass", "detail_sampler_name", "detail_scheduler",
"detail_steps", "detail_denoise", "detail_steps", "detail_denoise",
) )
@@ -5744,13 +5744,26 @@ class H3LongVideos:
"tooltip": "Base H3 wants ~20 (res_multistep + simple). Drop to 6-8 ONLY with a " "tooltip": "Base H3 wants ~20 (res_multistep + simple). Drop to 6-8 ONLY with a "
"working distill/turbo LoRA or a low-step MXFP8 checkpoint -- on the " "working distill/turbo LoRA or a low-step MXFP8 checkpoint -- on the "
"bare base model, low steps are the #1 cause of soft output."}), "bare base model, low steps are the #1 cause of soft output."}),
"cfg": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 30.0, "step": 0.1}), "cfg": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 30.0, "step": 0.1,
"sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"default": "res_multistep"}), "tooltip": "H3 is effectively CFG-free here. Leave this at 1.0 unless you are "
"scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"default": "simple"}), "deliberately testing unusual sampler behaviour; higher values do not act "
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "control_after_generate": True}), "like a normal SD/Flux CFG boost and are not the fix for weak identity."}),
"sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"default": "res_multistep",
"tooltip": "Main-pass sampler. Base H3 is tuned around res_multistep; changing this is "
"a real behaviour change, not a cosmetic preference. Treat it as an advanced "
"override."}),
"scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"default": "simple",
"tooltip": "Main-pass scheduler. Base H3 is tuned around simple when paired with "
"res_multistep and the default model-sampling shifts."}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "control_after_generate": True,
"tooltip": "Base seed for the whole chain. With vary_seed_per_shot OFF, every beat is "
"sampled from the same noise field for better continuity. With it ON, this "
"becomes the first seed and later beats use seed+1, seed+2, ..."}),
}, },
"optional": { "optional": {
"first_frame": ("IMAGE",), "first_frame": ("IMAGE", {"tooltip": "Optional opening keyframe for shot 1. Leave empty to "
"start from pure text/reference conditioning. On later shots the node normally uses the "
"previous shot's last frame automatically; this socket is only the initial handoff."}),
# ref2va inputs. SOCKET NUMBER matters: prompt tags refer to these # ref2va inputs. SOCKET NUMBER matters: prompt tags refer to these
# exact slots, even when some intermediate sockets are left empty. # exact slots, even when some intermediate sockets are left empty.
# A shot using only ref_image_7 is still tagged as <Picture 7> in the # A shot using only ref_image_7 is still tagged as <Picture 7> in the
@@ -5760,19 +5773,20 @@ class H3LongVideos:
# the order they are handed that shot. # the order they are handed that shot.
# Refer to socket tags in the prompt if you want a reference bound to # Refer to socket tags in the prompt if you want a reference bound to
# a named character ("Kristy, <Picture 7>, walks in"). # a named character ("Kristy, <Picture 7>, walks in").
"ref_1": ("REFERENCE", {"tooltip": "Reference object for <Picture 1> -- image plus identity/environment metadata " "ref_1": ("REFERENCE", {"tooltip": "Reference slot <Picture 1>. Feed a Dumas Character "
"carried into the shots. Which shots receive it is set by ref_mode (or <Picture N> " "Reference or Dumas Location Reference here. The image is used for real H3 visual "
"tags in the beats); a referenced shot ALSO carries the previous frame as its " "conditioning; the structured metadata is also used for prompt context."}),
"keyframe, so taking a reference never costs continuity."}), "ref_2": ("REFERENCE", {"tooltip": "Reference slot <Picture 2>. Socket numbers matter: "
"ref_2": ("REFERENCE", {"tooltip": "Reference object for <Picture 2>."}), "tag this exact slot as <Picture 2> when you want explicit placement in a beat."}),
"ref_3": ("REFERENCE", {"tooltip": "Reference object for <Picture 3>."}), "ref_3": ("REFERENCE", {"tooltip": "Reference slot <Picture 3>."}),
"ref_4": ("REFERENCE", {"tooltip": "Reference object for <Picture 4>."}), "ref_4": ("REFERENCE", {"tooltip": "Reference slot <Picture 4>."}),
"ref_5": ("REFERENCE", {"tooltip": "Reference object for <Picture 5>."}), "ref_5": ("REFERENCE", {"tooltip": "Reference slot <Picture 5>."}),
"ref_6": ("REFERENCE", {"tooltip": "Reference object for <Picture 6>."}), "ref_6": ("REFERENCE", {"tooltip": "Reference slot <Picture 6>."}),
"ref_7": ("REFERENCE", {"tooltip": "Reference object for <Picture 7>."}), "ref_7": ("REFERENCE", {"tooltip": "Reference slot <Picture 7>."}),
"ref_8": ("REFERENCE", {"tooltip": "Reference object for <Picture 8>."}), "ref_8": ("REFERENCE", {"tooltip": "Reference slot <Picture 8>."}),
"ref_9": ("REFERENCE", {"tooltip": "Reference object for <Picture 9>."}), "ref_9": ("REFERENCE", {"tooltip": "Reference slot <Picture 9>."}),
"ref_image_1": ("*", {"tooltip": "Legacy alias for ref_1. Accepts old IMAGE wiring or a REFERENCE payload."}), "ref_image_1": ("*", {"tooltip": "Legacy alias for ref_1. Accepts old IMAGE wiring or a "
"REFERENCE payload. Keep old graphs working without rewiring."}),
"ref_image_2": ("*", {"tooltip": "Legacy alias for ref_2. Accepts old IMAGE wiring or a REFERENCE payload."}), "ref_image_2": ("*", {"tooltip": "Legacy alias for ref_2. Accepts old IMAGE wiring or a REFERENCE payload."}),
"ref_image_3": ("*", {"tooltip": "Legacy alias for ref_3. Accepts old IMAGE wiring or a REFERENCE payload."}), "ref_image_3": ("*", {"tooltip": "Legacy alias for ref_3. Accepts old IMAGE wiring or a REFERENCE payload."}),
"ref_image_4": ("*", {"tooltip": "Legacy alias for ref_4. Accepts old IMAGE wiring or a REFERENCE payload."}), "ref_image_4": ("*", {"tooltip": "Legacy alias for ref_4. Accepts old IMAGE wiring or a REFERENCE payload."}),
@@ -5782,9 +5796,10 @@ class H3LongVideos:
"ref_image_8": ("*", {"tooltip": "Legacy alias for ref_8. Accepts old IMAGE wiring or a REFERENCE payload."}), "ref_image_8": ("*", {"tooltip": "Legacy alias for ref_8. Accepts old IMAGE wiring or a REFERENCE payload."}),
"ref_image_9": ("*", {"tooltip": "Legacy alias for ref_9. Accepts old IMAGE wiring or a REFERENCE payload."}), "ref_image_9": ("*", {"tooltip": "Legacy alias for ref_9. Accepts old IMAGE wiring or a REFERENCE payload."}),
"plan_only": ("BOOLEAN", {"default": False, "plan_only": ("BOOLEAN", {"default": False,
"tooltip": "Preview the shot split WITHOUT rendering. Uses THIS node's own settings (no " "tooltip": "Preview the shot split and timing WITHOUT sampling pixels. Uses this "
"second node, no duplicate entry): returns the plan in 'info' and the " "node's current settings and returns the plan in `info` almost instantly. "
"shots/frames/seconds outputs near-instantly. Turn off to render for real."}), "Good for checking beat counts, shot ceilings, ref placement and clamps "
"before a long render."}),
"fps": ("INT", {"default": 24, "min": 1, "max": 60, "fps": ("INT", {"default": 24, "min": 1, "max": 60,
"tooltip": "DISPLAY ONLY -- H3 always renders 24 fps. The model's frame grid and its " "tooltip": "DISPLAY ONLY -- H3 always renders 24 fps. The model's frame grid and its "
"audio latent are both defined against 24, so this node computes every " "audio latent are both defined against 24, so this node computes every "
@@ -5844,23 +5859,29 @@ class H3LongVideos:
"shot instead of the literal last frame. Set 2-4 if chained shots open " "shot instead of the literal last frame. Set 2-4 if chained shots open "
"with moving/talking mouths -- it avoids seeding the next shot with a " "with moving/talking mouths -- it avoids seeding the next shot with a "
"mid-word open-mouth pose. Trims the matching audio tail too. 0 = last frame."}), "mid-word open-mouth pose. Trims the matching audio tail too. 0 = last frame."}),
"shot_seconds": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 15.1, "step": 0.5, "shot_seconds": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 15.1, "step": 0.5,
"forceInput": True, "forceInput": True,
"tooltip": "Length of EACH shot in seconds, taken from a connected input -- " "tooltip": "GLOBAL per-shot ceiling in seconds, not 'force every beat to exactly "
"H3 Shot Length is the intended source, since it also reports the " "this length'. H3 Shot Length is the intended source because it snaps to "
"matching frame count on the 17k+5 grid.\n\n" "H3's frame grid and reports the real duration.\n\n"
"Leave it UNCONNECTED for auto: the largest shot that fits at the " "Leave UNCONNECTED for auto: the node picks the largest shot that fits "
"chosen size, which is what a 0 in the old widget did. One " "the current size/VRAM budget. Connect a value to cap every beat at that "
"paragraph = one shot, so total video = (paragraph count) x this. " "length. A beat's own `seconds:` directive can still ask for less, and any "
"Max ~15s."}), "request above H3's hard ~15.1s single-shot limit is clamped. Total video "
"allow_oversize_shots": ("BOOLEAN", {"default": False, "length is the sum of the beat shots, not simply beat-count x this value."}),
"tooltip": "OFF (default): a forced shot_seconds that won't fit VRAM is clamped DOWN to " "allow_oversize_shots": ("BOOLEAN", {"default": False,
"what fits, and the clamp is reported in info. ON: honor the requested length " "tooltip": "OFF (default): a forced shot_seconds that won't fit VRAM is clamped DOWN to "
"even if it exceeds the budget -- the render may spill into system RAM (slow) " "what fits, and the clamp is reported in info. ON: honor the requested length "
"or OOM. Only affects forced shot_seconds, not auto."}), "even if it exceeds the budget -- the render may spill into system RAM (slow) "
"vram_headroom_gb": ("FLOAT", {"default": 1.5, "min": 0.0, "max": 32.0, "step": 0.5}), "or OOM. Only affects forced shot_seconds, not auto."}),
"allow_res_backoff": ("BOOLEAN", {"default": True, "vram_headroom_gb": ("FLOAT", {"default": 1.5, "min": 0.0, "max": 32.0, "step": 0.5,
"tooltip": "If VRAM is tight, step resolution down instead of failing."}), "tooltip": "Safety margin RESERVED from free VRAM before the node budgets shot length. "
"Higher = shorter safer shots. Lower = longer shots but more risk of spill or "
"OOM during sampling/decode peaks. 1.5GB is the conservative default."}),
"allow_res_backoff": ("BOOLEAN", {"default": True,
"tooltip": "If a render does not fit, try stepping the resolution down instead of just "
"failing. Helps salvage long jobs automatically, but the later shots may come "
"back smaller and the latent output can no longer join cleanly across sizes."}),
# ON by default: the prompt-side clauses ASK H3 not to vocalize (and now # ON by default: the prompt-side clauses ASK H3 not to vocalize (and now
# condition the soundscape field too), but asking is not a guarantee -- # condition the soundscape field too), but asking is not a guarantee --
# babble under a silent shot was the one artifact that survived both. # babble under a silent shot was the one artifact that survived both.
@@ -5886,11 +5907,11 @@ class H3LongVideos:
"decode_tile_size": ("INT", {"default": 0, "min": 0, "max": 1024, "step": 32, "decode_tile_size": ("INT", {"default": 0, "min": 0, "max": 1024, "step": 32,
"tooltip": "Spatial tile size for the VAE decode (tile_x/tile_y). 0 = ComfyUI default. " "tooltip": "Spatial tile size for the VAE decode (tile_x/tile_y). 0 = ComfyUI default. "
"Try 256 on a tight card at 1344x768."}), "Try 256 on a tight card at 1344x768."}),
"cleanup_between_shots": ("BOOLEAN", {"default": True, "cleanup_between_shots": ("BOOLEAN", {"default": True,
"tooltip": "Between beats, move each shot's decoded video+audio to system RAM and run " "tooltip": "Between beats, move each shot's decoded video+audio to system RAM and run "
"a full VRAM+RAM purge (GC + CUDA cache), so a long chain doesn't accumulate " "a full VRAM+RAM purge (GC + CUDA cache), so a long chain doesn't accumulate "
"on the GPU and OOM. Recommended on 16GB. Turn off only on a big card where " "on the GPU and OOM. Recommended on 16GB. Turn off only on a big card where "
"you want to skip the per-shot cleanup cost."}), "you want to skip the per-shot cleanup cost."}),
"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 "
@@ -5944,7 +5965,7 @@ class H3LongVideos:
"white as asked; 2-3 makes it survive a bright sky or a white wall."}), "white as asked; 2-3 makes it survive a bright sky or a white wall."}),
"ref_mode": (["auto ref2v", "where tagged", "first shot", "every shot", "every shot + handoff ref"], "ref_mode": (["auto ref2v", "where tagged", "first shot", "every shot", "every shot + handoff ref"],
{"default": "auto ref2v", {"default": "auto ref2v",
"tooltip": "Which shots the ref_image inputs condition. 'auto ref2v' (default) is " "tooltip": "Which shots the reference IMAGE inputs condition. 'auto ref2v' (default) is "
"the reference-to-video bias: if the prompt uses <Picture N> tags, those " "the reference-to-video bias: if the prompt uses <Picture N> tags, those "
"tags decide which shot gets which ref; if there are NO tags anywhere, the " "tags decide which shot gets which ref; if there are NO tags anywhere, the "
"node conditions EVERY shot with the connected refs rather than collapsing " "node conditions EVERY shot with the connected refs rather than collapsing "
@@ -5952,7 +5973,9 @@ class H3LongVideos:
"for long chains where identity drift matters more than strict per-shot " "for long chains where identity drift matters more than strict per-shot "
"routing. 'where tagged' keeps the old strict behavior, including the " "routing. 'where tagged' keeps the old strict behavior, including the "
"first-shot fallback when no tags are found. Tags are renumbered per shot, " "first-shot fallback when no tags are found. Tags are renumbered per shot, "
"so <Picture 2> alone still resolves. 'first shot' / 'every shot' / " "so <Picture 2> alone still resolves. Character names in the beat can also "
"pull their matching character refs into the real image-conditioning list. "
"'first shot' / 'every shot' / "
"'every shot + handoff ref' go purely by position. Ignored when no " "'every shot + handoff ref' go purely by position. Ignored when no "
"ref_image is connected."}), "ref_image is connected."}),
"ref_noise_aug": ("FLOAT", {"default": 0.95, "min": 0.50, "max": 1.0, "step": 0.005, "ref_noise_aug": ("FLOAT", {"default": 0.95, "min": 0.50, "max": 1.0, "step": 0.005,
@@ -6189,16 +6212,23 @@ class H3LongVideos:
"detail_pass": ("BOOLEAN", {"default": False, "detail_pass": ("BOOLEAN", {"default": False,
"tooltip": "Run a second refinement sampler on each beat BEFORE any upscale. " "tooltip": "Run a second refinement sampler on each beat BEFORE any upscale. "
"It reuses the same conditioning and keeps the output video-only by " "It reuses the same conditioning and keeps the output video-only by "
"preserving the first pass's audio latent. Good for extra detail without " "preserving the first pass's audio latent. Use it for detail cleanup, not "
"building a separate graph."}), "for huge rewrites: too many steps or too much denoise can pull identity or "
"continuity away from the main pass."}),
"detail_sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"default": "euler", "detail_sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"default": "euler",
"tooltip": "Sampler used for the optional refinement pass."}), "tooltip": "Sampler for the optional refinement pass. Euler is the maintained default "
"direction for this lane."}),
"detail_scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"default": "beta", "detail_scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"default": "beta",
"tooltip": "Scheduler used for the optional refinement pass."}), "tooltip": "Scheduler for the optional refinement pass. Beta is the maintained default "
"direction for the H3 enhancement lane."}),
"detail_steps": ("INT", {"default": 8, "min": 1, "max": 200, "detail_steps": ("INT", {"default": 8, "min": 1, "max": 200,
"tooltip": "Steps for the optional refinement pass."}), "tooltip": "Extra steps for the refinement pass only. Start around 4-8. More is not "
"automatically better; once the pass starts rewriting instead of polishing, "
"identity and continuity can drift."}),
"detail_denoise": ("FLOAT", {"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01, "detail_denoise": ("FLOAT", {"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01,
"tooltip": "How hard the refinement pass is allowed to rewrite the beat latent."}), "tooltip": "How hard the refinement pass is allowed to rewrite the beat latent. Start "
"around 0.20-0.35 for gentle cleanup; 0.4+ is stronger and can noticeably "
"change faces, motion or composition."}),
}, },
# Read-only graph access, for SLA-LoRA detection: a LoRA's filename is # Read-only graph access, for SLA-LoRA detection: a LoRA's filename is
# the only thing that identifies an SLA build, and the graph is the only # the only thing that identifies an SLA build, and the graph is the only
+12
View File
@@ -432,12 +432,24 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
for index in range(1, 10): for index in range(1, 10):
self.assertIn(f"ref_{index}", optional) self.assertIn(f"ref_{index}", optional)
names = list(optional)
ref_positions = [names.index(f"ref_{index}") for index in range(1, 10)]
self.assertEqual(ref_positions, list(range(ref_positions[0], ref_positions[0] + 9)))
def test_input_types_keep_legacy_ref_image_aliases(self): def test_input_types_keep_legacy_ref_image_aliases(self):
optional = self.module.H3LongVideos.INPUT_TYPES()["optional"] optional = self.module.H3LongVideos.INPUT_TYPES()["optional"]
for index in range(1, 10): for index in range(1, 10):
self.assertIn(f"ref_image_{index}", optional) self.assertIn(f"ref_image_{index}", optional)
def test_shot_seconds_tooltip_describes_ceiling_behavior(self):
optional = self.module.H3LongVideos.INPUT_TYPES()["optional"]
tooltip = optional["shot_seconds"][1]["tooltip"]
self.assertIn("GLOBAL per-shot ceiling", tooltip)
self.assertIn("not 'force every beat to exactly this length'", tooltip)
self.assertIn("hard ~15.1s single-shot limit", tooltip)
def test_run_defaults_match_declared_ref_widget_defaults(self): def test_run_defaults_match_declared_ref_widget_defaults(self):
node = self.module.H3LongVideos() node = self.module.H3LongVideos()
optional = node.INPUT_TYPES()["optional"] optional = node.INPUT_TYPES()["optional"]