From eeca789c271ef4f75fce91a6b1e4c173bf79b242 Mon Sep 17 00:00:00 2001 From: Chris Dumas Date: Fri, 28 Aug 2026 11:36:39 +0000 Subject: [PATCH] Tidy H3 long videos input layout --- dumas_h3_longvideos.py | 142 ++++++++++++++++++------------ tests/test_dumas_h3_longvideos.py | 12 +++ 2 files changed, 98 insertions(+), 56 deletions(-) diff --git a/dumas_h3_longvideos.py b/dumas_h3_longvideos.py index 8d65595..2344f1c 100644 --- a/dumas_h3_longvideos.py +++ b/dumas_h3_longvideos.py @@ -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()] -# 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 -# 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. +# 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 +# onto the right widgets (ComfyUI matches them by position, not by name). +# 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 = ( "beat_split", "per_beat_length", "watermark_text", "watermark_position", "watermark_size", "watermark_opacity", @@ -277,7 +278,6 @@ ADDED_WIDGETS = ( "exposed_terms", "anatomy_guard", "lock_restraints", "solidity_guard", "motion_guard", "contact_guard", "auto_soundscape", "allow_nonspeech_vocals", - "ref_5", "ref_6", "ref_7", "ref_8", "ref_9", "detail_pass", "detail_sampler_name", "detail_scheduler", "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 " "working distill/turbo LoRA or a low-step MXFP8 checkpoint -- on the " "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}), - "sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"default": "res_multistep"}), - "scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"default": "simple"}), - "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "control_after_generate": True}), + "cfg": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 30.0, "step": 0.1, + "tooltip": "H3 is effectively CFG-free here. Leave this at 1.0 unless you are " + "deliberately testing unusual sampler behaviour; higher values do not act " + "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": { - "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 # exact slots, even when some intermediate sockets are left empty. # A shot using only ref_image_7 is still tagged as in the @@ -5760,19 +5773,20 @@ class H3LongVideos: # the order they are handed that shot. # Refer to socket tags in the prompt if you want a reference bound to # a named character ("Kristy, , walks in"). - "ref_1": ("REFERENCE", {"tooltip": "Reference object for -- image plus identity/environment metadata " - "carried into the shots. Which shots receive it is set by ref_mode (or " - "tags in the beats); a referenced shot ALSO carries the previous frame as its " - "keyframe, so taking a reference never costs continuity."}), - "ref_2": ("REFERENCE", {"tooltip": "Reference object for ."}), - "ref_3": ("REFERENCE", {"tooltip": "Reference object for ."}), - "ref_4": ("REFERENCE", {"tooltip": "Reference object for ."}), - "ref_5": ("REFERENCE", {"tooltip": "Reference object for ."}), - "ref_6": ("REFERENCE", {"tooltip": "Reference object for ."}), - "ref_7": ("REFERENCE", {"tooltip": "Reference object for ."}), - "ref_8": ("REFERENCE", {"tooltip": "Reference object for ."}), - "ref_9": ("REFERENCE", {"tooltip": "Reference object for ."}), - "ref_image_1": ("*", {"tooltip": "Legacy alias for ref_1. Accepts old IMAGE wiring or a REFERENCE payload."}), + "ref_1": ("REFERENCE", {"tooltip": "Reference slot . Feed a Dumas Character " + "Reference or Dumas Location Reference here. The image is used for real H3 visual " + "conditioning; the structured metadata is also used for prompt context."}), + "ref_2": ("REFERENCE", {"tooltip": "Reference slot . Socket numbers matter: " + "tag this exact slot as when you want explicit placement in a beat."}), + "ref_3": ("REFERENCE", {"tooltip": "Reference slot ."}), + "ref_4": ("REFERENCE", {"tooltip": "Reference slot ."}), + "ref_5": ("REFERENCE", {"tooltip": "Reference slot ."}), + "ref_6": ("REFERENCE", {"tooltip": "Reference slot ."}), + "ref_7": ("REFERENCE", {"tooltip": "Reference slot ."}), + "ref_8": ("REFERENCE", {"tooltip": "Reference slot ."}), + "ref_9": ("REFERENCE", {"tooltip": "Reference slot ."}), + "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_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."}), @@ -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_9": ("*", {"tooltip": "Legacy alias for ref_9. Accepts old IMAGE wiring or a REFERENCE payload."}), "plan_only": ("BOOLEAN", {"default": False, - "tooltip": "Preview the shot split WITHOUT rendering. Uses THIS node's own settings (no " - "second node, no duplicate entry): returns the plan in 'info' and the " - "shots/frames/seconds outputs near-instantly. Turn off to render for real."}), + "tooltip": "Preview the shot split and timing WITHOUT sampling pixels. Uses this " + "node's current settings and returns the plan in `info` almost instantly. " + "Good for checking beat counts, shot ceilings, ref placement and clamps " + "before a long render."}), "fps": ("INT", {"default": 24, "min": 1, "max": 60, "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 " @@ -5844,23 +5859,29 @@ class H3LongVideos: "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 " "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, - "forceInput": True, - "tooltip": "Length of EACH shot in seconds, taken from a connected input -- " - "H3 Shot Length is the intended source, since it also reports the " - "matching frame count on the 17k+5 grid.\n\n" - "Leave it UNCONNECTED for auto: the largest shot that fits at the " - "chosen size, which is what a 0 in the old widget did. One " - "paragraph = one shot, so total video = (paragraph count) x this. " - "Max ~15s."}), - "allow_oversize_shots": ("BOOLEAN", {"default": False, - "tooltip": "OFF (default): a forced shot_seconds that won't fit VRAM is clamped DOWN to " - "what fits, and the clamp is reported in info. ON: honor the requested length " - "even if it exceeds the budget -- the render may spill into system RAM (slow) " - "or OOM. Only affects forced shot_seconds, not auto."}), - "vram_headroom_gb": ("FLOAT", {"default": 1.5, "min": 0.0, "max": 32.0, "step": 0.5}), - "allow_res_backoff": ("BOOLEAN", {"default": True, - "tooltip": "If VRAM is tight, step resolution down instead of failing."}), + "shot_seconds": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 15.1, "step": 0.5, + "forceInput": True, + "tooltip": "GLOBAL per-shot ceiling in seconds, not 'force every beat to exactly " + "this length'. H3 Shot Length is the intended source because it snaps to " + "H3's frame grid and reports the real duration.\n\n" + "Leave UNCONNECTED for auto: the node picks the largest shot that fits " + "the current size/VRAM budget. Connect a value to cap every beat at that " + "length. A beat's own `seconds:` directive can still ask for less, and any " + "request above H3's hard ~15.1s single-shot limit is clamped. Total video " + "length is the sum of the beat shots, not simply beat-count x this value."}), + "allow_oversize_shots": ("BOOLEAN", {"default": False, + "tooltip": "OFF (default): a forced shot_seconds that won't fit VRAM is clamped DOWN to " + "what fits, and the clamp is reported in info. ON: honor the requested length " + "even if it exceeds the budget -- the render may spill into system RAM (slow) " + "or OOM. Only affects forced shot_seconds, not auto."}), + "vram_headroom_gb": ("FLOAT", {"default": 1.5, "min": 0.0, "max": 32.0, "step": 0.5, + "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 # condition the soundscape field too), but asking is not a guarantee -- # 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, "tooltip": "Spatial tile size for the VAE decode (tile_x/tile_y). 0 = ComfyUI default. " "Try 256 on a tight card at 1344x768."}), - "cleanup_between_shots": ("BOOLEAN", {"default": True, - "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 " - "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."}), + "cleanup_between_shots": ("BOOLEAN", {"default": True, + "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 " + "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."}), "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 " @@ -5944,7 +5965,7 @@ class H3LongVideos: "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"], {"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 tags, those " "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 " @@ -5952,7 +5973,9 @@ class H3LongVideos: "for long chains where identity drift matters more than strict per-shot " "routing. 'where tagged' keeps the old strict behavior, including the " "first-shot fallback when no tags are found. Tags are renumbered per shot, " - "so alone still resolves. 'first shot' / 'every shot' / " + "so 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 " "ref_image is connected."}), "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, "tooltip": "Run a second refinement sampler on each beat BEFORE any upscale. " "It reuses the same conditioning and keeps the output video-only by " - "preserving the first pass's audio latent. Good for extra detail without " - "building a separate graph."}), + "preserving the first pass's audio latent. Use it for detail cleanup, not " + "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", - "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", - "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, - "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, - "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 # the only thing that identifies an SLA build, and the graph is the only diff --git a/tests/test_dumas_h3_longvideos.py b/tests/test_dumas_h3_longvideos.py index 58697db..77a5969 100644 --- a/tests/test_dumas_h3_longvideos.py +++ b/tests/test_dumas_h3_longvideos.py @@ -432,12 +432,24 @@ class DumasH3LongVideosHelperTests(unittest.TestCase): for index in range(1, 10): 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): optional = self.module.H3LongVideos.INPUT_TYPES()["optional"] for index in range(1, 10): 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): node = self.module.H3LongVideos() optional = node.INPUT_TYPES()["optional"]