diff --git a/dumas_h3_longvideos.py b/dumas_h3_longvideos.py
index ef874ee..8d65595 100644
--- a/dumas_h3_longvideos.py
+++ b/dumas_h3_longvideos.py
@@ -8,7 +8,7 @@ One node covering both of H3's conditioning tasks:
* REF2VA -- reference images condition the shot on what a character LOOKS like,
independent of any frame.
-Connect nothing to ref_* and it behaves exactly as the FL2VA node always
+Connect nothing to ref_* and it behaves exactly as the FL2VA node always
did. Connect a reference and `ref_mode` decides which shots use it.
THE ONE RULE: a shot carries EITHER references or the last-frame handoff, never
@@ -30,24 +30,23 @@ video; each later paragraph = a scene beat), a shot length, and a resolution fro
the VRAM-appropriate list. It splits the beats into shots that fit H3's ceiling
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
-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 refinement
-pass can use its own denoise later, before any upscale, while keeping the output
-video-only.
+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
+partial denoise desyncs the joint audio/video schedule. An optional refinement
+pass can use its own denoise later, before any upscale, while keeping the output
+video-only.
Verified against ComfyUI core (comfy_extras/nodes_minimax_h3.py, model_base.py,
ldm/minimax/model.py, text_encoders/minimax.py, sd.py).
"""
-import gc
-from functools import lru_cache
-import json
+import gc
+from functools import lru_cache
+import json
import logging
import math
import os
import re
-import time
import torch
import nodes
@@ -57,28 +56,28 @@ import comfy.nested_tensor
import comfy.model_management as mm
import node_helpers
-try:
- from . import dumas_h3_overlay as _overlay
- from . import dumas_image_nodes as _image_nodes
-except ImportError: # loaded as a bare file (test_prompt_logic.py), not as a package
- import importlib.util as _ilu
- import os as _os
- import sys as _sys
- _spec = _ilu.spec_from_file_location(
- "dumas_h3_overlay",
- _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "dumas_h3_overlay.py"),
- )
- _overlay = _ilu.module_from_spec(_spec)
- _spec.loader.exec_module(_overlay)
- _image_nodes = _sys.modules.get("dumas_image_nodes")
- if _image_nodes is None:
- _img_spec = _ilu.spec_from_file_location(
- "dumas_image_nodes",
- _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "dumas_image_nodes.py"),
- )
- _image_nodes = _ilu.module_from_spec(_img_spec)
- _sys.modules["dumas_image_nodes"] = _image_nodes
- _img_spec.loader.exec_module(_image_nodes)
+try:
+ from . import dumas_h3_overlay as _overlay
+ from . import dumas_image_nodes as _image_nodes
+except ImportError: # loaded as a bare file (test_prompt_logic.py), not as a package
+ import importlib.util as _ilu
+ import os as _os
+ import sys as _sys
+ _spec = _ilu.spec_from_file_location(
+ "dumas_h3_overlay",
+ _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "dumas_h3_overlay.py"),
+ )
+ _overlay = _ilu.module_from_spec(_spec)
+ _spec.loader.exec_module(_overlay)
+ _image_nodes = _sys.modules.get("dumas_image_nodes")
+ if _image_nodes is None:
+ _img_spec = _ilu.spec_from_file_location(
+ "dumas_image_nodes",
+ _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "dumas_image_nodes.py"),
+ )
+ _image_nodes = _ilu.module_from_spec(_img_spec)
+ _sys.modules["dumas_image_nodes"] = _image_nodes
+ _img_spec.loader.exec_module(_image_nodes)
AUDIO_LATENT_FPS = 40
GB = 1024 ** 3
@@ -268,44 +267,44 @@ def split_paragraphs(text, delimiter):
# 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",
- "watermark_margin", "intro_text", "intro_position", "intro_seconds",
- "intro_fade", "intro_size", "overlay_font", "overlay_stroke",
- "ref_mode", "ref_image_size", "ref_noise_aug", "auto_props", "prevent_nudity",
- "exposed_terms", "anatomy_guard", "lock_restraints", "solidity_guard",
- "motion_guard", "contact_guard",
- "auto_soundscape", "allow_nonspeech_vocals",
- "detail_pass", "detail_sampler_name", "detail_scheduler",
- "detail_steps", "detail_denoise",
-)
+# APPEND to this tuple when adding a widget; never insert into the middle.
+ADDED_WIDGETS = (
+ "beat_split", "per_beat_length",
+ "watermark_text", "watermark_position", "watermark_size", "watermark_opacity",
+ "watermark_margin", "intro_text", "intro_position", "intro_seconds",
+ "intro_fade", "intro_size", "overlay_font", "overlay_stroke",
+ "ref_mode", "ref_image_size", "ref_noise_aug", "auto_props", "prevent_nudity",
+ "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",
+)
NL = "\n"
# Lines that CONFIGURE a beat rather than being one. They attach to the beat that
# follows them, so a line-split never turns "wardrobe: ..." into its own shot.
-DIRECTIVE_KEYS = ("wardrobe", "seconds", "duration", "exit", "enter",
- "overall_soundscape", "non_diegetic_music", "soundscape", "music",
- "continuity", "ref_mode", "ref_noise_aug", "anchor_add")
-
-_REF_MODE_DIRECTIVE_MAP = {
- "auto ref2v": "auto ref2v",
- "where tagged": "where tagged",
- "first shot": "first shot",
- "every shot": "every shot",
- "every shot + handoff ref": "every shot + handoff ref",
-}
-
-_CONTINUITY_DIRECTIVE_MAP = {
- "auto": "auto",
- "default": "auto",
- "soft carry": "soft carry",
- "hard cut": "hard cut",
- "keyframe carry": "keyframe carry",
- "handoff ref": "handoff ref",
-}
+DIRECTIVE_KEYS = ("wardrobe", "seconds", "duration", "exit", "enter",
+ "overall_soundscape", "non_diegetic_music", "soundscape", "music",
+ "continuity", "ref_mode", "ref_noise_aug", "anchor_add")
+
+_REF_MODE_DIRECTIVE_MAP = {
+ "auto ref2v": "auto ref2v",
+ "where tagged": "where tagged",
+ "first shot": "first shot",
+ "every shot": "every shot",
+ "every shot + handoff ref": "every shot + handoff ref",
+}
+
+_CONTINUITY_DIRECTIVE_MAP = {
+ "auto": "auto",
+ "default": "auto",
+ "soft carry": "soft carry",
+ "hard cut": "hard cut",
+ "keyframe carry": "keyframe carry",
+ "handoff ref": "handoff ref",
+}
def is_directive_line(line):
@@ -1571,8 +1570,8 @@ def compose_persistent(body, active, anchor_id, removed=None, departed=None,
if speaking and silence_nonspeech and len(names) >= 2 and not unnamed:
speakers = _speakers_in(body, names)
if speakers:
- bound_names = set(refs) or (
- {n for n in names} if _plural_cast_matches_present(body, len(names)) else set())
+ bound_names = set(refs) or (
+ {n for n in names} if _plural_cast_matches_present(body, len(names)) else set())
if speakers & bound_names:
listeners = bound_names - speakers
@@ -1583,11 +1582,11 @@ def compose_persistent(body, active, anchor_id, removed=None, departed=None,
return speaking and n not in listeners
roll_call = ""
- if not refs and _plural_cast_matches_present(body, len(names)):
- bits = []
- for n in names:
- desc = ", ".join(_clean_items(active[n], n, drop_mouth_state=_drop_mouth(n)))
- bits.append(f"{n} ({desc})" if desc else n)
+ if not refs and _plural_cast_matches_present(body, len(names)):
+ bits = []
+ for n in names:
+ desc = ", ".join(_clean_items(active[n], n, drop_mouth_state=_drop_mouth(n)))
+ bits.append(f"{n} ({desc})" if desc else n)
roll_call = ((", ".join(bits[:-1]) + " and " + bits[-1])
+ (" are both in this shot." if len(bits) == 2
else " are all in this shot."))
@@ -1653,22 +1652,22 @@ def compose_persistent(body, active, anchor_id, removed=None, departed=None,
return (count_prefix + out).strip()
-def extract_wardrobe(body):
+def extract_wardrobe(body):
"""Pull a 'wardrobe: ...' directive line out of a beat body. Returns
(clean_body, wardrobe_or_None). The directive is a whole line starting with
'wardrobe:' (case-insensitive), placed INSIDE a beat (not as its own blank-
line-separated paragraph, which would become its own shot). It's removed
from the body so the literal 'wardrobe:' text isn't stamped as an action."""
kept, wardrobe = [], None
- for ln in body.split("\n"):
- if re.match(r"\s*wardrobe\s*:", ln, re.I):
- wardrobe = ln.split(":", 1)[1].strip()
- else:
- kept.append(ln)
- return "\n".join(kept).strip(), wardrobe
-
-
-extract_wardrobe = lru_cache(maxsize=2048)(extract_wardrobe)
+ for ln in body.split("\n"):
+ if re.match(r"\s*wardrobe\s*:", ln, re.I):
+ wardrobe = ln.split(":", 1)[1].strip()
+ else:
+ kept.append(ln)
+ return "\n".join(kept).strip(), wardrobe
+
+
+extract_wardrobe = lru_cache(maxsize=2048)(extract_wardrobe)
# --- anchor hazards ---------------------------------------------------------
@@ -2077,7 +2076,7 @@ _SPOKEN_CUE = re.compile(
re.I)
-def has_speech(body):
+def has_speech(body):
"""True only if a beat contains ACTUAL scripted speech -- double-quoted words
or an explicit ... tag. Bare speech VERBS ('calls out', 'tells', 'says'
with no quoted line) deliberately do NOT count: unscripted speech is exactly
@@ -2101,13 +2100,13 @@ def has_speech(body):
lead = body[max(0, m.start() - 60):m.start()].lower()
written = [x.end() for x in _WRITTEN_CUE.finditer(lead)]
spoken = [x.end() for x in _SPOKEN_CUE.finditer(lead)]
- if written and (not spoken or written[-1] > spoken[-1]):
- continue # printed in the scene, nobody said it
- return True
- return False
-
-
-has_speech = lru_cache(maxsize=2048)(has_speech)
+ if written and (not spoken or written[-1] > spoken[-1]):
+ continue # printed in the scene, nobody said it
+ return True
+ return False
+
+
+has_speech = lru_cache(maxsize=2048)(has_speech)
def _spoken_quotes(body):
@@ -2322,7 +2321,7 @@ NO_VOICE_SOUNDSCAPE = ("ambient background sound and room tone only, no voices,
NO_VOICE_CLAUSE = ", no voices, no speech, no talking, no vocal sounds"
NO_VOICE_SPEECH_SOUNDSCAPE = ("ambient background sound and room tone only, no speech, no dialogue, "
"no talking, no singing, no whispering, no spoken words")
-NO_VOICE_SPEECH_CLAUSE = ", no speech, no dialogue, no talking, no singing, no whispering, no spoken words"
+NO_VOICE_SPEECH_CLAUSE = ", no speech, no dialogue, no talking, no singing, no whispering, no spoken words"
# A beat that refers to the cast only in the PLURAL ("they face each other") used
@@ -2335,43 +2334,43 @@ NO_VOICE_SPEECH_CLAUSE = ", no speech, no dialogue, no talking, no singing, no w
# often as to people -- "she steps out of them" is a garment, "light floods through
# them" is a pair of doors -- and this fires only when nobody was bound by name or
# singular pronoun, which is exactly the scenery-beat case that must stay empty.
-_PLURAL_CAST = re.compile(
- r"\b(?:they|themselves|both|each other|one another|"
- r"the two of them|the two characters|the three characters|"
- r"the four characters|all of them|all three|all four)\b", re.I)
-
-_PLURAL_COUNT_PATTERNS = (
- (re.compile(r"\b(?:both|each other|the two of them|the two characters)\b", re.I), 2),
- (re.compile(r"\b(?:the three characters|all three)\b", re.I), 3),
- (re.compile(r"\b(?:the four characters|all four)\b", re.I), 4),
- (re.compile(r"\ball of them\b", re.I), "all"),
- # Bare "they"/"themselves"/"one another" is only safe when exactly two tracked
- # people are active; with three or more it is ambiguous and should not summon
- # the whole cast into the shot.
- (re.compile(r"\b(?:they|themselves|one another)\b", re.I), "ambiguous"),
-)
-
-
-def _plural_cast_matches_present(body, present_count):
- """Does this beat unambiguously refer to the whole currently-active cast?
-
- Plural wording used to pull EVERY tracked character from character_memory into a
- shot whenever the beat said "they" or "both of them". That is only safe when
- the count implied by the words matches the active cast exactly. Otherwise the
- plural is ambiguous and must not be expanded into a full roll-call."""
- text = body or ""
- present_count = max(0, int(present_count or 0))
- if present_count < 2:
- return False
- for rx, target in _PLURAL_COUNT_PATTERNS:
- if not rx.search(text):
- continue
- if target == "all":
- return True
- if target == "ambiguous":
- return present_count == 2
- return present_count == target
- return False
+_PLURAL_CAST = re.compile(
+ r"\b(?:they|themselves|both|each other|one another|"
+ r"the two of them|the two characters|the three characters|"
+ r"the four characters|all of them|all three|all four)\b", re.I)
+
+_PLURAL_COUNT_PATTERNS = (
+ (re.compile(r"\b(?:both|each other|the two of them|the two characters)\b", re.I), 2),
+ (re.compile(r"\b(?:the three characters|all three)\b", re.I), 3),
+ (re.compile(r"\b(?:the four characters|all four)\b", re.I), 4),
+ (re.compile(r"\ball of them\b", re.I), "all"),
+ # Bare "they"/"themselves"/"one another" is only safe when exactly two tracked
+ # people are active; with three or more it is ambiguous and should not summon
+ # the whole cast into the shot.
+ (re.compile(r"\b(?:they|themselves|one another)\b", re.I), "ambiguous"),
+)
+
+
+def _plural_cast_matches_present(body, present_count):
+ """Does this beat unambiguously refer to the whole currently-active cast?
+
+ Plural wording used to pull EVERY tracked character from character_memory into a
+ shot whenever the beat said "they" or "both of them". That is only safe when
+ the count implied by the words matches the active cast exactly. Otherwise the
+ plural is ambiguous and must not be expanded into a full roll-call."""
+ text = body or ""
+ present_count = max(0, int(present_count or 0))
+ if present_count < 2:
+ return False
+ for rx, target in _PLURAL_COUNT_PATTERNS:
+ if not rx.search(text):
+ continue
+ if target == "all":
+ return True
+ if target == "ambiguous":
+ return present_count == 2
+ return present_count == target
+ return False
def person_referenced(body, name, active):
@@ -2391,7 +2390,7 @@ def person_referenced(body, name, active):
return False
-def person_in_shot(body, name, active, departed=()):
+def person_in_shot(body, name, active, departed=()):
"""Is this person IN this shot -- by name, by a resolvable pronoun, or as part
of a cast addressed in the plural?
@@ -2404,10 +2403,10 @@ def person_in_shot(body, name, active, departed=()):
the restraint clause (a plural beat dropped the physical constraint, so the
restraints appeared to break). Both are gated on this function now, so a third
caller cannot rediscover it."""
- if person_referenced(body, name, active):
- return True
- present = [n for n in (active or {}) if n and n not in (departed or ())]
- return _plural_cast_matches_present(body, len(present))
+ if person_referenced(body, name, active):
+ return True
+ present = [n for n in (active or {}) if n and n not in (departed or ())]
+ return _plural_cast_matches_present(body, len(present))
def _subject_term(name, active):
@@ -2785,94 +2784,94 @@ def removed_phrase_items(body, anchor_id):
return out
-def extract_directive(body, key):
+def extract_directive(body, key):
"""Pull a ': ...' line out of a beat body. Returns (clean_body, value|None)."""
kept, val = [], None
- for ln in body.split("\n"):
- if re.match(r"\s*" + key + r"\s*:", ln, re.I):
- val = ln.split(":", 1)[1].strip()
- else:
- kept.append(ln)
- return "\n".join(kept).strip(), val
-
-
-extract_directive = lru_cache(maxsize=4096)(extract_directive)
-
-
-def extract_directive_aliases(body, keys):
- """Pull all alias lines for one logical directive, returning the last value found."""
- cleaned = str(body or "")
- value = None
- for key in keys:
- cleaned, found = extract_directive(cleaned, key)
- if found:
- value = found
- return cleaned, value
-
-
-def _normalize_choice_directive(value, allowed_map):
- lowered = str(value or "").strip().lower()
- if not lowered:
- return None
- return allowed_map.get(lowered)
-
-
-def beat_ref_mode_directive(beat):
- _, value = extract_directive((beat or ""), "ref_mode")
- return _normalize_choice_directive(value, _REF_MODE_DIRECTIVE_MAP)
-
-
-beat_ref_mode_directive = lru_cache(maxsize=2048)(beat_ref_mode_directive)
-
-
-def beat_ref_noise_aug_directive(beat):
- _, value = extract_directive((beat or ""), "ref_noise_aug")
- if not value:
- return None
- match = re.search(r"([0-9]*\.?[0-9]+)", value)
- if not match:
- return None
- try:
- parsed = float(match.group(1))
- except ValueError:
- return None
- return parsed if parsed >= 0 else None
-
-
-beat_ref_noise_aug_directive = lru_cache(maxsize=2048)(beat_ref_noise_aug_directive)
-
-
-def beat_continuity_directive(beat):
- _, value = extract_directive((beat or ""), "continuity")
- return _normalize_choice_directive(value, _CONTINUITY_DIRECTIVE_MAP)
-
-
-beat_continuity_directive = lru_cache(maxsize=2048)(beat_continuity_directive)
-
-
-def beat_override_summary(beat, shot_number):
- items = []
- continuity = beat_continuity_directive(beat)
- if continuity and continuity != "auto":
- items.append(f"continuity {continuity}")
- ref_mode = beat_ref_mode_directive(beat)
- if ref_mode:
- items.append(f"ref_mode {ref_mode}")
- ref_noise_aug = beat_ref_noise_aug_directive(beat)
- if ref_noise_aug is not None:
- items.append(f"ref_noise_aug {ref_noise_aug:g}")
- _, anchor_add = extract_directive((beat or ""), "anchor_add")
- if anchor_add:
- items.append("anchor_add")
- _, soundscape = extract_directive_aliases((beat or ""), ("overall_soundscape", "soundscape"))
- if soundscape:
- items.append("soundscape")
- _, music = extract_directive_aliases((beat or ""), ("non_diegetic_music", "music"))
- if music:
- items.append("music")
- if not items:
- return ""
- return f"shot {shot_number}: " + ", ".join(items)
+ for ln in body.split("\n"):
+ if re.match(r"\s*" + key + r"\s*:", ln, re.I):
+ val = ln.split(":", 1)[1].strip()
+ else:
+ kept.append(ln)
+ return "\n".join(kept).strip(), val
+
+
+extract_directive = lru_cache(maxsize=4096)(extract_directive)
+
+
+def extract_directive_aliases(body, keys):
+ """Pull all alias lines for one logical directive, returning the last value found."""
+ cleaned = str(body or "")
+ value = None
+ for key in keys:
+ cleaned, found = extract_directive(cleaned, key)
+ if found:
+ value = found
+ return cleaned, value
+
+
+def _normalize_choice_directive(value, allowed_map):
+ lowered = str(value or "").strip().lower()
+ if not lowered:
+ return None
+ return allowed_map.get(lowered)
+
+
+def beat_ref_mode_directive(beat):
+ _, value = extract_directive((beat or ""), "ref_mode")
+ return _normalize_choice_directive(value, _REF_MODE_DIRECTIVE_MAP)
+
+
+beat_ref_mode_directive = lru_cache(maxsize=2048)(beat_ref_mode_directive)
+
+
+def beat_ref_noise_aug_directive(beat):
+ _, value = extract_directive((beat or ""), "ref_noise_aug")
+ if not value:
+ return None
+ match = re.search(r"([0-9]*\.?[0-9]+)", value)
+ if not match:
+ return None
+ try:
+ parsed = float(match.group(1))
+ except ValueError:
+ return None
+ return parsed if parsed >= 0 else None
+
+
+beat_ref_noise_aug_directive = lru_cache(maxsize=2048)(beat_ref_noise_aug_directive)
+
+
+def beat_continuity_directive(beat):
+ _, value = extract_directive((beat or ""), "continuity")
+ return _normalize_choice_directive(value, _CONTINUITY_DIRECTIVE_MAP)
+
+
+beat_continuity_directive = lru_cache(maxsize=2048)(beat_continuity_directive)
+
+
+def beat_override_summary(beat, shot_number):
+ items = []
+ continuity = beat_continuity_directive(beat)
+ if continuity and continuity != "auto":
+ items.append(f"continuity {continuity}")
+ ref_mode = beat_ref_mode_directive(beat)
+ if ref_mode:
+ items.append(f"ref_mode {ref_mode}")
+ ref_noise_aug = beat_ref_noise_aug_directive(beat)
+ if ref_noise_aug is not None:
+ items.append(f"ref_noise_aug {ref_noise_aug:g}")
+ _, anchor_add = extract_directive((beat or ""), "anchor_add")
+ if anchor_add:
+ items.append("anchor_add")
+ _, soundscape = extract_directive_aliases((beat or ""), ("overall_soundscape", "soundscape"))
+ if soundscape:
+ items.append("soundscape")
+ _, music = extract_directive_aliases((beat or ""), ("non_diegetic_music", "music"))
+ if music:
+ items.append("music")
+ if not items:
+ return ""
+ return f"shot {shot_number}: " + ", ".join(items)
# "walks out OF THE BARN" is emerging INTO the scene, not leaving it -- and a false
@@ -3015,7 +3014,7 @@ _CLAUSE_SPLIT = (r"(?:[.!?;]+|,?\s+(?:and then|then|and|before|after|while|as|un
r"|,\s+(?=[a-z]+ing\b))")
-def action_clauses(beat):
+def action_clauses(beat):
"""How many distinct staged actions a beat contains.
"takes off her red jacket and drops it on the workbench" is two; "walks the
@@ -3024,66 +3023,66 @@ def action_clauses(beat):
body, _ = extract_wardrobe((beat or "").strip())
body = re.sub(r'["“][^"”]*["”]', " ", body)
body = " ".join(ln for ln in body.splitlines() if not is_directive_line(ln))
- parts = [p.strip() for p in re.split(_CLAUSE_SPLIT, body) if p and p.strip()]
- # A fragment of one word is a leftover ("it", "her"), not an action of its own.
- return sum(1 for p in parts if len(p.split()) >= 2)
+ parts = [p.strip() for p in re.split(_CLAUSE_SPLIT, body) if p and p.strip()]
+ # A fragment of one word is a leftover ("it", "her"), not an action of its own.
+ return sum(1 for p in parts if len(p.split()) >= 2)
+
+
+action_clauses = lru_cache(maxsize=2048)(action_clauses)
-action_clauses = lru_cache(maxsize=2048)(action_clauses)
-
-
-def estimate_beat_seconds(beat):
+def estimate_beat_seconds(beat):
"""Screen time this beat needs, from its own content. 0.0 when it has none.
Action and dialogue OVERLAP rather than add -- people talk while they move --
so the estimate is the larger of the two, not their sum."""
- n = action_clauses(beat)
- action = (BEAT_BASE_SEC + SECONDS_PER_ACTION * n) if n else 0.0
- return max(action, dialogue_seconds(beat))
+ n = action_clauses(beat)
+ action = (BEAT_BASE_SEC + SECONDS_PER_ACTION * n) if n else 0.0
+ return max(action, dialogue_seconds(beat))
+
+
+estimate_beat_seconds = lru_cache(maxsize=2048)(estimate_beat_seconds)
+
+
+@lru_cache(maxsize=2048)
+def _dialogue_spans_cached(beat):
+ """Word count of each double-quoted span in a beat, in order. Length of the
+ returned list is the number of speaking TURNS -- the multi-character case."""
+ body, _ = extract_wardrobe((beat or "").strip())
+ return tuple(len(q.split()) for q in re.findall(r'["\u201c]([^"\u201d]+)["\u201d]', body) if q.split())
+
+
+def dialogue_spans(beat):
+ return list(_dialogue_spans_cached(beat))
-estimate_beat_seconds = lru_cache(maxsize=2048)(estimate_beat_seconds)
+def dialogue_words(beat):
+ """Words inside double quotes in a beat -- the only speech H3 actually renders."""
+ return sum(_dialogue_spans_cached(beat))
+
+
+dialogue_words = lru_cache(maxsize=2048)(dialogue_words)
-@lru_cache(maxsize=2048)
-def _dialogue_spans_cached(beat):
- """Word count of each double-quoted span in a beat, in order. Length of the
- returned list is the number of speaking TURNS -- the multi-character case."""
- body, _ = extract_wardrobe((beat or "").strip())
- return tuple(len(q.split()) for q in re.findall(r'["\u201c]([^"\u201d]+)["\u201d]', body) if q.split())
-
-
-def dialogue_spans(beat):
- return list(_dialogue_spans_cached(beat))
-
-
-def dialogue_words(beat):
- """Words inside double quotes in a beat -- the only speech H3 actually renders."""
- return sum(_dialogue_spans_cached(beat))
-
-
-dialogue_words = lru_cache(maxsize=2048)(dialogue_words)
-
-
-def dialogue_seconds(beat, pad=True):
+def dialogue_seconds(beat, pad=True):
"""Screen time this beat's dialogue needs, 0.0 when the beat has none.
Counts every turn, so a two-character exchange is sized from the WHOLE
exchange plus a gap between turns -- not from the longest single line.
`pad` controls only the head/tail air; turn gaps are always counted because
they are time the shot genuinely has to contain."""
- spans = _dialogue_spans_cached(beat)
- if not spans:
- return 0.0
- return (sum(spans) / WORDS_PER_SEC
- + TURN_GAP_SEC * (len(spans) - 1)
- + (SPEECH_PAD_SEC if pad else 0.0))
+ spans = _dialogue_spans_cached(beat)
+ if not spans:
+ return 0.0
+ return (sum(spans) / WORDS_PER_SEC
+ + TURN_GAP_SEC * (len(spans) - 1)
+ + (SPEECH_PAD_SEC if pad else 0.0))
+
+
+dialogue_seconds = lru_cache(maxsize=4096)(dialogue_seconds)
-dialogue_seconds = lru_cache(maxsize=4096)(dialogue_seconds)
-
-
-def beat_seconds_directive(beat):
+def beat_seconds_directive(beat):
"""Explicit per-beat length: a 'seconds: 8' (or 'duration: 8') line in the beat.
Returns the float, or None when the beat doesn't set one."""
for key in ("seconds", "duration"):
@@ -3095,12 +3094,12 @@ def beat_seconds_directive(beat):
v = float(m.group(1))
except ValueError:
continue
- if v > 0:
- return v
- return None
-
-
-beat_seconds_directive = lru_cache(maxsize=2048)(beat_seconds_directive)
+ if v > 0:
+ return v
+ return None
+
+
+beat_seconds_directive = lru_cache(maxsize=2048)(beat_seconds_directive)
def plan_beat_frames(beats, fps, budget, per_beat=True):
@@ -3230,7 +3229,7 @@ def continuity_warnings(gens):
return out
-def dialogue_filler_warnings(beats, seconds_per_shot):
+def dialogue_filler_warnings(beats, seconds_per_shot):
"""Dialogue shots with far more time than their line, which H3 fills with speech.
dialogue_fit_warnings covers the opposite error -- a line too long for its shot,
@@ -3254,71 +3253,70 @@ def dialogue_filler_warnings(beats, seconds_per_shot):
if gap >= 3.0 and sec > spoken * 2:
out.append(f"shot {i}: {spoken:.1f}s of dialogue in a {sec:.1f}s shot -- {gap:.1f}s of "
f"unscripted audio the model will fill with more speech")
- return out
-
-
-def annotate_script_guards(gens, anatomy_shots, anatomy_mode):
- """Human-readable guard report prepended to the script socket.
-
- The `script` output is for inspection, not for feeding back into the node, so
- a compact report there is the quickest way to see whether a guard actually made
- it into each shot's prompt text.
- """
- lines = []
- if anatomy_mode == "off":
- lines.append("# anatomy_guard: off")
- elif anatomy_shots:
- lines.append(
- "# anatomy_guard: injected on shot(s) "
- + ",".join(str(n) for n in anatomy_shots)
- )
- else:
- lines.append("# anatomy_guard: no shots matched the auto gate")
- body = "\n---\n".join(gens)
- return "\n".join(lines + ["", body]) if body else "\n".join(lines)
-
-
-def annotate_script_debug(gens, anatomy_shots, anatomy_mode, ref_slots):
- parts = [annotate_script_guards(gens, anatomy_shots, anatomy_mode)]
- ref_report = annotate_script_refs(gens, ref_slots)
- if ref_report:
- parts.insert(1, ref_report)
- return "\n".join(part for part in parts if part)
-
-
-def annotate_script_refs(gens, ref_slots):
- """Per-shot reference routing summary for the script socket."""
- lines = []
- normalized_slots = _normalized_ref_slots(ref_slots)
- for shot_index, gen in enumerate(gens or [], 1):
- tagged = _slot_refs_for_text(gen, normalized_slots)
- named = _named_refs_for_text(gen, normalized_slots, kinds=("character", "location"))
- seen = set()
- merged = []
- for slot_number, ref in tagged:
- key = (slot_number, "tag")
- if key in seen:
- continue
- seen.add(key)
- merged.append((slot_number, ref, "tag"))
- for slot_number, ref in named:
- key = (slot_number, "name")
- if key in seen or any(slot_number == existing[0] for existing in merged):
- continue
- seen.add(key)
- merged.append((slot_number, ref, "name"))
- if not merged:
- lines.append(f"# shot {shot_index} refs: none")
- continue
- labels = []
- for slot_number, ref, source in merged:
- label = _reference_text(ref.get("name")) or _reference_text(ref.get("id")) or f"reference {slot_number}"
- labels.append(f"Picture {slot_number} {label} (by {source})")
- lines.append(f"# shot {shot_index} refs: " + "; ".join(labels))
- return "\n".join(lines)
-
-
-def speech_flags(beats):
+ return out
+
+
+def annotate_script_guards(gens, anatomy_shots, anatomy_mode):
+ """Human-readable guard report prepended to the script socket.
+
+ The `script` output is for inspection, not for feeding back into the node, so
+ a compact report there is the quickest way to see whether a guard actually made
+ it into each shot's prompt text.
+ """
+ lines = []
+ if anatomy_mode == "off":
+ lines.append("# anatomy_guard: off")
+ elif anatomy_shots:
+ lines.append(
+ "# anatomy_guard: injected on shot(s) "
+ + ",".join(str(n) for n in anatomy_shots)
+ )
+ else:
+ lines.append("# anatomy_guard: no shots matched the auto gate")
+ body = "\n---\n".join(gens)
+ return "\n".join(lines + ["", body]) if body else "\n".join(lines)
+
+
+def annotate_script_debug(gens, anatomy_shots, anatomy_mode, ref_slots):
+ parts = [annotate_script_guards(gens, anatomy_shots, anatomy_mode)]
+ ref_report = annotate_script_refs(gens, ref_slots)
+ if ref_report:
+ parts.insert(1, ref_report)
+ return "\n".join(part for part in parts if part)
+
+
+def annotate_script_refs(gens, ref_slots):
+ """Per-shot reference routing summary for the script socket."""
+ lines = []
+ for shot_index, gen in enumerate(gens or [], 1):
+ tagged = _slot_refs_for_text(gen, ref_slots)
+ named = _named_character_refs_for_text(gen, ref_slots)
+ seen = set()
+ merged = []
+ for slot_number, ref in tagged:
+ key = (slot_number, "tag")
+ if key in seen:
+ continue
+ seen.add(key)
+ merged.append((slot_number, ref, "tag"))
+ for slot_number, ref in named:
+ key = (slot_number, "name")
+ if key in seen or any(slot_number == existing[0] for existing in merged):
+ continue
+ seen.add(key)
+ merged.append((slot_number, ref, "name"))
+ if not merged:
+ lines.append(f"# shot {shot_index} refs: none")
+ continue
+ labels = []
+ for slot_number, ref, source in merged:
+ label = _reference_text(ref.get("name")) or _reference_text(ref.get("id")) or f"reference {slot_number}"
+ labels.append(f"Picture {slot_number} {label} (by {source})")
+ lines.append(f"# shot {shot_index} refs: " + "; ".join(labels))
+ return "\n".join(lines)
+
+
+def speech_flags(beats):
"""Per-beat: does it contain scripted (quoted) dialogue? Same rule the prompt
builder uses to decide silencing, exposed so the renderer can also MUTE the
audio of non-speech shots -- a deterministic fix when H3 vocalizes anyway."""
@@ -3333,7 +3331,7 @@ def distribute_generations(anchor, beats, gs, music="", char_memory="", auto_war
auto_silence_nonspeech=True, allow_nonspeech_vocals=False, count_subjects=False, front_load=False,
notes_out=None, auto_props=True, prevent_nudity=True,
exposed_terms="", strip_out=None, anatomy_guard=False,
- anatomy_auto=False,
+ anatomy_auto=False,
lock_restraints=True, solidity_guard="auto",
motion_guard="auto", contact_guard="auto", count_auto=False):
"""One beat = one shot. Stamp the permanent identity into each beat. Total
@@ -3383,21 +3381,21 @@ def distribute_generations(anchor, beats, gs, music="", char_memory="", auto_war
# unmatched. Checked after the loop, once the full cast is known.
seen_names = {k for k in active if k}
blocks = []
- for gi, b in enumerate(beats, 1):
- body, wardrobe_change = extract_wardrobe((b or "").strip())
- body, _ = extract_directive(body, "seconds") # shot length, not prose
- body, _ = extract_directive(body, "duration") # ditto (alias)
- body, exit_directive = extract_directive(body, "exit") # explicit 'exit: Jon'
- body, enter_directive = extract_directive(body, "enter") # explicit 'enter: Jon' (undo)
- body, shot_soundscape = extract_directive_aliases(body, ("overall_soundscape", "soundscape"))
- body, shot_music = extract_directive_aliases(body, ("non_diegetic_music", "music"))
- body, shot_anchor_add = extract_directive(body, "anchor_add")
- body, _ = extract_directive(body, "continuity")
- body, _ = extract_directive(body, "ref_mode")
- body, _ = extract_directive(body, "ref_noise_aug")
- if enter_directive:
- for nm in _entries(enter_directive):
- departed.discard(_norm_name(nm))
+ for gi, b in enumerate(beats, 1):
+ body, wardrobe_change = extract_wardrobe((b or "").strip())
+ body, _ = extract_directive(body, "seconds") # shot length, not prose
+ body, _ = extract_directive(body, "duration") # ditto (alias)
+ body, exit_directive = extract_directive(body, "exit") # explicit 'exit: Jon'
+ body, enter_directive = extract_directive(body, "enter") # explicit 'enter: Jon' (undo)
+ body, shot_soundscape = extract_directive_aliases(body, ("overall_soundscape", "soundscape"))
+ body, shot_music = extract_directive_aliases(body, ("non_diegetic_music", "music"))
+ body, shot_anchor_add = extract_directive(body, "anchor_add")
+ body, _ = extract_directive(body, "continuity")
+ body, _ = extract_directive(body, "ref_mode")
+ body, _ = extract_directive(body, "ref_noise_aug")
+ if enter_directive:
+ for nm in _entries(enter_directive):
+ departed.discard(_norm_name(nm))
# Naming a departed character again is intent to have them BACK. Without
# this they stayed departed, so the beat carried their bare NAME with no
# description while everyone else kept theirs -- and the described character
@@ -3550,16 +3548,16 @@ def distribute_generations(anchor, beats, gs, music="", char_memory="", auto_war
zones_bare = [z for z, mk in marks.items() if mk in active[nm]]
if zones_bare:
bare_now[nm] = zones_bare
- persistent = compose_persistent(body, active, anchor_id, removed, departed, count_subjects,
- speaking=has_speech(body), front_load=front_load,
- count_auto=count_auto,
- silence_nonspeech=bool(auto_silence_nonspeech))
- if shot_anchor_add:
- persistent = persistent.rstrip(". ")
- persistent = f"{persistent}. {shot_anchor_add}".strip(". ") if persistent else shot_anchor_add
- # State the DIRECTION of the change, in the shot that performs it. Only for
- # people actually in this shot; an anchor-prose garment is stated
- # impersonally, so it summons nobody.
+ persistent = compose_persistent(body, active, anchor_id, removed, departed, count_subjects,
+ speaking=has_speech(body), front_load=front_load,
+ count_auto=count_auto,
+ silence_nonspeech=bool(auto_silence_nonspeech))
+ if shot_anchor_add:
+ persistent = persistent.rstrip(". ")
+ persistent = f"{persistent}. {shot_anchor_add}".strip(". ") if persistent else shot_anchor_add
+ # State the DIRECTION of the change, in the shot that performs it. Only for
+ # people actually in this shot; an anchor-prose garment is stated
+ # impersonally, so it summons nobody.
speak_off = [(n, it) for n, it in off_now
if not n or person_referenced(body, n, active)]
off_clause = takes_off_clause(speak_off, active)
@@ -3661,22 +3659,22 @@ def distribute_generations(anchor, beats, gs, music="", char_memory="", auto_war
persistent = persistent.rstrip(". ") + "." + clause
silent_shot = no_speech and not allow_vocals
block = f"[Generation {gi}] {persistent}".strip()
- # A silenced shot ALWAYS gets a soundscape line. Leaving the field out is
- # what let H3 improvise a voice track under a shot whose picture was already
- # told to keep its mouth shut -- the babble the lips-closed clause cannot
- # reach, because it only constrains the frames.
- if shot_soundscape:
- if silent_shot:
- block += f"\noverall_soundscape: {shot_soundscape}{NO_VOICE_CLAUSE}"
- elif no_speech:
- block += f"\noverall_soundscape: {shot_soundscape}{NO_VOICE_SPEECH_CLAUSE}"
- else:
- block += f"\noverall_soundscape: {shot_soundscape}"
- elif "soundscape:" not in block.lower():
- if gs:
- if silent_shot:
- block += f"\noverall_soundscape: {gs}{NO_VOICE_CLAUSE}"
- elif no_speech:
+ # A silenced shot ALWAYS gets a soundscape line. Leaving the field out is
+ # what let H3 improvise a voice track under a shot whose picture was already
+ # told to keep its mouth shut -- the babble the lips-closed clause cannot
+ # reach, because it only constrains the frames.
+ if shot_soundscape:
+ if silent_shot:
+ block += f"\noverall_soundscape: {shot_soundscape}{NO_VOICE_CLAUSE}"
+ elif no_speech:
+ block += f"\noverall_soundscape: {shot_soundscape}{NO_VOICE_SPEECH_CLAUSE}"
+ else:
+ block += f"\noverall_soundscape: {shot_soundscape}"
+ elif "soundscape:" not in block.lower():
+ if gs:
+ if silent_shot:
+ block += f"\noverall_soundscape: {gs}{NO_VOICE_CLAUSE}"
+ elif no_speech:
block += f"\noverall_soundscape: {gs}{NO_VOICE_SPEECH_CLAUSE}"
else:
block += f"\noverall_soundscape: {gs}"
@@ -3684,14 +3682,14 @@ def distribute_generations(anchor, beats, gs, music="", char_memory="", auto_war
block += f"\noverall_soundscape: {NO_VOICE_SOUNDSCAPE}"
elif no_speech:
block += f"\noverall_soundscape: {NO_VOICE_SPEECH_SOUNDSCAPE}"
- # Music is OPT-IN: a blank field emits the spec's silence token N/A on every
- # shot, so H3 doesn't improvise a score. (Soundscape is NOT forced to N/A --
- # per the spec it takes N/A only when total silence is explicitly wanted, so a
- # blank soundscape still lets H3 provide ambient sound.)
- if shot_music:
- block += f"\nnon_diegetic_music: {shot_music}"
- elif "non_diegetic_music:" not in block.lower():
- block += f"\nnon_diegetic_music: {music if music else 'N/A'}"
+ # Music is OPT-IN: a blank field emits the spec's silence token N/A on every
+ # shot, so H3 doesn't improvise a score. (Soundscape is NOT forced to N/A --
+ # per the spec it takes N/A only when total silence is explicitly wanted, so a
+ # blank soundscape still lets H3 provide ambient sound.)
+ if shot_music:
+ block += f"\nnon_diegetic_music: {shot_music}"
+ elif "non_diegetic_music:" not in block.lower():
+ block += f"\nnon_diegetic_music: {music if music else 'N/A'}"
blocks.append(block.strip())
# Exits stay DEFERRED, unlike removals: a character has to be visible in the
# shot that shows them leaving, and the frame they leave in is the shot's own
@@ -4008,131 +4006,72 @@ def _silent_audio_latent(audio_vae, frame_count, fps):
return None # never fail a render for a nicety
-def _decode_audio(audio_vae, out_latent):
- latent = out_latent["samples"]
- if latent.is_nested:
- latent = latent.unbind()[-1]
- audio = audio_vae.decode(latent).movedim(-1, 1)
- std = torch.std(audio, dim=[1, 2], keepdim=True) * 5.0
- std[std < 1.0] = 1.0
- audio = audio / std
- sr = getattr(audio_vae, "audio_sample_rate_output", getattr(audio_vae, "audio_sample_rate", 44100))
- return {"waveform": audio, "sample_rate": sr}
-
-
-def _copy_sample_latent(out_latent):
- """Detach a sampled latent to CPU without changing its layout."""
- raw = out_latent.get("samples") if isinstance(out_latent, dict) else None
- if raw is None:
- return None
- try:
- parts = raw.unbind() if hasattr(raw, "unbind") else None
- return ([t.detach().to("cpu", copy=True) for t in parts]
- if parts else raw.detach().to("cpu", copy=True))
- except Exception:
- return None
-
-
-def _latent_with_replaced_samples(template_latent, sampled_latent):
- """Reuse the original latent payload, but swap in freshly sampled tensors."""
- if not isinstance(template_latent, dict):
- return sampled_latent
- out = dict(template_latent)
- if isinstance(sampled_latent, dict):
- for key, value in sampled_latent.items():
- if key != "samples" and key not in out:
- out[key] = value
- if "samples" in sampled_latent:
- out["samples"] = sampled_latent["samples"]
- return out
- return sampled_latent
-
-
-def _video_only_refined_latent(base_latent, refined_latent):
- """Keep the refined video latent, but preserve the original audio latent."""
- base = base_latent.get("samples") if isinstance(base_latent, dict) else None
- refined = refined_latent.get("samples") if isinstance(refined_latent, dict) else None
- if base is None or refined is None:
- return refined_latent
- if not getattr(base, "is_nested", False) or not getattr(refined, "is_nested", False):
- return refined_latent
- try:
- base_parts = base.unbind()
- refined_parts = refined.unbind()
- if len(base_parts) >= 2 and len(refined_parts) >= 1:
- return {"samples": comfy.nested_tensor.NestedTensor((refined_parts[0], base_parts[-1]))}
- except Exception:
- return refined_latent
- return refined_latent
-
-
-def _coerce_bool_flag(value):
- if isinstance(value, str):
- text = value.strip().lower()
- if text in ("", "0", "false", "no", "off", "none", "null"):
- return False
- if text in ("1", "true", "yes", "on"):
- return True
- return bool(value)
-
-
-def _format_elapsed_seconds(seconds):
- seconds = max(0.0, float(seconds or 0.0))
- if seconds >= 3600:
- hours = int(seconds // 3600)
- minutes = int((seconds % 3600) // 60)
- secs = seconds % 60
- return f"{hours}h{minutes:02d}m{secs:04.1f}s"
- if seconds >= 60:
- minutes = int(seconds // 60)
- secs = seconds % 60
- return f"{minutes}m{secs:04.1f}s"
- return f"{seconds:0.1f}s"
-
-
-def _format_timing_note(shot_timings):
- if not shot_timings:
- return ""
- totals = {
- "total": 0.0,
- "retry_elapsed": 0.0,
- "sample": 0.0,
- "detail_sample": 0.0,
- "decode_video": 0.0,
- "decode_audio": 0.0,
- "cleanup": 0.0,
- "retries": 0,
- }
- slowest = None
- for shot in shot_timings:
- totals["total"] += float(shot.get("total", 0.0) or 0.0)
- totals["retry_elapsed"] += float(shot.get("retry_elapsed", 0.0) or 0.0)
- totals["sample"] += float(shot.get("sample", 0.0) or 0.0)
- totals["detail_sample"] += float(shot.get("detail_sample", 0.0) or 0.0)
- totals["decode_video"] += float(shot.get("decode_video", 0.0) or 0.0)
- totals["decode_audio"] += float(shot.get("decode_audio", 0.0) or 0.0)
- totals["cleanup"] += float(shot.get("cleanup", 0.0) or 0.0)
- totals["retries"] += max(0, int(shot.get("attempts", 1) or 1) - 1)
- if slowest is None or float(shot.get("total", 0.0) or 0.0) > float(slowest.get("total", 0.0) or 0.0):
- slowest = shot
- pieces = [
- f"timing: {len(shot_timings)} shot(s) total {_format_elapsed_seconds(totals['total'])}",
- f"sample {_format_elapsed_seconds(totals['sample'])}",
- f"decode video {_format_elapsed_seconds(totals['decode_video'])}",
- f"decode audio {_format_elapsed_seconds(totals['decode_audio'])}",
- f"cleanup {_format_elapsed_seconds(totals['cleanup'])}",
- ]
- if totals["retry_elapsed"]:
- pieces.append(f"retry elapsed {_format_elapsed_seconds(totals['retry_elapsed'])}")
- if totals["detail_sample"]:
- pieces.append(f"detail {_format_elapsed_seconds(totals['detail_sample'])}")
- if totals["retries"]:
- pieces.append(f"retries {totals['retries']}")
- if slowest is not None:
- pieces.append(
- f"slowest shot {int(slowest.get('shot', 0) or 0)} {_format_elapsed_seconds(slowest.get('total', 0.0))}"
- )
- return "; ".join(pieces)
+def _decode_audio(audio_vae, out_latent):
+ latent = out_latent["samples"]
+ if latent.is_nested:
+ latent = latent.unbind()[-1]
+ audio = audio_vae.decode(latent).movedim(-1, 1)
+ std = torch.std(audio, dim=[1, 2], keepdim=True) * 5.0
+ std[std < 1.0] = 1.0
+ audio = audio / std
+ sr = getattr(audio_vae, "audio_sample_rate_output", getattr(audio_vae, "audio_sample_rate", 44100))
+ return {"waveform": audio, "sample_rate": sr}
+
+
+def _copy_sample_latent(out_latent):
+ """Detach a sampled latent to CPU without changing its layout."""
+ raw = out_latent.get("samples") if isinstance(out_latent, dict) else None
+ if raw is None:
+ return None
+ try:
+ parts = raw.unbind() if hasattr(raw, "unbind") else None
+ return ([t.detach().to("cpu", copy=True) for t in parts]
+ if parts else raw.detach().to("cpu", copy=True))
+ except Exception:
+ return None
+
+
+def _latent_with_replaced_samples(template_latent, sampled_latent):
+ """Reuse the original latent payload, but swap in freshly sampled tensors."""
+ if not isinstance(template_latent, dict):
+ return sampled_latent
+ out = dict(template_latent)
+ if isinstance(sampled_latent, dict):
+ for key, value in sampled_latent.items():
+ if key != "samples" and key not in out:
+ out[key] = value
+ if "samples" in sampled_latent:
+ out["samples"] = sampled_latent["samples"]
+ return out
+ return sampled_latent
+
+
+def _video_only_refined_latent(base_latent, refined_latent):
+ """Keep the refined video latent, but preserve the original audio latent."""
+ base = base_latent.get("samples") if isinstance(base_latent, dict) else None
+ refined = refined_latent.get("samples") if isinstance(refined_latent, dict) else None
+ if base is None or refined is None:
+ return refined_latent
+ if not getattr(base, "is_nested", False) or not getattr(refined, "is_nested", False):
+ return refined_latent
+ try:
+ base_parts = base.unbind()
+ refined_parts = refined.unbind()
+ if len(base_parts) >= 2 and len(refined_parts) >= 1:
+ return {"samples": comfy.nested_tensor.NestedTensor((refined_parts[0], base_parts[-1]))}
+ except Exception:
+ return refined_latent
+ return refined_latent
+
+
+def _coerce_bool_flag(value):
+ if isinstance(value, str):
+ text = value.strip().lower()
+ if text in ("", "0", "false", "no", "off", "none", "null"):
+ return False
+ if text in ("1", "true", "yes", "on"):
+ return True
+ return bool(value)
# --- ref2va reference conditioning ----------------------------------------
@@ -4160,18 +4099,18 @@ def ref_image_canvas(w, h, gen_w, gen_h, mode="match"):
return snap(w), snap(h)
-def _build_ref_images(vae, images, gen_w, gen_h, mode="match"):
- """(tokenizer items, DiT blocks) for a list of reference images.
-
- The tokenizer labels each one `:` itself, in the order given here --
- so the roster the prompt refers to is decided by input order, not by anything
- written in the prompt."""
- items, blocks = [], []
- for source in images:
- img = _reference_image(source)
- if img is None:
- continue
- h, w = int(img.shape[1]), int(img.shape[2])
+def _build_ref_images(vae, images, gen_w, gen_h, mode="match"):
+ """(tokenizer items, DiT blocks) for a list of reference images.
+
+ The tokenizer labels each one `:` itself, in the order given here --
+ so the roster the prompt refers to is decided by input order, not by anything
+ written in the prompt."""
+ items, blocks = [], []
+ for source in images:
+ img = _reference_image(source)
+ if img is None:
+ continue
+ h, w = int(img.shape[1]), int(img.shape[2])
tw, th = ref_image_canvas(w, h, gen_w, gen_h, mode)
resized = _resize(img[:1], tw, th, "disabled")
items.append({"type": "image", "data": resized})
@@ -4180,11 +4119,11 @@ def _build_ref_images(vae, images, gen_w, gen_h, mode="match"):
return items, blocks
-def _build_shot_conditioning(clip, vae, prompt, width, height, length, fps, handoff,
- ref_images=None, ref_image_size="match", ref_noise_aug=None,
- audio_vae=None, silent=False):
- latent, fc = _empty_av_latent(width, height, length, fps)
- refs = [r for r in (ref_images or []) if _reference_image(r) is not None]
+def _build_shot_conditioning(clip, vae, prompt, width, height, length, fps, handoff,
+ ref_images=None, ref_image_size="match", ref_noise_aug=None,
+ audio_vae=None, silent=False):
+ latent, fc = _empty_av_latent(width, height, length, fps)
+ refs = [r for r in (ref_images or []) if _reference_image(r) is not None]
if refs:
# ref2va: this shot is reference-conditioned rather than keyframe-conditioned,
# and run() decides which per shot. A tagged shot is handed the previous
@@ -4332,168 +4271,131 @@ def keyframe_rides_with_refs(ref_noise_aug):
_PICTURE_TAG = re.compile(r"<\s*picture[\s_\-]*(\d+)\s*>", re.I)
-def picture_tags(text):
- """The reference slots a shot's text asks for, in ascending order."""
- return sorted({int(m.group(1)) for m in _PICTURE_TAG.finditer(text or "")})
-
-
-def _reference_slot(ref, slot_index=None):
- return _image_nodes.normalize_reference(ref, picture_id=slot_index, allow_image_fallback=True)
-
-
-def _reference_image(ref):
- try:
- if isinstance(ref, dict):
- normalized = _image_nodes.normalize_reference(ref, allow_image_fallback=False)
- else:
- normalized = _reference_slot(ref)
- except Exception:
- return None
- return normalized.get("image")
-
-
-def _reference_text(value):
- return " ".join(str(value or "").split()).strip()
-
-
-def _reference_sentence(value):
- text = _reference_text(value)
- if text and text[-1] not in ".!?":
- text += "."
- return text
-
-
-def _reference_positive_int(value):
- text = _reference_text(value)
- if not text:
- return None
- try:
- parsed = int(text)
- except (TypeError, ValueError):
- return None
- return parsed if parsed > 0 else None
-
-
-def _reference_height_text(facts):
- feet = _reference_text((facts or {}).get("height_feet"))
- inches = _reference_text((facts or {}).get("height_inches"))
- if feet and inches:
- return f"{feet} foot {inches}"
- if feet:
- return f"{feet} foot"
- if inches:
- return f"{inches} inch"
- return ""
-
-
-def _reference_fact_sentence(ref, label):
- facts = dict(ref.get("facts") or {})
- bits = []
- gender = _reference_text(facts.get("gender"))
- age = _reference_positive_int(facts.get("age"))
- nationality = _reference_text(facts.get("nationality"))
- occupation = _reference_text(facts.get("occupation"))
- accent = _reference_text(facts.get("accent"))
- height = _reference_height_text(facts)
- aliases = [_reference_text(alias) for alias in (ref.get("aliases") or []) if _reference_text(alias)]
- if aliases:
- bits.append(f"also known as {aliases[0]}")
- if gender:
- bits.append(gender)
- if age is not None:
- bits.append(f"{age} years old")
- if nationality:
- bits.append(nationality)
- if occupation:
- bits.append(f"works as {occupation}")
- if height:
- bits.append(f"{height} tall")
- if accent:
- bits.append(f"speaks with a {accent} accent")
- if not bits:
- return ""
- return f"Character facts for {label}: " + ", ".join(bits) + "."
-
-
-def _reference_name_keys(ref):
- names = []
- for key in ("name", "id"):
- value = _reference_text(ref.get(key))
- if value:
- names.append(value)
- for alias in ref.get("aliases") or []:
- value = _reference_text(alias)
- if value:
- names.append(value)
- seen = set()
- out = []
- for name in names:
- key = name.lower()
- if key in seen:
- continue
- seen.add(key)
- out.append(name)
- return out
-
-
-def _normalized_ref_slots(ref_slots):
- """Normalize every connected ref once so downstream helpers can reuse them."""
- out = []
- for slot_number, raw in enumerate(ref_slots or [], 1):
- if raw is None:
- out.append(None)
- elif isinstance(raw, dict) and "image" in raw and raw.get("image") is None:
- out.append(_image_nodes.normalize_reference(raw, picture_id=slot_number, allow_image_fallback=False))
- else:
- out.append(_reference_slot(raw, slot_number))
- return tuple(out)
-
-
-def _slot_refs_for_text(text, ref_slots):
- refs = []
- for slot_number in picture_tags(text):
- if not (1 <= slot_number <= len(ref_slots or [])):
- continue
- raw = ref_slots[slot_number - 1]
- ref = _reference_slot(raw, slot_number)
- if _reference_image(ref) is None:
- continue
- refs.append((slot_number, ref))
- return refs
-
-
+def picture_tags(text):
+ """The reference slots a shot's text asks for, in ascending order."""
+ return sorted({int(m.group(1)) for m in _PICTURE_TAG.finditer(text or "")})
+
+
+def _reference_slot(ref, slot_index=None):
+ return _image_nodes.normalize_reference(ref, picture_id=slot_index, allow_image_fallback=True)
+
+
+def _reference_image(ref):
+ try:
+ normalized = _reference_slot(ref)
+ except Exception:
+ return None
+ return normalized.get("image")
+
+
+def _reference_text(value):
+ return " ".join(str(value or "").split()).strip()
+
+
+def _reference_sentence(value):
+ text = _reference_text(value)
+ if text and text[-1] not in ".!?":
+ text += "."
+ return text
+
+
+def _reference_positive_int(value):
+ text = _reference_text(value)
+ if not text:
+ return None
+ try:
+ parsed = int(text)
+ except (TypeError, ValueError):
+ return None
+ return parsed if parsed > 0 else None
+
+
+def _reference_height_text(facts):
+ feet = _reference_text((facts or {}).get("height_feet"))
+ inches = _reference_text((facts or {}).get("height_inches"))
+ if feet and inches:
+ return f"{feet} foot {inches}"
+ if feet:
+ return f"{feet} foot"
+ if inches:
+ return f"{inches} inch"
+ return ""
+
+
+def _reference_fact_sentence(ref, label):
+ facts = dict(ref.get("facts") or {})
+ bits = []
+ gender = _reference_text(facts.get("gender"))
+ age = _reference_positive_int(facts.get("age"))
+ nationality = _reference_text(facts.get("nationality"))
+ occupation = _reference_text(facts.get("occupation"))
+ accent = _reference_text(facts.get("accent"))
+ height = _reference_height_text(facts)
+ aliases = [_reference_text(alias) for alias in (ref.get("aliases") or []) if _reference_text(alias)]
+ if aliases:
+ bits.append(f"also known as {aliases[0]}")
+ if gender:
+ bits.append(gender)
+ if age is not None:
+ bits.append(f"{age} years old")
+ if nationality:
+ bits.append(nationality)
+ if occupation:
+ bits.append(f"works as {occupation}")
+ if height:
+ bits.append(f"{height} tall")
+ if accent:
+ bits.append(f"speaks with a {accent} accent")
+ if not bits:
+ return ""
+ return f"Character facts for {label}: " + ", ".join(bits) + "."
+
+
+def _reference_name_keys(ref):
+ names = []
+ for key in ("name", "id"):
+ value = _reference_text(ref.get(key))
+ if value:
+ names.append(value)
+ for alias in ref.get("aliases") or []:
+ value = _reference_text(alias)
+ if value:
+ names.append(value)
+ seen = set()
+ out = []
+ for name in names:
+ key = name.lower()
+ if key in seen:
+ continue
+ seen.add(key)
+ out.append(name)
+ return out
+
+
+def _slot_refs_for_text(text, ref_slots):
+ refs = []
+ for slot_number in picture_tags(text):
+ if not (1 <= slot_number <= len(ref_slots or [])):
+ continue
+ raw = ref_slots[slot_number - 1]
+ ref = _reference_slot(raw, slot_number)
+ if _reference_image(ref) is None:
+ continue
+ refs.append((slot_number, ref))
+ return refs
+
+
def _named_character_refs_for_text(text, ref_slots):
haystack = str(text or "")
matched = []
for slot_number, raw in enumerate(ref_slots or [], 1):
ref = _reference_slot(raw, slot_number)
- if ref.get("kind") != "character" or _reference_image(ref) is None:
- continue
- for name in _reference_name_keys(ref):
- if re.search(r"\b" + re.escape(name) + r"\b", haystack, re.I):
- matched.append((slot_number, ref))
- break
- return matched
-
-
-def _named_refs_for_text(text, ref_slots, kinds=None):
- haystack = str(text or "")
- matched = []
- wanted = {str(k).strip().lower() for k in (kinds or ()) if str(k).strip()}
- by_name = {}
- for slot_number, ref in enumerate(ref_slots or [], 1):
- if ref is None or _reference_image(ref) is None:
- continue
- if wanted and str(ref.get("kind") or "").strip().lower() not in wanted:
+ if ref.get("kind") != "character" or _reference_image(ref) is None:
continue
for name in _reference_name_keys(ref):
- by_name.setdefault(name.lower(), []).append((slot_number, ref, name))
- for entries in by_name.values():
- if len(entries) != 1:
- continue
- slot_number, ref, name = entries[0]
- if re.search(r"\b" + re.escape(name) + r"\b", haystack, re.I):
- matched.append((slot_number, ref))
+ if re.search(r"\b" + re.escape(name) + r"\b", haystack, re.I):
+ matched.append((slot_number, ref))
+ break
return matched
@@ -4502,98 +4404,97 @@ def _matched_reference_slots(text, ref_slots):
seen = set()
for slot_number, ref in _slot_refs_for_text(text, ref_slots) + _named_character_refs_for_text(text, ref_slots):
if slot_number in seen:
- continue
- seen.add(slot_number)
- matched.append((slot_number, ref))
- return matched
-
-
-def _reference_context_for_text(text, ref_slots):
- parts = []
- for slot_number, ref in _matched_reference_slots(text, ref_slots):
- label = _reference_text(ref.get("name")) or _reference_text(ref.get("id")) or f"reference {slot_number}"
- description = _reference_sentence(ref.get("description"))
- wardrobe = _reference_sentence(ref.get("wardrobe"))
- general = _reference_sentence(ref.get("general"))
- facts = _reference_fact_sentence(ref, label)
- if ref.get("kind") == "location":
- if description:
- parts.append(f"Location context for {label}: {description}")
- if general:
- parts.append(f"Location notes for {label}: {general}")
- continue
- if facts:
- parts.append(facts)
- if description:
- parts.append(f"Persistent appearance for {label}: {description}")
- if wardrobe:
- parts.append(f"Persistent wardrobe/style for {label}: {wardrobe}")
- if general:
- parts.append(f"Character notes for {label}: {general}")
- return " ".join(parts).strip()
-
-
-def _inject_reference_context(block, context):
- if not context:
- return block
- text = str(block or "").strip()
- if not text:
- return context
- match = re.match(r"^(\[Generation \d+\]\s*)", text)
- if not match:
- return f"{context} {text}".strip()
- return re.sub(
- r"^(\[Generation \d+\]\s*)",
- lambda m: m.group(1) + context + " ",
- text,
- count=1,
- )
-
-
-def _reference_character_memory(ref_slots):
- lines = []
- seen = set()
- for slot_number, ref in enumerate(ref_slots or [], 1):
- if ref is None:
- continue
- if ref.get("kind") != "character":
- continue
- wardrobe = _reference_text(ref.get("wardrobe"))
- if not wardrobe:
- continue
- label = (
- _reference_text(ref.get("name"))
- or (_reference_name_keys(ref)[0] if _reference_name_keys(ref) else "")
- )
- line = f"{label} = {wardrobe}" if label else wardrobe
- key = line.lower()
- if key in seen:
- continue
- seen.add(key)
- lines.append(line)
- return "\n".join(lines)
-
-
-def _connected_refs(ref_slots):
- """Connected refs only, preserving slot order and skipping empty sockets."""
- return [ref for ref in (ref_slots or []) if _reference_image(ref) is not None]
-
-
+ continue
+ seen.add(slot_number)
+ matched.append((slot_number, ref))
+ return matched
+
+
+def _reference_context_for_text(text, ref_slots):
+ parts = []
+ for slot_number, ref in _matched_reference_slots(text, ref_slots):
+ label = _reference_text(ref.get("name")) or _reference_text(ref.get("id")) or f"reference {slot_number}"
+ description = _reference_sentence(ref.get("description"))
+ wardrobe = _reference_sentence(ref.get("wardrobe"))
+ general = _reference_sentence(ref.get("general"))
+ facts = _reference_fact_sentence(ref, label)
+ if ref.get("kind") == "location":
+ if description:
+ parts.append(f"Location context for {label}: {description}")
+ if general:
+ parts.append(f"Location notes for {label}: {general}")
+ continue
+ if facts:
+ parts.append(facts)
+ if description:
+ parts.append(f"Persistent appearance for {label}: {description}")
+ if wardrobe:
+ parts.append(f"Persistent wardrobe/style for {label}: {wardrobe}")
+ if general:
+ parts.append(f"Character notes for {label}: {general}")
+ return " ".join(parts).strip()
+
+
+def _inject_reference_context(block, context):
+ if not context:
+ return block
+ text = str(block or "").strip()
+ if not text:
+ return context
+ match = re.match(r"^(\[Generation \d+\]\s*)", text)
+ if not match:
+ return f"{context} {text}".strip()
+ return re.sub(
+ r"^(\[Generation \d+\]\s*)",
+ lambda m: m.group(1) + context + " ",
+ text,
+ count=1,
+ )
+
+
+def _reference_character_memory(ref_slots):
+ lines = []
+ seen = set()
+ for slot_number, raw in enumerate(ref_slots or [], 1):
+ ref = _reference_slot(raw, slot_number)
+ if ref.get("kind") != "character":
+ continue
+ wardrobe = _reference_text(ref.get("wardrobe"))
+ if not wardrobe:
+ continue
+ label = (
+ _reference_text(ref.get("name"))
+ or (_reference_name_keys(ref)[0] if _reference_name_keys(ref) else "")
+ )
+ line = f"{label} = {wardrobe}" if label else wardrobe
+ key = line.lower()
+ if key in seen:
+ continue
+ seen.add(key)
+ lines.append(line)
+ return "\n".join(lines)
+
+
+def _connected_refs(ref_slots):
+ """Connected refs only, preserving slot order and skipping empty sockets."""
+ return [ref for ref in (ref_slots or []) if _reference_image(ref) is not None]
+
+
def resolve_tagged_refs(text, ref_list):
- """(rewritten text, images, dropped) for the tags in ONE shot.
-
- The tokenizer numbers references by their position in the list it is handed, so
- a shot that uses only would receive that image labelled
+ """(rewritten text, images, dropped) for the tags in ONE shot.
+
+ The tokenizer numbers references by their position in the list it is handed, so
+ a shot that uses only would receive that image labelled
and the text would point at nothing. The tags are therefore
RENUMBERED per shot to match what that shot actually carries: slot 2 alone
becomes , slots 2 and 4 become and .
-
- A tag naming a slot with no image connected refers to nothing at all, so it is
- removed from the text rather than left to confuse the encoder, and reported."""
- wanted = picture_tags(text)
- live = [n for n in wanted if 1 <= n <= len(ref_list or []) and ref_list[n - 1] is not None]
- dropped = [n for n in wanted if n not in live]
- renumber = {old: new for new, old in enumerate(live, 1)}
+
+ A tag naming a slot with no image connected refers to nothing at all, so it is
+ removed from the text rather than left to confuse the encoder, and reported."""
+ wanted = picture_tags(text)
+ live = [n for n in wanted if 1 <= n <= len(ref_list or []) and ref_list[n - 1] is not None]
+ dropped = [n for n in wanted if n not in live]
+ renumber = {old: new for new, old in enumerate(live, 1)}
def sub(m):
n = int(m.group(1))
@@ -4605,9 +4506,29 @@ def resolve_tagged_refs(text, ref_list):
out = re.sub(r"(,\s*){2,}", ", ", out)
out = re.sub(r"\s{2,}", " ", out)
return out.strip(), [ref_list[n - 1] for n in live], dropped
+
+
+def resolve_prompt_refs(text, ref_list):
+ """(rewritten text, refs, dropped) for the refs a shot actually carries.
+
+ Explicit tags still decide which slot numbers the prompt points at,
+ but named character matches must ride into the real ref-image list too. Without
+ that split, a shot could inherit the facts/context for "Mara" and "Jon" while
+ only carrying a tagged location image, which reads exactly like the names were
+ understood but the faces were ignored."""
+ rewritten, tagged_refs, dropped = resolve_tagged_refs(text, ref_list)
+ refs = list(tagged_refs)
+ seen = {id(ref) for ref in refs}
+ for _slot_number, ref in _named_character_refs_for_text(rewritten, ref_list):
+ marker = id(ref)
+ if marker in seen:
+ continue
+ seen.add(marker)
+ refs.append(ref)
+ return rewritten, refs, dropped
-def shot_references(ref_list, ref_mode, shot_index, handoff):
+def shot_references(ref_list, ref_mode, shot_index, handoff):
"""Pure: which reference images shot `shot_index` is conditioned on, or [] when
the shot should use the keyframe handoff instead.
@@ -4617,34 +4538,34 @@ def shot_references(ref_list, ref_mode, shot_index, handoff):
mode contributes to the REFERENCE channel; they no longer describe a shot's whole
conditioning, and 'no handoff at all' is no longer a consequence of picking one:
- 'auto ref2v' -- use explicit prompt tags when they exist; otherwise carry
- the references on every shot. This is the ref2v-biased
- default: identity first, no need to tag a single-subject
- chain by hand just to stop refs collapsing to shot 1.
- 'first shot' -- references establish the cast in shot 1; every later shot
- uses the last-frame handoff. Continuity is unbroken and the
- look propagates down the chain, but only through the frames.
+ 'auto ref2v' -- use explicit prompt tags when they exist; otherwise carry
+ the references on every shot. This is the ref2v-biased
+ default: identity first, no need to tag a single-subject
+ chain by hand just to stop refs collapsing to shot 1.
+ 'first shot' -- references establish the cast in shot 1; every later shot
+ uses the last-frame handoff. Continuity is unbroken and the
+ look propagates down the chain, but only through the frames.
'every shot' -- every shot is ref-conditioned. Strongest identity, and no
handoff at all, so shots meet as CUTS rather than as one
continuous take.
'every shot + handoff ref'
-- every shot is ref-conditioned AND the previous shot's last
- frame is appended as one more reference. Continuity comes
- back as a soft signal (the model is shown where the last
- shot ended rather than told to start exactly there), and it
- stays a single ref2va task, so nothing conflicts."""
- refs = _connected_refs(ref_list)
- if not refs:
- return []
- if ref_mode == "auto ref2v":
- return list(refs)
- if ref_mode == "first shot":
- return list(refs) if shot_index == 0 else []
- if ref_mode == "every shot":
- return list(refs)
- if ref_mode == "every shot + handoff ref":
- return list(refs) + ([handoff] if handoff is not None else [])
- return list(refs) if shot_index == 0 else [] # unknown value -> safest
+ frame is appended as one more reference. Continuity comes
+ back as a soft signal (the model is shown where the last
+ shot ended rather than told to start exactly there), and it
+ stays a single ref2va task, so nothing conflicts."""
+ refs = _connected_refs(ref_list)
+ if not refs:
+ return []
+ if ref_mode == "auto ref2v":
+ return list(refs)
+ if ref_mode == "first shot":
+ return list(refs) if shot_index == 0 else []
+ if ref_mode == "every shot":
+ return list(refs)
+ if ref_mode == "every shot + handoff ref":
+ return list(refs) + ([handoff] if handoff is not None else [])
+ return list(refs) if shot_index == 0 else [] # unknown value -> safest
# --- text-encoder / DiT compatibility -------------------------------------
@@ -5738,9 +5659,9 @@ def _evict_all_but(keep_model):
-class H3LongVideos:
- CATEGORY = "Dumas/MiniMax"
- FUNCTION = "run"
+class H3LongVideos:
+ CATEGORY = "Dumas/MiniMax"
+ FUNCTION = "run"
# fps is emitted as BOTH types on purpose: ComfyUI does not coerce between them,
# and the nodes that want a frame rate are split -- CreateVideo / SaveWEBM /
# VHS Video Combine take a FLOAT, while plenty of utility nodes take an INT.
@@ -5750,13 +5671,13 @@ class H3LongVideos:
# output. Inserting mid-list would silently re-target them.
# APPEND to these, never insert. A workflow stores an output link by SLOT INDEX,
# so a new type in the middle silently re-points every link after it.
- RETURN_TYPES = ("IMAGE", "AUDIO", "STRING", "STRING", "INT", "INT", "INT", "FLOAT", "FLOAT", "INT",
- "LATENT", "STRING", "IMAGE", "AUDIO")
- RETURN_NAMES = ("images", "audio", "info", "script", "frames_per_shot", "total_frames",
- "shots", "video_seconds", "fps", "fps_int",
- "latent", "soundscape", "beat_images", "beat_audio")
- OUTPUT_IS_LIST = (False, False, False, False, False, False, False, False, False, False,
- False, False, True, True)
+ RETURN_TYPES = ("IMAGE", "AUDIO", "STRING", "STRING", "INT", "INT", "INT", "FLOAT", "FLOAT", "INT",
+ "LATENT", "STRING", "IMAGE", "AUDIO")
+ RETURN_NAMES = ("images", "audio", "info", "script", "frames_per_shot", "total_frames",
+ "shots", "video_seconds", "fps", "fps_int",
+ "latent", "soundscape", "beat_images", "beat_audio")
+ OUTPUT_IS_LIST = (False, False, False, False, False, False, False, False, False, False,
+ False, False, True, True)
@classmethod
def IS_CHANGED(cls, plan_only=False, **kwargs):
@@ -5823,62 +5744,47 @@ 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,
- "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, ..."}),
+ "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}),
},
"optional": {
- "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
- # prompt and renumbered only for the per-shot tokenizer payload.
- # When a mode uses all connected refs, they keep socket order.
- # The tokenizer labels the carried refs .. in
- # 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 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."}),
- "ref_image_5": ("*", {"tooltip": "Legacy alias for ref_5. Accepts old IMAGE wiring or a REFERENCE payload."}),
- "ref_image_6": ("*", {"tooltip": "Legacy alias for ref_6. Accepts old IMAGE wiring or a REFERENCE payload."}),
- "ref_image_7": ("*", {"tooltip": "Legacy alias for ref_7. 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."}),
- "plan_only": ("BOOLEAN", {"default": False,
- "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."}),
+ "first_frame": ("IMAGE",),
+ # 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
+ # prompt and renumbered only for the per-shot tokenizer payload.
+ # When a mode uses all connected refs, they keep socket order.
+ # The tokenizer labels the carried refs .. in
+ # 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_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."}),
+ "ref_image_5": ("*", {"tooltip": "Legacy alias for ref_5. Accepts old IMAGE wiring or a REFERENCE payload."}),
+ "ref_image_6": ("*", {"tooltip": "Legacy alias for ref_6. Accepts old IMAGE wiring or a REFERENCE payload."}),
+ "ref_image_7": ("*", {"tooltip": "Legacy alias for ref_7. 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."}),
+ "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."}),
"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 "
@@ -5940,27 +5846,21 @@ 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"
- "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."}),
+ "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,
- "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."}),
+ "vram_headroom_gb": ("FLOAT", {"default": 1.5, "min": 0.0, "max": 32.0, "step": 0.5}),
"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."}),
+ "tooltip": "If VRAM is tight, step resolution down instead of failing."}),
# 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.
@@ -6042,33 +5942,30 @@ class H3LongVideos:
"overlay_stroke": ("INT", {"default": 0, "min": 0, "max": 20,
"tooltip": "Black outline thickness in pixels around the white text. 0 keeps it pure "
"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 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 "
- "them to shot 1. That is the better default for single-subject ref2v and "
- "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. In 'auto ref2v', character and "
- "location names in the beat can also pull their matching refs into the "
- "real image-conditioning list; 'where tagged' does NOT do that. "
- "'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,
- "tooltip": "How CLEAN each reference is presented to the model. 0.999 (H3's own "
- "default) hands it a finished, noise-free image -- which invites the "
- "model to REPRODUCE the reference in the opening frames instead of just "
- "taking an identity from it. Lower values blend the condition with "
- "noise and label it as approximate, so it informs the face without "
- "being copied. 0.95 is the ref2v-biased default here; 0.999 keeps the "
- "upstream literal-reference behavior. Too low (below ~0.8) and the "
- "reference stops holding identity at all. Applies ONLY to "
- "ref-conditioned shots -- the last-frame handoff is never weakened, or "
- "continuity would break."}),
+ "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 "
+ "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 "
+ "them to shot 1. That is the better default for single-subject ref2v and "
+ "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' / "
+ "'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,
+ "tooltip": "How CLEAN each reference is presented to the model. 0.999 (H3's own "
+ "default) hands it a finished, noise-free image -- which invites the "
+ "model to REPRODUCE the reference in the opening frames instead of just "
+ "taking an identity from it. Lower values blend the condition with "
+ "noise and label it as approximate, so it informs the face without "
+ "being copied. 0.95 is the ref2v-biased default here; 0.999 keeps the "
+ "upstream literal-reference behavior. Too low (below ~0.8) and the "
+ "reference stops holding identity at all. Applies ONLY to "
+ "ref-conditioned shots -- the last-frame handoff is never weakened, or "
+ "continuity would break."}),
"ref_image_size": (["match", "max"], {"default": "match",
"tooltip": "How large each reference is encoded. 'match' scales it down to the "
"generation's pixel area -- a reference then costs about one frame per "
@@ -6176,7 +6073,7 @@ class H3LongVideos:
"pulls, twists, writhes -- where a limb most often arrives without its "
"path), since a beat where nobody changes orientation has no path to "
"describe. 'on' states it every shot. Names nobody, so it adds no "
- "second reference to anyone already in frame.\n\n"
+ "second reference to anyone already in frame.\n\n"
"A snap right after a cut is a different thing: that is the model "
"leaving the keyframe pose. handoff_offset helps there."}),
"solidity_guard": (["off", "auto", "on"], {"default": "auto",
@@ -6204,7 +6101,7 @@ class H3LongVideos:
"burn a face into every opening frame). 'auto' = on below 768 short edge "
"OR when a LoRA is applied, and also on ANY shot holding two or more "
"people -- spare limbs are grown where bodies meet and move together. "
- "Costs ~90 tokens on shots with people."}),
+ "Costs ~90 tokens on shots with people."}),
"exposed_terms": ("STRING", {"multiline": True, "forceInput": True, "default": "",
"tooltip": "What a stripped body zone is CALLED, per character, so it persists "
"automatically instead of being typed into every beat. Same syntax as "
@@ -6271,12 +6168,12 @@ class H3LongVideos:
"vocalizations. Audio is also left unmuted on those shots. Turn ON "
"when your scene contains distress sounds that H3 would otherwise "
"suppress. Keep auto_silence_nonspeech ON for shots that should be "
- "truly silent."}),
- "character_memory": ("STRING", {"multiline": True, "forceInput": True, "default": "",
- "tooltip": "Optional dedicated wardrobe channel (same role as a 'wardrobe:' line in "
- "the first paragraph -- use whichever you prefer; this field wins if both "
- "are set). Re-stamped into every shot so clothing holds even when the "
- "camera crops it out. IMPORTANT: this is the ONLY place clothing should "
+ "truly silent."}),
+ "character_memory": ("STRING", {"multiline": True, "forceInput": True, "default": "",
+ "tooltip": "Optional dedicated wardrobe channel (same role as a 'wardrobe:' line in "
+ "the first paragraph -- use whichever you prefer; this field wins if both "
+ "are set). Re-stamped into every shot so clothing holds even when the "
+ "camera crops it out. IMPORTANT: this is the ONLY place clothing should "
"live -- keep it out of the anchor prose, or a removal won't stick because "
"the immutable anchor keeps re-adding it. To change/remove an item "
"mid-chain, put 'wardrobe: ' inside the beat where it "
@@ -6285,31 +6182,24 @@ class H3LongVideos:
"'a woman with silver hair'. A noun phrase renders as 'She (a woman with...)', "
"i.e. two subjects in one clause, which causes character duplication. The node "
"strips them automatically, but writing attributes directly is cleaner. "
- "ONE-TOKEN EDITS (no restating the outfit): 'wardrobe: -= jacket' removes "
- "the jacket, 'wardrobe: += sunglasses' adds one. TWO+ PEOPLE: name them -- "
- "'Maya = grey shorts, red jacket; Jon = navy overalls', then edit one at a "
- "time: 'wardrobe: Maya -= jacket' leaves Jon untouched."}),
- "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. 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 for the optional refinement pass. Euler is the maintained default "
- "direction for this lane."}),
- "detail_scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"default": "beta",
- "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": "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. Start "
- "around 0.20-0.35 for gentle cleanup; 0.4+ is stronger and can noticeably "
- "change faces, motion or composition."}),
- },
+ "ONE-TOKEN EDITS (no restating the outfit): 'wardrobe: -= jacket' removes "
+ "the jacket, 'wardrobe: += sunglasses' adds one. TWO+ PEOPLE: name them -- "
+ "'Maya = grey shorts, red jacket; Jon = navy overalls', then edit one at a "
+ "time: 'wardrobe: Maya -= jacket' leaves Jon untouched."}),
+ "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."}),
+ "detail_sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"default": "euler",
+ "tooltip": "Sampler used for the optional refinement pass."}),
+ "detail_scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"default": "beta",
+ "tooltip": "Scheduler used for the optional refinement pass."}),
+ "detail_steps": ("INT", {"default": 8, "min": 1, "max": 200,
+ "tooltip": "Steps for the optional refinement pass."}),
+ "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."}),
+ },
# 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
# place it survives. Named 'graph'/'node_id' rather than the usual
@@ -6330,82 +6220,55 @@ class H3LongVideos:
opt[name] = opt.pop(name) # re-insert at the end, value unchanged
return schema
- def _render(self, model, clip, vae, audio_vae, negative, prompt, w, h, ln, fps, tiled, sa,
- handoff, decode_tile_frames=0, decode_tile_size=0,
- refs=None, ref_image_size="match", ref_noise_aug=None, silent=False,
- detail_pass=False, detail_sampler_name="euler", detail_scheduler="beta",
- detail_steps=8, detail_denoise=0.4, timing_sink=None):
- timing = {"sample": 0.0, "detail_sample": 0.0, "decode_video": 0.0, "decode_audio": 0.0, "cleanup": 0.0}
- positive, latent = _build_shot_conditioning(clip, vae, prompt, w, h, ln, fps, handoff,
- ref_images=refs, ref_image_size=ref_image_size,
- ref_noise_aug=ref_noise_aug,
- audio_vae=audio_vae, silent=silent)
- seed, steps, cfg, sn, sch, denoise = sa
+ def _render(self, model, clip, vae, audio_vae, negative, prompt, w, h, ln, fps, tiled, sa,
+ handoff, decode_tile_frames=0, decode_tile_size=0,
+ refs=None, ref_image_size="match", ref_noise_aug=None, silent=False,
+ detail_pass=False, detail_sampler_name="euler", detail_scheduler="beta",
+ detail_steps=8, detail_denoise=0.4):
+ positive, latent = _build_shot_conditioning(clip, vae, prompt, w, h, ln, fps, handoff,
+ ref_images=refs, ref_image_size=ref_image_size,
+ ref_noise_aug=ref_noise_aug,
+ audio_vae=audio_vae, silent=silent)
+ seed, steps, cfg, sn, sch, denoise = sa
# Conditioning is built, so the text encoder and VAEs are dead weight for the
# whole sampling loop -- evict them and keep only the DiT on the card.
_evict_all_but(model)
- try:
- sample_start = time.perf_counter()
- (out,) = nodes.common_ksampler(model, seed, steps, cfg, sn, sch, positive, negative,
- latent, denoise=denoise)
- timing["sample"] += time.perf_counter() - sample_start
- except Exception as e:
+ try:
+ (out,) = nodes.common_ksampler(model, seed, steps, cfg, sn, sch, positive, negative,
+ latent, denoise=denoise)
+ except Exception as e:
# Mark WHERE this failed. `tiled` only affects the DECODE, so the caller's
# OOM retry cannot help an OOM raised here -- it just re-runs the whole
# sampling pass and fails the same way, which on a 362-frame shot is four
# more minutes for nothing.
- if _is_oom(e):
- e._h3_stage = "sampling"
- raise
- refined_out = out
- detail_pass = _coerce_bool_flag(detail_pass)
- if detail_pass:
- detail_latent = _latent_with_replaced_samples(latent, out)
- try:
- detail_start = time.perf_counter()
- (refined_out,) = nodes.common_ksampler(
- model, seed, int(detail_steps), cfg, detail_sampler_name, detail_scheduler,
- positive, negative, detail_latent, denoise=float(detail_denoise))
- timing["detail_sample"] += time.perf_counter() - detail_start
- except Exception as e:
- if _is_oom(e):
- e._h3_stage = "sampling"
- raise
- refined_out = _video_only_refined_latent(out, refined_out)
+ if _is_oom(e):
+ e._h3_stage = "sampling"
+ raise
+ refined_out = out
+ detail_pass = _coerce_bool_flag(detail_pass)
+ if detail_pass:
+ detail_latent = _latent_with_replaced_samples(latent, out)
+ try:
+ (refined_out,) = nodes.common_ksampler(
+ model, seed, int(detail_steps), cfg, detail_sampler_name, detail_scheduler,
+ positive, negative, detail_latent, denoise=float(detail_denoise))
+ except Exception as e:
+ if _is_oom(e):
+ e._h3_stage = "sampling"
+ raise
+ refined_out = _video_only_refined_latent(out, refined_out)
# Keep a CPU copy of the sampled latent BEFORE decoding, for the `latent`
# output. Latents are ~1000x smaller than the frames they decode to (a
# 1344x768 124f shot is ~1.5MB against ~1.5GB), so carrying one per shot for
# the whole chain is free. Detached and moved off the card immediately, for
# the same reason the decoded frames are.
- decode_video_start = time.perf_counter()
shot_latent = _copy_sample_latent(refined_out)
- try:
- video = _decode_video(vae, refined_out, tiled, free_first=model,
- tile_t=decode_tile_frames, tile_xy=decode_tile_size)
- except Exception as e:
- # Decode is the biggest allocation in the run. If the straight path
- # fails, retry once with tiled decode and without the aggressive unload
- # so a marginal card can still finish the render.
- if not _is_oom(e) and "decode" not in str(e).lower():
- raise
- mm.soft_empty_cache(True)
- retry_tiled = True
- retry_tile_t = decode_tile_frames or 16
- retry_tile_xy = decode_tile_size or 256
- video = _decode_video(vae, refined_out, retry_tiled, free_first=None,
- tile_t=retry_tile_t, tile_xy=retry_tile_xy)
- timing["decode_video"] += time.perf_counter() - decode_video_start
- decode_audio_start = time.perf_counter()
- audio = _decode_audio(audio_vae, out)
- timing["decode_audio"] += time.perf_counter() - decode_audio_start
- cleanup_start = time.perf_counter()
- del out, refined_out, positive, latent
- _deep_cleanup()
- timing["cleanup"] += time.perf_counter() - cleanup_start
- if timing_sink is not None:
- timing["total"] = sum(timing.values())
- timing_sink.append(timing)
- return video, audio, shot_latent
+ video = _decode_video(vae, refined_out, tiled, free_first=model,
+ tile_t=decode_tile_frames, tile_xy=decode_tile_size)
+ audio = _decode_audio(audio_vae, out)
+ del out, refined_out, positive, latent
+ _deep_cleanup()
+ return video, audio, shot_latent
def run(self, model, clip, vae, audio_vae, prompt, resolution,
steps, cfg, sampler_name, scheduler, seed,
@@ -6423,24 +6286,24 @@ class H3LongVideos:
solidity_guard="auto", motion_guard="auto", contact_guard="auto",
auto_soundscape="fill if blank",
auto_silence_nonspeech=True, allow_nonspeech_vocals=False,
- subject_count_guard="auto",
+ subject_count_guard="auto",
upscale="off", upscale_model="none",
upscale_target_short_edge=0, upscale_batch=4,
mute_nonspeech_audio=True, mute_fade_ms=40,
watermark_text="", watermark_position="bottom-right", watermark_size=4.0,
watermark_opacity=0.75, watermark_margin=3.0,
- intro_text="", intro_position="center", intro_seconds=3.0, intro_fade=0.6,
- intro_size=9.0, overlay_font="arial.ttf", overlay_stroke=0,
- ref_1=None, ref_2=None, ref_3=None, ref_4=None,
- ref_5=None, ref_6=None, ref_7=None, ref_8=None,
- ref_9=None,
- ref_image_1=None, ref_image_2=None, ref_image_3=None, ref_image_4=None,
- ref_image_5=None, ref_image_6=None, ref_image_7=None, ref_image_8=None,
- ref_image_9=None,
- ref_mode="auto ref2v", ref_image_size="match", ref_noise_aug=0.95,
- detail_pass=False, detail_sampler_name="euler", detail_scheduler="beta",
- detail_steps=8, detail_denoise=0.4,
- graph=None, node_id=None):
+ intro_text="", intro_position="center", intro_seconds=3.0, intro_fade=0.6,
+ intro_size=9.0, overlay_font="arial.ttf", overlay_stroke=0,
+ ref_1=None, ref_2=None, ref_3=None, ref_4=None,
+ ref_5=None, ref_6=None, ref_7=None, ref_8=None,
+ ref_9=None,
+ ref_image_1=None, ref_image_2=None, ref_image_3=None, ref_image_4=None,
+ ref_image_5=None, ref_image_6=None, ref_image_7=None, ref_image_8=None,
+ ref_image_9=None,
+ ref_mode="auto ref2v", ref_image_size="match", ref_noise_aug=0.95,
+ detail_pass=False, detail_sampler_name="euler", detail_scheduler="beta",
+ detail_steps=8, detail_denoise=0.4,
+ graph=None, node_id=None):
# FIRST: detect a checkpoint swap since the previous execution and hard-flush.
# A stale resident model from a different checkpoint would otherwise poison
@@ -6454,31 +6317,28 @@ class H3LongVideos:
# H3 renders 24 fps, always. Honor the widget only as a warning: a lower value
# used to silently shorten every shot (10s -> 124f -> 5.2s of real time).
- fps_note = ("" if int(fps) == H3_FPS else
- f"fps widget is {int(fps)} but H3 always renders {H3_FPS} fps -- all durations "
- f"computed at {H3_FPS}; set your video-save node to {H3_FPS} too")
- fps = H3_FPS
- w, h = parse_resolution(resolution)
- legacy_ref_slots = (
- ref_image_1, ref_image_2, ref_image_3, ref_image_4, ref_image_5,
- ref_image_6, ref_image_7, ref_image_8, ref_image_9,
- )
- direct_ref_slots = tuple(
- current if current is not None else legacy
- for current, legacy in zip((
- ref_1, ref_2, ref_3, ref_4, ref_5,
- ref_6, ref_7, ref_8, ref_9,
- ), legacy_ref_slots)
- )
- ref_slots = direct_ref_slots
- normalized_ref_slots = _normalized_ref_slots(ref_slots)
- connected_refs = [ref for ref in normalized_ref_slots if _reference_image(ref) is not None]
- direct_ref_count = len(connected_refs)
- explicit_character_memory = (character_memory or "").strip()
- derived_character_memory = _reference_character_memory(ref_slots)
- effective_character_memory = explicit_character_memory or derived_character_memory
- # A pixel budget overrides the preset's SIZE while keeping its aspect ratio,
- # so the dropdown chooses the shape and this chooses how big. Scaling from
+ fps_note = ("" if int(fps) == H3_FPS else
+ f"fps widget is {int(fps)} but H3 always renders {H3_FPS} fps -- all durations "
+ f"computed at {H3_FPS}; set your video-save node to {H3_FPS} too")
+ fps = H3_FPS
+ w, h = parse_resolution(resolution)
+ legacy_ref_slots = (
+ ref_image_1, ref_image_2, ref_image_3, ref_image_4, ref_image_5,
+ ref_image_6, ref_image_7, ref_image_8, ref_image_9,
+ )
+ direct_ref_slots = tuple(
+ current if current is not None else legacy
+ for current, legacy in zip((
+ ref_1, ref_2, ref_3, ref_4, ref_5,
+ ref_6, ref_7, ref_8, ref_9,
+ ), legacy_ref_slots)
+ )
+ ref_slots = direct_ref_slots
+ direct_ref_count = len(_connected_refs(direct_ref_slots))
+ derived_character_memory = _reference_character_memory(ref_slots)
+ effective_character_memory = (character_memory or "").strip() or derived_character_memory
+ # A pixel budget overrides the preset's SIZE while keeping its aspect ratio,
+ # so the dropdown chooses the shape and this chooses how big. Scaling from
# the preset's own dimensions is what makes 1.00MP reproduce each native
# size exactly -- the preset NAMES are approximations (1344x768 is 7:4, not
# 16:9), so computing from a nominal ratio would not.
@@ -6507,17 +6367,17 @@ class H3LongVideos:
# Patch the dual video/audio schedule onto the model here, so a missing
# upstream ModelSamplingMiniMaxH3 can't silently produce gibberish audio.
# Shifts come from the widgets (12/3 base default; MXFP8/turbo differ).
- ms_note = ""
- if apply_model_sampling:
- model, ms_note = apply_h3_model_sampling(model, shift_video, shift_audio)
- detail_note = ""
- if detail_pass:
- detail_note = (f" detail pass: {int(detail_steps)} step(s) via "
- f"{detail_sampler_name}/{detail_scheduler} at denoise "
- f"{float(detail_denoise):.2f}; video-only refinement keeps "
- f"audio from the first pass")
-
- paras = split_paragraphs(prompt, "##")
+ ms_note = ""
+ if apply_model_sampling:
+ model, ms_note = apply_h3_model_sampling(model, shift_video, shift_audio)
+ detail_note = ""
+ if detail_pass:
+ detail_note = (f" detail pass: {int(detail_steps)} step(s) via "
+ f"{detail_sampler_name}/{detail_scheduler} at denoise "
+ f"{float(detail_denoise):.2f}; video-only refinement keeps "
+ f"audio from the first pass")
+
+ paras = split_paragraphs(prompt, "##")
if anchor_override.strip():
anchor, beat_paras = anchor_override.strip(), paras
elif paras:
@@ -6530,9 +6390,9 @@ class H3LongVideos:
# anchor to avoid introducing them twice). Keep it as a BEAT and say so loudly,
# rather than losing a shot and the scene text along with it.
anchor_note = ""
- if (not anchor_override.strip()) and paras and \
- (anchor_contributes_nothing(anchor, effective_character_memory)
- or anchor_is_action_beat(anchor, paras[1:])):
+ if (not anchor_override.strip()) and paras and \
+ (anchor_contributes_nothing(anchor, effective_character_memory)
+ or anchor_is_action_beat(anchor, paras[1:])):
preview = " ".join(anchor.split())[:60]
anchor, beat_paras = "", paras
anchor_note = (
@@ -6675,33 +6535,30 @@ class H3LongVideos:
"BABBLE RISK -- " + "; ".join(filler_warnings)
+ ". Turn per_beat_length ON to size these shots from their line, or set "
"'seconds:' on the beat")
- wardrobe_notes = []
- strip_shots = [] # shots that newly bared a zone -> the NEXT shot starts fresh
- gens = distribute_generations(anchor, beats, global_soundscape.strip(),
- non_diegetic_music.strip(), effective_character_memory,
- auto_wardrobe, auto_silence_nonspeech, allow_nonspeech_vocals, count_subjects,
- lora_on, notes_out=wardrobe_notes, auto_props=auto_props,
- prevent_nudity=prevent_nudity,
- exposed_terms=exposed_terms, strip_out=strip_shots,
- anatomy_guard=anatomy_on,
- anatomy_auto=anatomy_auto,
- lock_restraints=lock_restraints,
- solidity_guard=solidity_guard,
- motion_guard=motion_guard,
- contact_guard=contact_guard,
- count_auto=(subject_count_guard == "auto"))
- enriched_gens = []
- for block in gens:
- context = _reference_context_for_text(
- block,
- ref_slots,
- )
- if context:
- block = _inject_reference_context(block, context)
- enriched_gens.append(block)
- gens = enriched_gens
-
- # A scenery beat mid-chain hands the next shot a frame with no people in
+ wardrobe_notes = []
+ strip_shots = [] # shots that newly bared a zone -> the NEXT shot starts fresh
+ gens = distribute_generations(anchor, beats, global_soundscape.strip(),
+ non_diegetic_music.strip(), effective_character_memory,
+ auto_wardrobe, auto_silence_nonspeech, allow_nonspeech_vocals, count_subjects,
+ lora_on, notes_out=wardrobe_notes, auto_props=auto_props,
+ prevent_nudity=prevent_nudity,
+ exposed_terms=exposed_terms, strip_out=strip_shots,
+ anatomy_guard=anatomy_on,
+ anatomy_auto=anatomy_auto,
+ lock_restraints=lock_restraints,
+ solidity_guard=solidity_guard,
+ motion_guard=motion_guard,
+ contact_guard=contact_guard,
+ count_auto=(subject_count_guard == "auto"))
+ enriched_gens = []
+ for block in gens:
+ context = _reference_context_for_text(block, ref_slots)
+ if context:
+ block = _inject_reference_context(block, context)
+ enriched_gens.append(block)
+ gens = enriched_gens
+
+ # A scenery beat mid-chain hands the next shot a frame with no people in
# it. Both prompts are individually correct, so this is invisible without
# looking at the sequence -- which is why chains lose their cast in the
# middle rather than degrading steadily.
@@ -6716,40 +6573,40 @@ class H3LongVideos:
f"{seed}..{seed + len(gens) - 1} rather than one field. Stochastic detail "
f"resets at every boundary, which looks like a cut in a continuous take. "
f"Turn it off unless the beats are meant to look separately shot")
- preflight = [("SLA", sla_note),
- ("LORA HINTS", "; ".join(hint_notes)),
- ("", mp_note),
- ("SCHEDULE", sched_note),
- ("KERNELS", kernel_note),
- ("AUDIO", audio_ratio_note),
- ("CONTINUITY", "; ".join(cohesion_notes)),
- ("SOUND", sound_note)]
- preflight_txt = "".join(f"{(lbl + ' -- ') if lbl else ''}{txt}. "
- for lbl, txt in preflight if txt)
- override_notes = [beat_override_summary(beat, index) for index, beat in enumerate(beats, 1)]
- override_notes = [note for note in override_notes if note]
- any_tags_anywhere = any(picture_tags(g) for g in gens)
- anatomy_shots = [
- shot_index + 1
- for shot_index, gen in enumerate(gens)
- if ANATOMY_STATE.strip() in gen
- ]
- anatomy_mode = ("forced" if anatomy_guard == "on" else
- "auto" if anatomy_guard == "auto" else
- "off")
- anatomy_note = ""
- if anatomy_shots:
- if anatomy_mode == "forced":
- anatomy_note = (f" ANATOMY -- guard injected on shot(s) "
- f"{','.join(str(n) for n in anatomy_shots)}")
- elif anatomy_mode == "auto":
- anatomy_note = (f" ANATOMY -- guard injected on shot(s) "
- f"{','.join(str(n) for n in anatomy_shots)} "
- f"(auto: multi-person beats and/or sub-native or LoRA-biased runs)")
-
- if plan_only:
- # Preview the split using THIS node's own settings -- no render, near-instant.
- shots = len(gens)
+ preflight = [("SLA", sla_note),
+ ("LORA HINTS", "; ".join(hint_notes)),
+ ("", mp_note),
+ ("SCHEDULE", sched_note),
+ ("KERNELS", kernel_note),
+ ("AUDIO", audio_ratio_note),
+ ("CONTINUITY", "; ".join(cohesion_notes)),
+ ("SOUND", sound_note)]
+ preflight_txt = "".join(f"{(lbl + ' -- ') if lbl else ''}{txt}. "
+ for lbl, txt in preflight if txt)
+ override_notes = [beat_override_summary(beat, index) for index, beat in enumerate(beats, 1)]
+ override_notes = [note for note in override_notes if note]
+ any_tags_anywhere = any(picture_tags(g) for g in gens)
+ anatomy_shots = [
+ shot_index + 1
+ for shot_index, gen in enumerate(gens)
+ if ANATOMY_STATE.strip() in gen
+ ]
+ anatomy_mode = ("forced" if anatomy_guard == "on" else
+ "auto" if anatomy_guard == "auto" else
+ "off")
+ anatomy_note = ""
+ if anatomy_shots:
+ if anatomy_mode == "forced":
+ anatomy_note = (f" ANATOMY -- guard injected on shot(s) "
+ f"{','.join(str(n) for n in anatomy_shots)}")
+ elif anatomy_mode == "auto":
+ anatomy_note = (f" ANATOMY -- guard injected on shot(s) "
+ f"{','.join(str(n) for n in anatomy_shots)} "
+ f"(auto: multi-person beats and/or sub-native or LoRA-biased runs)")
+
+ if plan_only:
+ # Preview the split using THIS node's own settings -- no render, near-instant.
+ shots = len(gens)
plan_lens = (lens + [ln] * shots)[:shots]
total = round(sum(plan_lens) / fps, 2)
uniform = len(set(plan_lens)) == 1
@@ -6771,69 +6628,69 @@ class H3LongVideos:
else "prompt/soundscape silencing only"))
# Same reference accounting the render reports: which shots lose the
# handoff is a composition decision, so it belongs in the preview.
- n_refs = len(connected_refs)
- plan_ref = ""
- if n_refs:
- # Mirror the render's placement exactly: 'where tagged' reads the
- # prompts and falls back to first shot when nothing is tagged --
- # reporting by ref_mode alone described shots the render never gave
- # references to.
- tagged_used = False
- on = []
- effective_modes = []
- for shot_index, gen in enumerate(gens):
- shot_mode = beat_ref_mode_directive(beats[shot_index] if shot_index < len(beats) else "") or ref_mode
- if shot_mode in ("where tagged", "auto ref2v") and any_tags_anywhere:
- if resolve_tagged_refs(gen, ref_slots)[1]:
- on.append(shot_index + 1)
- tagged_used = True
- else:
- mode_eff = ("every shot" if shot_mode == "auto ref2v"
- else "first shot" if shot_mode == "where tagged" else shot_mode)
- effective_modes.append(mode_eff)
- if shot_references(ref_slots, mode_eff, shot_index, 1 if shot_index else None):
- on.append(shot_index + 1)
- if tagged_used:
- how = "placed by tags"
- else:
- distinct_modes = list(dict.fromkeys(effective_modes))
- mode_eff = distinct_modes[0] if len(distinct_modes) == 1 else "per-shot overrides"
- global_tag_mode = ref_mode in ("where tagged", "auto ref2v")
- how = (f"ref_mode '{mode_eff}'"
- + (" -- no tags found anywhere" if global_tag_mode and not any_tags_anywhere else ""))
- src = []
- if direct_ref_count:
- src.append(f"{direct_ref_count} direct")
- plan_ref = (f" ref2va: {n_refs} reference image(s) at '{ref_image_size}' on shot(s) "
- f"{','.join(str(n) for n in on) or 'none'} ({how}) -> those shots keep "
- f"the previous frame as their keyframe too, unless ref_noise_aug was lowered"
- + (f" [source: {', '.join(src)}]" if src else ""))
+ n_refs = len(_connected_refs(ref_slots))
+ plan_ref = ""
+ if n_refs:
+ # Mirror the render's placement exactly: 'where tagged' reads the
+ # prompts and falls back to first shot when nothing is tagged --
+ # reporting by ref_mode alone described shots the render never gave
+ # references to.
+ tagged_used = False
+ on = []
+ effective_modes = []
+ for shot_index, gen in enumerate(gens):
+ shot_mode = beat_ref_mode_directive(beats[shot_index] if shot_index < len(beats) else "") or ref_mode
+ if shot_mode in ("where tagged", "auto ref2v") and any_tags_anywhere:
+ if resolve_prompt_refs(gen, ref_slots)[1]:
+ on.append(shot_index + 1)
+ tagged_used = True
+ else:
+ mode_eff = ("every shot" if shot_mode == "auto ref2v"
+ else "first shot" if shot_mode == "where tagged" else shot_mode)
+ effective_modes.append(mode_eff)
+ if shot_references(ref_slots, mode_eff, shot_index, 1 if shot_index else None):
+ on.append(shot_index + 1)
+ if tagged_used:
+ how = "placed by tags"
+ else:
+ distinct_modes = list(dict.fromkeys(effective_modes))
+ mode_eff = distinct_modes[0] if len(distinct_modes) == 1 else "per-shot overrides"
+ global_tag_mode = ref_mode in ("where tagged", "auto ref2v")
+ how = (f"ref_mode '{mode_eff}'"
+ + (" -- no tags found anywhere" if global_tag_mode and not any_tags_anywhere else ""))
+ src = []
+ if direct_ref_count:
+ src.append(f"{direct_ref_count} direct")
+ plan_ref = (f" ref2va: {n_refs} reference image(s) at '{ref_image_size}' on shot(s) "
+ f"{','.join(str(n) for n in on) or 'none'} ({how}) -> those shots keep "
+ f"the previous frame as their keyframe too, unless ref_noise_aug was lowered"
+ + (f" [source: {', '.join(src)}]" if src else ""))
plan = ((anchor_note + " ") if anchor_note else "") + \
preflight_txt + \
(("DIALOGUE MAY BE CUT OFF -- " + "; ".join(fit_warnings) + ". ") if fit_warnings else "") + \
(f"PLAN (no render): {shape} = ~{total:g}s at {w}x{h}. "
f"{len(beats) or 1} beat(s). decode {'tiled' if tiled else 'full'}. {vram_str}."
- + (f" {beats_note}." if beats_note else "")
- + (" ANCHOR: " + "; ".join(anchor_hazards) + "."
- if anchor_hazards else "")
- + (f"{anatomy_note}." if anatomy_note else "")
- + (f"{plan_audio}." if plan_audio else "")
- + (" EXPOSURE -- " + "; ".join(wardrobe_notes) + "."
- if wardrobe_notes else "")
- + (" OVERRIDES -- " + "; ".join(override_notes) + "."
- if override_notes else "")
- + (f"{plan_ref}." if plan_ref else "")
- + (f" {fps_note}." if fps_note else "")
- + (f" {ln_note}." if ln_note else ""))
+ + (f" {beats_note}." if beats_note else "")
+ + (" ANCHOR: " + "; ".join(anchor_hazards) + "."
+ if anchor_hazards else "")
+ + (f"{anatomy_note}." if anatomy_note else "")
+ + (f"{plan_audio}." if plan_audio else "")
+ + (" EXPOSURE -- " + "; ".join(wardrobe_notes) + "."
+ if wardrobe_notes else "")
+ + (" OVERRIDES -- " + "; ".join(override_notes) + "."
+ if override_notes else "")
+ + (f"{plan_ref}." if plan_ref else "")
+ + (f" {fps_note}." if fps_note else "")
+ + (f" {ln_note}." if ln_note else ""))
ph_img = torch.zeros((1, 64, 64, 3))
ph_audio = {"waveform": torch.zeros((1, 2, 1)), "sample_rate": 44100}
# plan_only samples nothing, so there is no latent to hand out. Emit a
# correctly-SHAPED empty one rather than None: a downstream LATENT input
# would choke on None, and this keeps the preview wireable exactly like
# a real run.
- return (ph_img, ph_audio, plan, annotate_script_debug(gens, anatomy_shots, anatomy_mode, ref_slots), max(plan_lens),
- sum(plan_lens), shots, total, float(fps), int(fps),
- _empty_av_latent(w, h, 5, fps)[0], global_soundscape, [], [])
+ return (ph_img, ph_audio, plan, annotate_script_debug(gens, anatomy_shots, anatomy_mode, ref_slots), max(plan_lens),
+ sum(plan_lens), shots, total, float(fps), int(fps),
+ _empty_av_latent(w, h, 5, fps)[0], global_soundscape, [], [])
spk = speech_flags(beats) # which shots have real (quoted) dialogue
vram_trace = [] # free VRAM after each shot
@@ -6843,130 +6700,120 @@ class H3LongVideos:
latent_chunks = [] # per-shot sampled latents, pre-decode
mouth_settled = [] # shots seeded from a settled (closed) mouth
handoff, sr = first_frame, None
- connected_ref_count = len(connected_refs)
- ref_shots = [] # which shots ended up ref-conditioned
- ref_missing = [] # 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
+ connected_ref_count = len(_connected_refs(ref_slots))
+ ref_shots = [] # which shots ended up ref-conditioned
+ ref_missing = [] # 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 = []
+ 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, gen_prompt in enumerate(gens):
# denoise is fixed at 1.0 (partial denoise desyncs the joint AV schedule).
- sa = (seed + i if vary_seed_per_shot else seed, steps, cfg, sampler_name, scheduler, 1.0)
- ln_i = shot_lens[i] # this beat's own length (<= the VRAM ceiling)
- # Which conditioning channels this shot carries is decided here; see
- # _build_shot_conditioning for how they are packed. On ComfyUI 0.31+ a
- # shot may carry BOTH references and a keyframe.
- beat_text = beats[i] if i < len(beats) else ""
- shot_mode = beat_ref_mode_directive(beat_text) or ref_mode
- shot_continuity = beat_continuity_directive(beat_text) or "auto"
- shot_ref_noise_aug = beat_ref_noise_aug_directive(beat_text)
- shot_aug = ref_noise_aug if shot_ref_noise_aug is None else shot_ref_noise_aug
- shot_tag_driven = bool(connected_ref_count) and shot_mode in ("where tagged", "auto ref2v") and any_tags_anywhere
- if shot_tag_driven:
- shot_mode_eff = shot_mode
- else:
- shot_mode_eff = ("every shot" if shot_mode == "auto ref2v"
- else "first shot" if shot_mode == "where tagged" else shot_mode)
- carry_keyframe = False # tagged shot keeps its handoff as a keyframe
- if shot_tag_driven:
- # The prompt itself says where each reference belongs: the shot whose
- # text names gets image N, renumbered to match what that
- # shot actually carries. Every untagged shot keeps its handoff.
- gen_prompt, shot_refs, dropped = resolve_tagged_refs(gen_prompt, ref_slots)
- for n in dropped:
- if n not in ref_missing:
- ref_missing.append(n)
- else:
- shot_refs = shot_references(ref_slots, shot_mode_eff, i, handoff)
- # A shot that follows a strip starts FRESH. Continuing from a frame that
- # still shows the garment is how it reappears -- the picture outvotes the
- # text every time. Costs a cut exactly where the state changes, which is
- # where a cut belongs anyway.
- # No scripted line -> anchor this shot's audio branch to silence.
- shot_silent = bool(auto_silence_nonspeech and not allow_nonspeech_vocals and i < len(spk) and not spk[i])
- after_strip = i in strip_shots # strip_shots is 1-based, i is 0-based
- if handoff is not None and shot_refs and shot_tag_driven and keyframe_rides_with_refs(shot_aug):
- carry_keyframe = True
- elif handoff is not None and shot_refs and shot_tag_driven:
- if handoff not in shot_refs:
- shot_refs = shot_refs + [handoff]
- if (i + 1) not in ref_carried:
- ref_carried.append(i + 1)
- elif handoff is not None and shot_refs and keyframe_rides_with_refs(shot_aug):
- carry_keyframe = True
- shot_refs = [r for r in shot_refs if r is not handoff]
- elif handoff is not None and shot_refs and shot_mode_eff == "every shot + handoff ref":
- if (i + 1) not in ref_carried:
- ref_carried.append(i + 1)
-
- if after_strip:
- shot_refs = [r for r in shot_refs if r is not handoff]
- shot_handoff = None
- carry_keyframe = False
- continuity_label = "hard cut (post-strip)"
- elif shot_continuity == "hard cut":
- shot_refs = [r for r in shot_refs if r is not handoff]
- shot_handoff = None
- carry_keyframe = False
- continuity_label = "hard cut"
- elif shot_continuity == "keyframe carry":
- shot_refs = [r for r in shot_refs if r is not handoff]
- shot_handoff = handoff
- carry_keyframe = handoff is not None
- continuity_label = "keyframe carry"
- elif shot_continuity == "handoff ref":
- if handoff is not None and shot_refs:
- if handoff not in shot_refs:
- shot_refs = shot_refs + [handoff]
- if (i + 1) not in ref_carried:
- ref_carried.append(i + 1)
- shot_handoff = None
- else:
- shot_handoff = handoff if handoff is not None else None
- carry_keyframe = False
- continuity_label = "handoff ref"
- elif shot_continuity == "soft carry":
- shot_refs = [r for r in shot_refs if r is not handoff]
- shot_handoff = handoff if not shot_refs else None
- carry_keyframe = False
- continuity_label = "soft carry"
- else:
- shot_handoff = handoff if (carry_keyframe or not shot_refs) else None
- continuity_label = ("keyframe carry" if carry_keyframe else
- "handoff ref" if (handoff is not None and handoff in shot_refs) else
- "soft carry" if shot_handoff is not None else "hard cut")
- if carry_keyframe and (i + 1) not in ref_keyframed and handoff is not None:
- ref_keyframed.append(i + 1)
- ref_mode_used.append(shot_mode if shot_tag_driven else shot_mode_eff)
- continuity_used.append(continuity_label)
- ref_aug_used.append(shot_aug)
- if shot_refs:
- ref_shots.append(i + 1)
- shot_total_start = time.perf_counter()
- shot_retry_elapsed = 0.0
- shot_attempts = 0
- shot_timing = []
- if i == 0:
- while True:
- shot_attempts += 1
- attempt_start = time.perf_counter()
- try:
- frames, audio, shot_latent = self._render(
- model, clip, vae, audio_vae, negative, gen_prompt, w, h, ln_i, fps,
- tiled, sa, shot_handoff, decode_tile_frames, decode_tile_size,
- shot_refs, ref_image_size, shot_aug, shot_silent,
- detail_pass, detail_sampler_name, detail_scheduler,
- detail_steps, detail_denoise, timing_sink=shot_timing)
+ sa = (seed + i if vary_seed_per_shot else seed, steps, cfg, sampler_name, scheduler, 1.0)
+ ln_i = shot_lens[i] # this beat's own length (<= the VRAM ceiling)
+ # Which conditioning channels this shot carries is decided here; see
+ # _build_shot_conditioning for how they are packed. On ComfyUI 0.31+ a
+ # shot may carry BOTH references and a keyframe.
+ beat_text = beats[i] if i < len(beats) else ""
+ shot_mode = beat_ref_mode_directive(beat_text) or ref_mode
+ shot_continuity = beat_continuity_directive(beat_text) or "auto"
+ shot_ref_noise_aug = beat_ref_noise_aug_directive(beat_text)
+ shot_aug = ref_noise_aug if shot_ref_noise_aug is None else shot_ref_noise_aug
+ shot_tag_driven = bool(connected_ref_count) and shot_mode in ("where tagged", "auto ref2v") and any_tags_anywhere
+ if shot_tag_driven:
+ shot_mode_eff = shot_mode
+ else:
+ shot_mode_eff = ("every shot" if shot_mode == "auto ref2v"
+ else "first shot" if shot_mode == "where tagged" else shot_mode)
+ carry_keyframe = False # tagged shot keeps its handoff as a keyframe
+ if shot_tag_driven:
+ # The prompt itself says where each reference belongs: the shot whose
+ # text names gets image N, renumbered to match what that
+ # shot actually carries. Every untagged shot keeps its handoff.
+ gen_prompt, shot_refs, dropped = resolve_prompt_refs(gen_prompt, ref_slots)
+ for n in dropped:
+ if n not in ref_missing:
+ ref_missing.append(n)
+ else:
+ shot_refs = shot_references(ref_slots, shot_mode_eff, i, handoff)
+ # A shot that follows a strip starts FRESH. Continuing from a frame that
+ # still shows the garment is how it reappears -- the picture outvotes the
+ # text every time. Costs a cut exactly where the state changes, which is
+ # where a cut belongs anyway.
+ # No scripted line -> anchor this shot's audio branch to silence.
+ shot_silent = bool(auto_silence_nonspeech and not allow_nonspeech_vocals and i < len(spk) and not spk[i])
+ after_strip = i in strip_shots # strip_shots is 1-based, i is 0-based
+ if handoff is not None and shot_refs and shot_tag_driven and keyframe_rides_with_refs(shot_aug):
+ carry_keyframe = True
+ elif handoff is not None and shot_refs and shot_tag_driven:
+ if handoff not in shot_refs:
+ shot_refs = shot_refs + [handoff]
+ if (i + 1) not in ref_carried:
+ ref_carried.append(i + 1)
+ elif handoff is not None and shot_refs and keyframe_rides_with_refs(shot_aug):
+ carry_keyframe = True
+ shot_refs = [r for r in shot_refs if r is not handoff]
+ elif handoff is not None and shot_refs and shot_mode_eff == "every shot + handoff ref":
+ if (i + 1) not in ref_carried:
+ ref_carried.append(i + 1)
+
+ if after_strip:
+ shot_refs = [r for r in shot_refs if r is not handoff]
+ shot_handoff = None
+ carry_keyframe = False
+ continuity_label = "hard cut (post-strip)"
+ elif shot_continuity == "hard cut":
+ shot_refs = [r for r in shot_refs if r is not handoff]
+ shot_handoff = None
+ carry_keyframe = False
+ continuity_label = "hard cut"
+ elif shot_continuity == "keyframe carry":
+ shot_refs = [r for r in shot_refs if r is not handoff]
+ shot_handoff = handoff
+ carry_keyframe = handoff is not None
+ continuity_label = "keyframe carry"
+ elif shot_continuity == "handoff ref":
+ if handoff is not None and shot_refs:
+ if handoff not in shot_refs:
+ shot_refs = shot_refs + [handoff]
+ if (i + 1) not in ref_carried:
+ ref_carried.append(i + 1)
+ shot_handoff = None
+ else:
+ shot_handoff = handoff if handoff is not None else None
+ carry_keyframe = False
+ continuity_label = "handoff ref"
+ elif shot_continuity == "soft carry":
+ shot_refs = [r for r in shot_refs if r is not handoff]
+ shot_handoff = handoff if not shot_refs else None
+ carry_keyframe = False
+ continuity_label = "soft carry"
+ else:
+ shot_handoff = handoff if (carry_keyframe or not shot_refs) else None
+ continuity_label = ("keyframe carry" if carry_keyframe else
+ "handoff ref" if (handoff is not None and handoff in shot_refs) else
+ "soft carry" if shot_handoff is not None else "hard cut")
+ if carry_keyframe and (i + 1) not in ref_keyframed and handoff is not None:
+ ref_keyframed.append(i + 1)
+ ref_mode_used.append(shot_mode if shot_tag_driven else shot_mode_eff)
+ continuity_used.append(continuity_label)
+ ref_aug_used.append(shot_aug)
+ if shot_refs:
+ ref_shots.append(i + 1)
+ if i == 0:
+ while True:
+ try:
+ frames, audio, shot_latent = self._render(model, clip, vae, audio_vae, negative, gen_prompt, w, h, ln_i, fps, tiled, sa, shot_handoff, decode_tile_frames, decode_tile_size,
+ shot_refs, ref_image_size, shot_aug, shot_silent,
+ detail_pass, detail_sampler_name, detail_scheduler,
+ detail_steps, detail_denoise)
break
except (torch.cuda.OutOfMemoryError, RuntimeError) as e:
- shot_retry_elapsed += time.perf_counter() - attempt_start
if not _is_oom(e):
raise
mm.soft_empty_cache(True)
@@ -6978,17 +6825,12 @@ class H3LongVideos:
raise RuntimeError("H3 Long Videos: not enough VRAM even at the smallest size. "
"Pick a smaller resolution, close other GPU apps, or use a smaller quant.")
else:
- try:
- shot_attempts += 1
- attempt_start = time.perf_counter()
- frames, audio, shot_latent = self._render(
- model, clip, vae, audio_vae, negative, gen_prompt, w, h, ln_i, fps,
- tiled, sa, shot_handoff, decode_tile_frames, decode_tile_size,
- shot_refs, ref_image_size, shot_aug, shot_silent,
- detail_pass, detail_sampler_name, detail_scheduler,
- detail_steps, detail_denoise, timing_sink=shot_timing)
+ try:
+ frames, audio, shot_latent = self._render(model, clip, vae, audio_vae, negative, gen_prompt, w, h, ln_i, fps, tiled, sa, shot_handoff, decode_tile_frames, decode_tile_size,
+ shot_refs, ref_image_size, shot_aug, shot_silent,
+ detail_pass, detail_sampler_name, detail_scheduler,
+ detail_steps, detail_denoise)
except (torch.cuda.OutOfMemoryError, RuntimeError) as e:
- shot_retry_elapsed += time.perf_counter() - attempt_start
if _is_oom(e) and getattr(e, "_h3_stage", "") == "sampling":
# Retrying with tiles would re-run the whole sampling pass and
# fail identically. Fail now, and say what actually shrinks it.
@@ -6998,29 +6840,11 @@ class H3LongVideos:
) from e
if not _is_oom(e) or tiled:
raise
- mm.soft_empty_cache(True); tiled = True; backoff.append(f"shot {i+1}: tiled")
- shot_attempts += 1
- attempt_start = time.perf_counter()
- frames, audio, shot_latent = self._render(
- model, clip, vae, audio_vae, negative, gen_prompt, w, h, ln_i, fps,
- tiled, sa, shot_handoff, decode_tile_frames, decode_tile_size,
- shot_refs, ref_image_size, shot_aug, shot_silent,
- detail_pass, detail_sampler_name, detail_scheduler,
- detail_steps, detail_denoise, timing_sink=shot_timing)
- shot_retry_elapsed += time.perf_counter() - attempt_start
-
- shot_total = time.perf_counter() - shot_total_start
- if shot_timing:
- render_timing = dict(shot_timing[-1])
- else:
- render_timing = {}
- shot_timings.append({
- "shot": i + 1,
- "total": shot_total,
- "retry_elapsed": shot_retry_elapsed,
- "attempts": shot_attempts,
- **render_timing,
- })
+ mm.soft_empty_cache(True); tiled = True; backoff.append(f"shot {i+1}: tiled")
+ frames, audio, shot_latent = self._render(model, clip, vae, audio_vae, negative, gen_prompt, w, h, ln_i, fps, tiled, sa, shot_handoff, decode_tile_frames, decode_tile_size,
+ shot_refs, ref_image_size, shot_aug, shot_silent,
+ detail_pass, detail_sampler_name, detail_scheduler,
+ detail_steps, detail_denoise)
if shot_latent is not None:
latent_chunks.append(shot_latent)
@@ -7177,7 +7001,7 @@ class H3LongVideos:
watermark_opacity, watermark_margin, intro_text, intro_seconds,
intro_fade, intro_size, intro_position, overlay_font, overlay_stroke)
- script = annotate_script_debug(gens, anatomy_shots, anatomy_mode, ref_slots)
+ script = annotate_script_debug(gens, anatomy_shots, anatomy_mode, ref_slots)
actual = all_frames.shape[0] / fps
uniform_len = len(set(shot_lens)) == 1
shape_str = (f"{len(gens)} shot(s) x {shot_lens[0]}f (~{shot_lens[0] / fps:.1f}s each) "
@@ -7212,33 +7036,33 @@ class H3LongVideos:
ref_note_missing = (f" named in the prompt "
f"but no image is connected to that ref_image input -- the tag(s) were "
f"dropped from the text")
- else:
- ref_note_missing = ""
- if connected_ref_count and ref_shots:
- kept = [n for n in range(1, len(gens) + 1) if n not in ref_shots]
- distinct_ref_modes = list(dict.fromkeys(ref_mode_used))
- tagged_used = any(mode in ("where tagged", "auto ref2v") for mode in ref_mode_used) and any_tags_anywhere
- ref_placement = ("placed by tags" if tagged_used else
- f"ref_mode '{distinct_ref_modes[0]}'" if len(distinct_ref_modes) == 1 else
- "mixed per-shot ref_mode")
- ref_source = []
- if direct_ref_count:
- ref_source.append(f"{direct_ref_count} direct")
- aug_override_notes = [
- f"{index + 1}={value:.3f}"
- for index, value in enumerate(ref_aug_used)
- if ref_noise_aug is not None and float(value) != float(ref_noise_aug)
- ]
- ref_note = (f" ref2va: {connected_ref_count} reference image(s) at '{ref_image_size}' on shot(s) "
- f"{','.join(str(n) for n in ref_shots)} "
- f"({ref_placement})"
- + (f", ref_noise_aug {ref_noise_aug:.3f}" if ref_noise_aug is not None
- and float(ref_noise_aug) < 0.999 else "")
- + (f"; shot-specific ref_noise_aug shot(s) {', '.join(aug_override_notes)}"
- if aug_override_notes else "")
- + (f"; source {' + '.join(ref_source)}" if ref_source else "")
- + (f"; shot(s) {','.join(str(n) for n in kept)} keep the handoff" if kept
- else "")
+ else:
+ ref_note_missing = ""
+ if connected_ref_count and ref_shots:
+ kept = [n for n in range(1, len(gens) + 1) if n not in ref_shots]
+ distinct_ref_modes = list(dict.fromkeys(ref_mode_used))
+ tagged_used = any(mode in ("where tagged", "auto ref2v") for mode in ref_mode_used) and any_tags_anywhere
+ ref_placement = ("placed by tags" if tagged_used else
+ f"ref_mode '{distinct_ref_modes[0]}'" if len(distinct_ref_modes) == 1 else
+ "mixed per-shot ref_mode")
+ ref_source = []
+ if direct_ref_count:
+ ref_source.append(f"{direct_ref_count} direct")
+ aug_override_notes = [
+ f"{index + 1}={value:.3f}"
+ for index, value in enumerate(ref_aug_used)
+ if ref_noise_aug is not None and float(value) != float(ref_noise_aug)
+ ]
+ ref_note = (f" ref2va: {connected_ref_count} reference image(s) at '{ref_image_size}' on shot(s) "
+ f"{','.join(str(n) for n in ref_shots)} "
+ f"({ref_placement})"
+ + (f", ref_noise_aug {ref_noise_aug:.3f}" if ref_noise_aug is not None
+ and float(ref_noise_aug) < 0.999 else "")
+ + (f"; shot-specific ref_noise_aug shot(s) {', '.join(aug_override_notes)}"
+ if aug_override_notes else "")
+ + (f"; source {' + '.join(ref_source)}" if ref_source else "")
+ + (f"; shot(s) {','.join(str(n) for n in kept)} keep the handoff" if kept
+ else "")
+ (f"; shot(s) {','.join(str(n) for n in ref_keyframed)} carry the previous "
f"frame as a real KEYFRAME alongside their references, so they anchor "
f"rather than cut" if ref_keyframed else "")
@@ -7246,18 +7070,17 @@ class H3LongVideos:
f"{','.join(str(n) for n in ref_carried)} -- weaker than a keyframe, but "
f"ref_noise_aug below {KEYFRAME_SAFE_AUG:g} would soften a keyframe too "
f"(one aug covers every cond latent)" if ref_carried else "")
- + ("" if (ref_keyframed or ref_carried or kept)
- else ", so every cut between beats is a CUT, not a continuous take")
- + ref_note_missing)
- elif connected_ref_count:
- distinct_ref_modes = list(dict.fromkeys(ref_mode_used))
- mode_label = distinct_ref_modes[0] if len(distinct_ref_modes) == 1 else "mixed per-shot ref_mode"
- ref_note = (f" ref2va: {connected_ref_count} reference image(s) connected but ref_mode "
- f"'{mode_label}' applied them to no shot"
- + (f" (source {direct_ref_count} direct)" if direct_ref_count else ""))
- else:
- ref_note = ""
- timing_note = _format_timing_note(shot_timings)
+ + ("" if (ref_keyframed or ref_carried or kept)
+ else ", so every cut between beats is a CUT, not a continuous take")
+ + ref_note_missing)
+ elif connected_ref_count:
+ distinct_ref_modes = list(dict.fromkeys(ref_mode_used))
+ mode_label = distinct_ref_modes[0] if len(distinct_ref_modes) == 1 else "mixed per-shot ref_mode"
+ ref_note = (f" ref2va: {connected_ref_count} reference image(s) connected but ref_mode "
+ f"'{mode_label}' applied them to no shot"
+ + (f" (source {direct_ref_count} direct)" if direct_ref_count else ""))
+ else:
+ ref_note = ""
info = ((anchor_note + " ") if anchor_note else "") + \
(f"{shape_str} at {w}x{h}; {all_frames.shape[0]} frames (~{actual:.1f}s actual). "
f"decode {'tiled' if tiled else 'full'}. {vram_str}.{hoff_str}"
@@ -7270,24 +7093,23 @@ class H3LongVideos:
+ ("LoRA active -- count front-loaded so it binds before the scene"
if lora_on else "")
+ ").") if count_subjects else "")
- + ((" " + preflight_txt.strip()) if preflight_txt else "")
- + (f" MOUTH -- shot(s) {','.join(str(n) for n in mouth_settled)} were seeded "
- f"from a settled mouth ({MOUTH_SETTLE_FRAMES}f before the cut), because the "
- f"shot before them ended on dialogue." if mouth_settled else "")
- + (f"{anatomy_note}." if anatomy_note else "")
- + (f"{latent_note}." if latent_note else "")
- + (f"{detail_note}." if detail_note else "")
- + (f" SLA LoRA '{os.path.basename(str(sla_name))}' paired with sparse attention."
- if sla_name and sparse_on else "")
+ + ((" " + preflight_txt.strip()) if preflight_txt else "")
+ + (f" MOUTH -- shot(s) {','.join(str(n) for n in mouth_settled)} were seeded "
+ f"from a settled mouth ({MOUTH_SETTLE_FRAMES}f before the cut), because the "
+ f"shot before them ended on dialogue." if mouth_settled else "")
+ + (f"{anatomy_note}." if anatomy_note else "")
+ + (f"{latent_note}." if latent_note else "")
+ + (f"{detail_note}." if detail_note else "")
+ + (f" SLA LoRA '{os.path.basename(str(sla_name))}' paired with sparse attention."
+ if sla_name and sparse_on else "")
+ (f" {beats_note}." if beats_note else "")
- + (f"{audio_note}." if audio_note else "")
- + (" EXPOSURE -- " + "; ".join(wardrobe_notes) + "."
- if wardrobe_notes else "")
- + (" OVERRIDES -- " + "; ".join(override_notes) + "."
- if override_notes else "")
- + (f"{ref_note}." if ref_note else "")
- + (f" {timing_note}." if timing_note else "")
- + (f" {fps_note}." if fps_note else "")
+ + (f"{audio_note}." if audio_note else "")
+ + (" EXPOSURE -- " + "; ".join(wardrobe_notes) + "."
+ if wardrobe_notes else "")
+ + (" OVERRIDES -- " + "; ".join(override_notes) + "."
+ if override_notes else "")
+ + (f"{ref_note}." if ref_note else "")
+ + (f" {fps_note}." if fps_note else "")
+ (f" {swap_note}." if swap_note else "")
+ (f" free VRAM/shot: {vram_trace}." if len(vram_trace) > 1 else "")
+ (f" {accel_note}." if accel_note else "")
@@ -7302,19 +7124,19 @@ class H3LongVideos:
# reassigns it, so this is the derived bed when auto_soundscape fired and
# your own text when it did not. Emitting it means you can read what was
# generated, and feed it straight back into the widget-input to pin it.
- return (all_frames, {"waveform": all_audio, "sample_rate": sr}, info, script,
- max(shot_lens), all_frames.shape[0], len(gens), round(actual, 2),
- float(fps), int(fps), latent_out, global_soundscape,
- video_chunks, [{"waveform": chunk, "sample_rate": sr} for chunk in audio_chunks])
+ return (all_frames, {"waveform": all_audio, "sample_rate": sr}, info, script,
+ max(shot_lens), all_frames.shape[0], len(gens), round(actual, 2),
+ float(fps), int(fps), latent_out, global_soundscape,
+ video_chunks, [{"waveform": chunk, "sample_rate": sr} for chunk in audio_chunks])
-# The old FL2VA / REF2VA aliases were only alternate menu entries for the same class.
-# Dumas workflows now use the canonical "DumasH3LongVideos" key, so expose a single
-# node entry instead of triplicating the search results with duplicate aliases.
-NODE_CLASS_MAPPINGS = {
- "DumasH3LongVideos": H3LongVideos,
-}
-NODE_DISPLAY_NAME_MAPPINGS = {
- "DumasH3LongVideos": "Dumas H3 Long Videos (FL2VA + REF2VA)",
-}
-__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+# The old FL2VA / REF2VA aliases were only alternate menu entries for the same class.
+# Dumas workflows now use the canonical "DumasH3LongVideos" key, so expose a single
+# node entry instead of triplicating the search results with duplicate aliases.
+NODE_CLASS_MAPPINGS = {
+ "DumasH3LongVideos": H3LongVideos,
+}
+NODE_DISPLAY_NAME_MAPPINGS = {
+ "DumasH3LongVideos": "Dumas H3 Long Videos (FL2VA + REF2VA)",
+}
+__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
diff --git a/tests/test_dumas_h3_longvideos.py b/tests/test_dumas_h3_longvideos.py
index eca4397..58697db 100644
--- a/tests/test_dumas_h3_longvideos.py
+++ b/tests/test_dumas_h3_longvideos.py
@@ -160,41 +160,6 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
"keyframe carry",
)
- def test_timing_summary_reports_retry_and_bucket_totals(self):
- note = self.module._format_timing_note([
- {
- "shot": 1,
- "total": 12.4,
- "retry_elapsed": 1.2,
- "attempts": 2,
- "sample": 8.0,
- "detail_sample": 0.5,
- "decode_video": 2.1,
- "decode_audio": 0.4,
- "cleanup": 0.2,
- },
- {
- "shot": 2,
- "total": 7.6,
- "retry_elapsed": 0.0,
- "attempts": 1,
- "sample": 6.5,
- "decode_video": 0.5,
- "decode_audio": 0.3,
- "cleanup": 0.1,
- },
- ])
-
- self.assertIn("timing: 2 shot(s) total 20.0s", note)
- self.assertIn("sample 14.5s", note)
- self.assertIn("decode video 2.6s", note)
- self.assertIn("decode audio 0.7s", note)
- self.assertIn("cleanup 0.3s", note)
- self.assertIn("retry elapsed 1.2s", note)
- self.assertIn("detail 0.5s", note)
- self.assertIn("retries 1", note)
- self.assertIn("slowest shot 1 12.4s", note)
-
def test_detail_pass_refines_video_but_preserves_audio(self):
class FakeTensor:
def __init__(self, name):
@@ -226,7 +191,6 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
original_cleanup = self.module._deep_cleanup
original_nested = getattr(self.module.comfy.nested_tensor, "NestedTensor", None)
try:
- sentinel_model = object()
self.module.comfy.nested_tensor.NestedTensor = FakeNestedTensor
def common_ksampler(*args, **kwargs):
@@ -244,7 +208,7 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
self.module._deep_cleanup = lambda: None
result = self.module.H3LongVideos()._render(
- model=sentinel_model,
+ model=object(),
clip=types.SimpleNamespace(
tokenize=lambda text, **kwargs: text,
encode_from_tokens_scheduled=lambda tokens: tokens,
@@ -290,95 +254,6 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
else:
self.module.comfy.nested_tensor.NestedTensor = original_nested
- def test_render_retries_decode_with_tiling_after_decode_oom(self):
- class FakeTensor:
- def __init__(self, name):
- self.name = name
-
- def detach(self):
- return self
-
- def to(self, *args, **kwargs):
- return self
-
- class FakeNestedTensor:
- def __init__(self, parts):
- self._parts = tuple(parts)
- self.is_nested = True
-
- def unbind(self):
- return self._parts
-
- decode_calls = []
- cleanup_calls = []
- first_out = {"samples": FakeNestedTensor((FakeTensor("v1"), FakeTensor("a1")))}
-
- original_common_ksampler = self.module.nodes.common_ksampler
- original_build = self.module._build_shot_conditioning
- original_evict = self.module._evict_all_but
- original_decode_video = self.module._decode_video
- original_decode_audio = self.module._decode_audio
- original_cleanup = self.module._deep_cleanup
- original_soft_empty_cache = self.module.mm.soft_empty_cache
- original_nested = getattr(self.module.comfy.nested_tensor, "NestedTensor", None)
- try:
- sentinel_model = object()
- self.module.comfy.nested_tensor.NestedTensor = FakeNestedTensor
- self.module.nodes.common_ksampler = lambda *args, **kwargs: (first_out,)
- self.module._build_shot_conditioning = lambda *_args, **_kwargs: (
- "cond",
- {"samples": FakeNestedTensor((FakeTensor("basev"), FakeTensor("basea")))},
- )
- self.module._evict_all_but = lambda *_args, **_kwargs: None
-
- def decode_video(_vae, out_latent, tiled, free_first=None, tile_t=None, tile_xy=None):
- decode_calls.append((tiled, free_first, tile_t, tile_xy))
- if len(decode_calls) == 1:
- raise RuntimeError("CUDA out of memory during decode")
- return out_latent
-
- self.module._decode_video = decode_video
- self.module._decode_audio = lambda _vae, out_latent: out_latent
- self.module.mm.soft_empty_cache = lambda *args, **kwargs: cleanup_calls.append((args, kwargs))
- self.module._deep_cleanup = lambda: None
-
- result = self.module.H3LongVideos()._render(
- model=sentinel_model,
- clip=types.SimpleNamespace(
- tokenize=lambda text, **kwargs: text,
- encode_from_tokens_scheduled=lambda tokens: tokens,
- ),
- vae=object(),
- audio_vae=object(),
- negative="negative",
- prompt="beat",
- w=128,
- h=64,
- ln=24,
- fps=24,
- tiled=False,
- sa=(123, 20, 1.0, "res_multistep", "simple", 1.0),
- handoff=None,
- )
-
- self.assertEqual(len(decode_calls), 2)
- self.assertEqual(decode_calls[0], (False, sentinel_model, 0, 0))
- self.assertEqual(decode_calls[1], (True, None, 16, 256))
- self.assertTrue(cleanup_calls)
- self.assertIs(result[1], first_out)
- finally:
- self.module.nodes.common_ksampler = original_common_ksampler
- self.module._build_shot_conditioning = original_build
- self.module._evict_all_but = original_evict
- self.module._decode_video = original_decode_video
- self.module._decode_audio = original_decode_audio
- self.module._deep_cleanup = original_cleanup
- self.module.mm.soft_empty_cache = original_soft_empty_cache
- if original_nested is None:
- delattr(self.module.comfy.nested_tensor, "NestedTensor")
- else:
- self.module.comfy.nested_tensor.NestedTensor = original_nested
-
def test_detail_pass_treats_falsey_strings_as_disabled(self):
calls = []
original_common_ksampler = self.module.nodes.common_ksampler
@@ -513,26 +388,24 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
)
self.assertEqual(dropped, [4])
- def test_resolve_shot_references_uses_named_characters_without_picture_tags(self):
+ def test_resolve_prompt_refs_keeps_named_character_images_alongside_tagged_location(self):
refs = [
{"kind": "character", "image": "img1", "name": "Mara"},
{"kind": "character", "image": "img2", "name": "Jon"},
{"kind": "location", "image": "img3", "name": "Hangar"},
]
- text, references, dropped, shot_tag_driven, mode_eff = self.module.resolve_shot_references(
- "[Generation 1] Mara crosses the hangar.",
+ text, references, dropped = self.module.resolve_prompt_refs(
+ "Mara and Jon argue inside .",
refs,
- "auto ref2v",
- 0,
- None,
)
- self.assertEqual(text, "[Generation 1] Mara crosses the hangar.")
- self.assertEqual([self.module._reference_image(ref) for ref in references], ["img1"])
+ self.assertEqual(text, "Mara and Jon argue inside .")
+ self.assertEqual(
+ [self.module._reference_image(ref) for ref in references],
+ ["img3", "img1", "img2"],
+ )
self.assertEqual(dropped, [])
- self.assertFalse(shot_tag_driven)
- self.assertEqual(mode_eff, "auto ref2v")
def test_shot_references_uses_all_connected_sparse_slots(self):
refs = [
@@ -559,24 +432,12 @@ 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"]
@@ -636,32 +497,6 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
self.assertIn("Persistent wardrobe/style for Mara: red jacket.", context)
self.assertIn("Character notes for Mara: wears a long grey coat.", context)
- def test_reference_context_matches_location_names_without_picture_tag(self):
- refs = [
- {"kind": "location", "image": "img2", "name": "Hangar", "aliases": ["loading bay"], "description": "wet concrete floor", "general": "cold industrial lighting"},
- ]
-
- context = self.module._reference_context_for_text(
- "[Generation 1] They argue in the hangar near the loading bay.",
- refs,
- )
-
- self.assertIn("Location context for Hangar: wet concrete floor.", context)
- self.assertIn("Location notes for Hangar: cold industrial lighting.", context)
-
- def test_reference_context_skips_ambiguous_name_matches(self):
- refs = [
- {"kind": "character", "image": "img1", "name": "Alex", "description": "short dark hair"},
- {"kind": "character", "image": "img2", "name": "Alex", "description": "tall blond hair"},
- ]
-
- context = self.module._reference_context_for_text(
- "[Generation 1] Alex enters the room.",
- refs,
- )
-
- self.assertEqual(context, "")
-
def test_run_uses_legacy_ref_image_inputs_when_new_slots_are_empty(self):
calls = {}
original_parse_resolution = self.module.parse_resolution
@@ -773,36 +608,6 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
self.assertIn("Persistent appearance for Mara: silver hair.", context)
self.assertIn("Persistent wardrobe/style for Mara: red jacket.", context)
- def test_reference_context_can_skip_character_wardrobe_when_live_memory_is_explicit(self):
- refs = [
- {"kind": "character", "image": "img1", "name": "Mara", "description": "silver hair", "wardrobe": "red jacket"},
- ]
-
- context = self.module._reference_context_for_text(
- "[Generation 1] Mara walks into the room.",
- refs,
- include_character_wardrobe=False,
- )
-
- self.assertIn("Persistent appearance for Mara: silver hair.", context)
- self.assertNotIn("Persistent wardrobe/style for Mara: red jacket.", context)
-
- def test_resolve_tagged_refs_drops_reference_without_image(self):
- refs = [
- {"kind": "character", "image": None, "name": "Mara"},
- {"kind": "character", "image": "img2", "name": "Jon"},
- ]
-
- rewritten, matched, dropped = self.module.resolve_tagged_refs(
- "[Generation 1] faces .",
- refs,
- )
-
- self.assertEqual(rewritten, "[Generation 1] faces .")
- self.assertEqual(dropped, [1])
- self.assertEqual(len(matched), 1)
- self.assertEqual(matched[0]["name"], "Jon")
-
def test_reference_context_injects_immediately_after_generation_label(self):
block = (
"[Generation 1] Classic sitcom lighting and staging. "