Simplify H3 beat timing and cleanup

This commit is contained in:
2026-09-03 08:56:43 +00:00
parent 769257513b
commit e098e44bf1
4 changed files with 105 additions and 167 deletions
+14 -58
View File
@@ -10,7 +10,7 @@ The node does two jobs at once:
Think of it like this:
- `prompt` decides what happens.
- `shot_seconds`, `per_beat_length`, and beat-level `seconds:` decide how long each beat is allowed to be.
- `shot_seconds` and beat-level `seconds:` decide how long each beat is allowed to be.
- `first_frame` and the handoff system decide continuity.
- `ref_1` to `ref_9` decide identity and location reference conditioning.
@@ -375,60 +375,38 @@ This is the most important group to understand.
These settings work together:
- `shot_seconds`
- `per_beat_length`
- beat-level `seconds:` or `duration:`
- `allow_oversize_shots`
- `megapixels`
- `vram_headroom_gb`
- `allow_res_backoff`
### The Short Version
`shot_seconds` is a ceiling, not a promise.
`shot_seconds` is the socket-defined maximum.
A beat-level `seconds:` line is a request, not absolute law.
A beat-level `seconds:` line wins for that beat.
The final beat length is limited by:
- H3's hard single-shot max
- the VRAM budget
- the current resolution and pixel budget
If a requested length is too much for the hardware, the node keeps it and lets the render fail instead of quietly shrinking it.
### Exact Decision Order
For each beat, the node decides length like this:
1. If the beat has `seconds:` or `duration:`, use that as the requested length.
2. Otherwise, if `per_beat_length` is on, estimate the beat length from its content.
3. Otherwise, use the global shot ceiling.
4. Clamp that result to the global `shot_seconds` ceiling if one is set.
5. Clamp again if H3 or VRAM will not allow that size.
6. Snap the result to H3's frame grid.
2. Otherwise, estimate the beat length from its content.
3. Snap the result to H3's frame grid.
### `shot_seconds`
Global per-shot ceiling.
Global per-shot maximum.
Important:
- This does not mean every beat will be exactly this long.
- It means no beat is allowed to be longer than this, unless other limits clamp it first.
- It means beats are sized against this socket value when the node needs a maximum.
If left unconnected or set to `0`, the node uses the largest safe shot length it can budget automatically.
### `per_beat_length`
When on, the node estimates beat length from the beat content if there is no explicit beat-level `seconds:` line.
That means:
- short simple beats become shorter shots
- more complex beats become longer shots
When off:
- every beat uses the full global ceiling unless that beat has its own explicit `seconds:` line
### Beat-Level `seconds:` Or `duration:`
Inside a beat, you can write:
@@ -445,29 +423,7 @@ duration: 8
This has the highest priority for that beat.
But it can still be clamped down if:
- it exceeds the global `shot_seconds` ceiling
- it exceeds H3's hard max shot length
- it exceeds the VRAM budget and `allow_oversize_shots` is off
### `allow_oversize_shots`
This only affects forced global `shot_seconds`.
When off:
- too-long requests are clamped down to what fits
When on:
- the node will try to honor the requested global ceiling even if it exceeds the safe VRAM budget
Trade-off:
- slower renders
- possible spill to system RAM
- possible OOM
It is not silently shortened by the node. If you ask for more than your hardware can handle, the render can fail.
This does not bypass H3's hard max shot size.
@@ -1287,9 +1243,9 @@ Useful on tighter cards at larger resolutions.
### `cleanup_between_shots`
Moves completed shot data off the GPU and clears memory between beats.
The node always moves completed shot data off the GPU and clears memory between beats.
Good default for long chains.
This is hard-coded for long-chain stability.
### `upscale`
@@ -1313,7 +1269,7 @@ Use them in this order:
1. Get the render stable.
2. If decode fails, use decode tiling.
3. If shot length is too short, reduce pixel cost before forcing oversize.
3. If shot length is too short, reduce pixel cost before forcing a longer shot.
4. Only upscale after the base render is working.
## Group 10: Overlay Controls
@@ -1408,7 +1364,7 @@ Recommended pattern:
- one character ref in `ref_1`
- `ref_mode = auto ref2v`
- no explicit `<Picture 1>` tags needed
- `per_beat_length = on`
- put `seconds:` on a beat when you want a specific length
### Two Characters Plus One Location
@@ -1422,7 +1378,7 @@ Recommended pattern:
- use `shot_seconds` as a safe ceiling
- put `seconds:` inside beats that need exact control
- check `plan_only` first
- expect clamping if you ask for more than H3 or VRAM allows
- expect an OOM if you ask for more than H3 or your hardware allows
## Best Way To Check What Will Really Happen
+33 -64
View File
@@ -271,7 +271,7 @@ def split_paragraphs(text, delimiter):
# 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",
"beat_split",
"watermark_text", "watermark_position", "watermark_size", "watermark_opacity",
"watermark_margin", "intro_text", "intro_position", "intro_seconds",
"intro_fade", "intro_size", "overlay_font", "overlay_stroke",
@@ -3093,19 +3093,18 @@ def beat_seconds_directive(beat):
beat_seconds_directive = lru_cache(maxsize=2048)(beat_seconds_directive)
def plan_beat_frames(beats, fps, budget, per_beat=True):
def plan_beat_frames(beats, fps, budget):
"""Per-beat shot lengths in frames. Returns (lengths, notes).
`budget` is the CEILING -- the VRAM budget, or a forced shot_seconds already
clamped to it. Per-beat sizing can only ever make a shot shorter than that
ceiling, never longer. Priority per beat:
`budget` is the socket-defined ceiling used for the beat-shot maximum.
Priority per beat:
1. an explicit 'seconds: N' line in the beat -- always honored, down to
H3's real 5-frame minimum, because you stated a duration outright;
2. its own content -- action clauses and quoted dialogue (see
estimate_beat_seconds), floored at MIN_CONTENT_FRAMES so a shot always
has room for one action;
3. with per_beat off, the ceiling, exactly as before.
3. otherwise its own content estimate.
Why estimate at all, when action prose has no *reliable* duration? Because the
alternative is not "no guess" -- it is "guess the maximum", which is what giving
@@ -3115,11 +3114,8 @@ def plan_beat_frames(beats, fps, budget, per_beat=True):
shot continues from the handoff frame; leaning long costs a jacket that takes
itself off and puts itself back on."""
beats = beats if beats else [""]
# MIN_SHOT_FRAMES is the floor of the *VRAM budget* -- the shortest shot the node
# falls back to when it has to guess with no information at all. It must not raise
# a length that came from you or from the beat's own content: `max(floor, ...)`
# silently turned every request below ~5.2s into 124f, so 1s/2s/3s/4s all rendered
# identically and both the widget and the `seconds:` directive looked broken.
# MIN_SHOT_FRAMES is the floor when the node has to guess with no information
# at all. A stated or estimated length is not silently rewritten upward.
cap = max(5, int(budget))
content_floor = align_frame_count(MIN_CONTENT_FRAMES)
out, notes = [], []
@@ -3128,12 +3124,12 @@ def plan_beat_frames(beats, fps, budget, per_beat=True):
want, src, floor = beat_seconds_directive(b), "seconds:", 5
snap = align_frame_count # a stated length is never rounded DOWN
if want is None:
want = estimate_beat_seconds(b) if per_beat else 0.0
want = estimate_beat_seconds(b)
src, floor, snap = "content", content_floor, align_frame_count_nearest
if want <= 0: # no signal -> the ceiling
out.append(cap)
continue
n = min(cap, max(floor, snap(int(round(want * fps)))))
n = max(floor, snap(int(round(want * fps))))
out.append(n)
if n != cap:
notes.append(f"shot {i}: {n}f (~{n / fps:.1f}s, from {src})")
@@ -3879,23 +3875,23 @@ def estimate_shot_frames(total_gb, resident_gb, headroom_gb, pixels=None, free_g
def resolve_shot_frames(shot_seconds, fps, total_gb, resident_gb, headroom_gb,
allow_oversize=False, pixels=None, free_gb=None):
pixels=None, free_gb=None):
"""Returns (frames, note).
Auto mode (shot_seconds <= 0): frames = the VRAM budget estimate (resolution-
scaled). Forced mode: the requested length is clamped DOWN to the budget
unless allow_oversize is set. When VRAM is unknown the request is honored."""
scaled). Forced mode: the requested length is honored as requested. When VRAM
is unknown the request is also honored."""
budget = estimate_shot_frames(total_gb, resident_gb, headroom_gb, pixels, free_gb)
if not (shot_seconds and float(shot_seconds) > 0):
return budget, ""
requested = align_frame_count(min(H3_MAX_FRAMES, max(5, round(float(shot_seconds) * fps))))
if total_gb <= 0 or requested <= budget:
if total_gb <= 0:
return requested, ""
if requested > budget:
return requested, (f"requested {requested}f (~{requested/max(1,fps):.1f}s) exceeds the "
f"~{budget}f VRAM budget -- honoring it; may spill to system RAM "
f"(slow) or OOM")
return requested, ""
if allow_oversize:
return requested, (f"OVERSIZE: {requested}f requested vs {budget}f budget -- honoring it; "
f"may spill to system RAM (slow) or OOM")
return budget, (f"requested {requested}f (~{requested/max(1,fps):.1f}s) exceeds the ~{budget}f VRAM "
f"budget -- clamped to {budget}f (~{budget/max(1,fps):.1f}s). Set allow_oversize_shots to override")
def _is_oom(e):
@@ -6047,19 +6043,14 @@ class H3LongVideos:
"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": "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"
"tooltip": "GLOBAL per-shot maximum in seconds. 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."}),
"length. A beat's own `seconds:` directive can still ask for less. If the "
"requested duration exceeds your hardware, the node will keep it and let the "
"render fail instead of shrinking it. Total video length is the sum of the "
"beat shots, not simply beat-count x this value."}),
"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 "
@@ -6093,11 +6084,6 @@ 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."}),
"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 "
@@ -6195,20 +6181,6 @@ class H3LongVideos:
"When this is filled in, EVERY paragraph of the prompt box is a beat/shot -- "
"nothing is consumed as the identity anchor. Put the permanent identity here "
"(hair, face, build, age) and the clothing in character_memory."}),
"per_beat_length": ("BOOLEAN", {"default": True,
"tooltip": "PACING. Size each shot from what its beat actually stages, instead of giving "
"every shot the same length. ON (default): a beat's time is ~2s of setup plus "
"~2.5s per action clause, or its spoken line, whichever is longer -- so 'she "
"takes off her jacket and drops it on the bench' gets ~7s and a three-part "
"beat gets more. OFF: every shot gets the full ceiling. WHY IT MATTERS: a 3s "
"action in a 12s shot leaves 9 seconds the model was told nothing about, and "
"it fills them by repeating or REVERSING the action -- which is why clothing "
"comes off and goes back on. The estimate leans SHORT on purpose: an "
"unfinished action is continued by the next shot from the handoff frame, "
"while an overlong one is unrecoverable. Never exceeds the ceiling "
"(shot_seconds or the VRAM budget) and always lands on the 17n+5 grid. "
"Override any single beat with 'seconds: 8' on its own line inside that "
"paragraph -- that wins over everything, including this toggle."}),
"auto_soundscape": (["off", "fill if blank", "always"], {"default": "fill if blank",
"tooltip": "Build the ambient bed from the scene instead of typing one. Reads the "
"ANCHOR (the soundscape is global, so it must describe the PLACE, not "
@@ -6509,9 +6481,8 @@ class H3LongVideos:
shift_video=12.0, shift_audio=3.0, trim_seam=True, vary_seed_per_shot=False,
handoff_offset=0, vram_headroom_gb=1.5, allow_res_backoff=True,
decode_tile_frames=0, decode_tile_size=0,
cleanup_between_shots=True,
anchor_override="", shot_seconds=0.0, allow_oversize_shots=False,
per_beat_length=True, beat_split="auto",
anchor_override="", shot_seconds=0.0,
beat_split="auto",
character_memory="", auto_wardrobe=True, auto_props=True, prevent_nudity=True,
exposed_terms="", anatomy_guard="auto", lock_restraints=True,
solidity_guard="auto", motion_guard="auto", contact_guard="auto",
@@ -6661,7 +6632,7 @@ class H3LongVideos:
lora_gb = lora_overhead_gb(model)
eff_headroom = vram_headroom_gb + lora_gb
ln, ln_note = resolve_shot_frames(shot_seconds, fps, total_gb, resident_gb,
eff_headroom, allow_oversize_shots, w * h, free_gb)
eff_headroom, w * h, free_gb)
if lora_gb:
ln_note = ((ln_note + " ") if ln_note else "") + (
f"reserved ~{lora_gb:.1f}GB for bypass-LoRA adapters (they stay resident in bf16 "
@@ -6733,14 +6704,13 @@ class H3LongVideos:
# this". Forcing a length used to DISABLE per-beat sizing entirely, which is
# why a plan made with a forced length disagreed with the auto render: two
# different code paths for the same question.
lens, len_notes = plan_beat_frames(beats, fps, ln, per_beat=bool(per_beat_length))
lens, len_notes = plan_beat_frames(beats, fps, ln)
secs = [n / fps for n in lens]
if len_notes:
n_short = sum(1 for n in lens if n < ln)
ln_note = ((ln_note + " ") if ln_note else "") + (
f"per-beat pacing sized {n_short} of {len(lens)} shot(s) under the {ln}f "
f"(~{ln / fps:.1f}s) ceiling from their own content: " + "; ".join(len_notes)
+ ". Turn per_beat_length OFF to give every shot the full ceiling")
f"content pacing sized {n_short} of {len(lens)} shot(s) under the {ln}f "
f"(~{ln / fps:.1f}s) ceiling from their own content: " + "; ".join(len_notes))
# With pacing OFF, every beat gets the ceiling whether it has anything to fill
# it with or not -- so say which beats are too thin for the length they got.
# This is the failure that reads as an action repeating or playing backwards.
@@ -6749,16 +6719,14 @@ class H3LongVideos:
ln_note = ((ln_note + " ") if ln_note else "") + (
"THIN BEATS -- the model must invent the remaining time, which it fills by "
"repeating or REVERSING the action: " + "; ".join(pace_warnings)
+ ". Add a second clause to the beat, set 'seconds:' on it, or turn "
"per_beat_length ON to size shots from their content")
+ ". Add a second clause to the beat, or set 'seconds:' on it")
fit_warnings = dialogue_fit_warnings(beats, secs)
# The opposite error, and the one that babbles: far more shot than line.
filler_warnings = dialogue_filler_warnings(beats, secs)
if filler_warnings:
ln_note = ((ln_note + " ") if ln_note else "") + (
"BABBLE RISK -- " + "; ".join(filler_warnings)
+ ". Turn per_beat_length ON to size these shots from their line, or set "
"'seconds:' on the beat")
+ ". Set 'seconds:' on the beat if you want a fixed longer take")
wardrobe_notes = []
strip_shots = [] # shots that newly bared a zone -> the NEXT shot starts fresh
raw_gens = distribute_generations(anchor, beats, global_soundscape.strip(),
@@ -6947,6 +6915,7 @@ class H3LongVideos:
continuity_used = []
ref_aug_used = []
shot_timings = []
cleanup_between_shots = True
if cleanup_between_shots:
_deep_cleanup() # start the first (heaviest) shot with max free VRAM
+3 -3
View File
@@ -12,7 +12,7 @@ const GROUPS = [
id: "prompt",
label: "Prompt",
defaultCollapsed: false,
widgets: ["prompt", "resolution", "megapixels", "beat_split", "anchor_override", "per_beat_length", "shot_seconds", "plan_only", "fps"],
widgets: ["prompt", "resolution", "megapixels", "beat_split", "anchor_override", "shot_seconds", "plan_only", "fps"],
},
{
id: "refs",
@@ -27,8 +27,8 @@ const GROUPS = [
widgets: [
"steps", "cfg", "sampler_name", "scheduler", "seed",
"apply_model_sampling", "shift_video", "shift_audio",
"allow_oversize_shots", "vram_headroom_gb", "allow_res_backoff",
"decode_tile_frames", "decode_tile_size", "cleanup_between_shots",
"vram_headroom_gb", "allow_res_backoff",
"decode_tile_frames", "decode_tile_size",
],
},
{
+17 -4
View File
@@ -734,14 +734,27 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
for index in range(1, 10):
self.assertNotIn(f"ref_image_{index}", optional)
self.assertNotIn("per_beat_length", optional)
self.assertNotIn("cleanup_between_shots", 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)
self.assertIn("GLOBAL per-shot maximum", tooltip)
self.assertIn("A beat's own `seconds:` directive can still ask for less", tooltip)
self.assertIn("honoring it; may spill to system RAM (slow) or OOM", tooltip)
def test_resolve_shot_frames_honors_forced_request_over_budget(self):
original_estimate_shot_frames = self.module.estimate_shot_frames
try:
self.module.estimate_shot_frames = lambda *_args, **_kwargs: 73
frames, note = self.module.resolve_shot_frames(10.0, 24, 16.0, 8.0, 1.5)
self.assertEqual(frames, 243)
self.assertIn("honoring it", note)
finally:
self.module.estimate_shot_frames = original_estimate_shot_frames
def test_run_defaults_match_declared_ref_widget_defaults(self):
node = self.module.H3LongVideos()
@@ -1152,7 +1165,7 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
module.quant_accel_note = lambda *_args, **_kwargs: ""
module.lora_active = lambda _model: False
module.resolve_shot_frames = lambda *_args, **_kwargs: (73, "")
module.plan_beat_frames = lambda beats, fps, budget, per_beat=True: ([73] * len(beats), [])
module.plan_beat_frames = lambda beats, fps, budget: ([73] * len(beats), [])
module.dialogue_fit_warnings = lambda *_args, **_kwargs: []
module.dialogue_filler_warnings = lambda *_args, **_kwargs: []
module.distribute_generations = lambda anchor, beats, *_args, **_kwargs: [