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
+71 -102
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,28 +3114,25 @@ 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.
cap = max(5, int(budget))
# 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 = [], []
fps = max(1, int(fps))
for i, b in enumerate(beats, 1):
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
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)))))
out.append(n)
if n != cap:
notes.append(f"shot {i}: {n}f (~{n / fps:.1f}s, from {src})")
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)
src, floor, snap = "content", content_floor, align_frame_count_nearest
if want <= 0: # no signal -> the ceiling
out.append(cap)
continue
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})")
return out, notes
@@ -3878,24 +3874,24 @@ def estimate_shot_frames(total_gb, resident_gb, headroom_gb, pixels=None, free_g
return max(floor, align_frame_count(min(H3_MAX_FRAMES, frames)))
def resolve_shot_frames(shot_seconds, fps, total_gb, resident_gb, headroom_gb,
allow_oversize=False, pixels=None, free_gb=None):
def resolve_shot_frames(shot_seconds, fps, total_gb, resident_gb, headroom_gb,
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."""
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:
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")
Auto mode (shot_seconds <= 0): frames = the VRAM budget estimate (resolution-
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:
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, ""
def _is_oom(e):
@@ -6045,21 +6041,16 @@ 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": "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."}),
"shot_seconds": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 15.1, "step": 0.5,
"forceInput": True,
"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. 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,12 +6084,7 @@ 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",
"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 "
"Nvidia_RTX_Nodes_ComfyUI pack, falls back automatically if absent). 'model' = "
@@ -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",
@@ -6660,8 +6631,8 @@ class H3LongVideos:
streaming = total_gb > 0 and resident_gb > 0 and resident_gb > total_gb
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)
ln, ln_note = resolve_shot_frames(shot_seconds, fps, total_gb, resident_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,32 +6704,29 @@ 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.
pace_warnings = pacing_warnings(beats, lens, fps)
if pace_warnings:
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")
"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, 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")
"BABBLE RISK -- " + "; ".join(filler_warnings)
+ ". 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(),
@@ -6933,22 +6901,23 @@ class H3LongVideos:
spk = speech_flags(beats) # which shots have real (quoted) dialogue
vram_trace = [] # free VRAM after each shot
muted_flags = [] # which shots were audio-silenced
hoff = max(0, int(handoff_offset))
backoff, video_chunks, audio_chunks = [], [], []
latent_chunks = [] # per-shot sampled latents, pre-decode
mouth_settled = [] # shots seeded from a settled (closed) mouth
muted_flags = [] # which shots were audio-silenced
hoff = max(0, int(handoff_offset))
backoff, video_chunks, audio_chunks = [], [], []
latent_chunks = [] # per-shot sampled latents, pre-decode
mouth_settled = [] # shots seeded from a settled (closed) mouth
handoff, sr = first_frame, None
ref_shots = [] # which shots ended up ref-conditioned
ref_missing = [] # <Picture N> tags naming an unconnected slot
ref_carried = [] # tagged shots that kept continuity as an extra ref
ref_keyframed = [] # tagged shots that kept it as a real keyframe
ref_mode_used = []
continuity_used = []
ref_aug_used = []
shot_timings = []
if cleanup_between_shots:
_deep_cleanup() # start the first (heaviest) shot with max free VRAM
ref_mode_used = []
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
shot_lens = (lens + [ln] * len(gens))[:len(gens)]
for i, base_prompt in enumerate(raw_gens):