From 58b1c77c19a318535080f569c8181c38276a6601 Mon Sep 17 00:00:00 2001 From: Chris Dumas Date: Sun, 30 Aug 2026 21:36:50 +0000 Subject: [PATCH] Revert "Restore long videos release snapshot" This reverts commit 4a0f5cfcd49c918515d3e8c5a7734ca30a7fa893. --- dumas_h3_longvideos.py | 2588 +++++++++++++++-------------- tests/test_dumas_h3_longvideos.py | 213 ++- 2 files changed, 1587 insertions(+), 1214 deletions(-) diff --git a/dumas_h3_longvideos.py b/dumas_h3_longvideos.py index 8d65595..ef874ee 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,23 +30,24 @@ 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 @@ -56,28 +57,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 @@ -267,44 +268,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 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", -) +# 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", +) 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): @@ -1570,8 +1571,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 @@ -1582,11 +1583,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.")) @@ -1652,22 +1653,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 --------------------------------------------------------- @@ -2076,7 +2077,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 @@ -2100,13 +2101,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): @@ -2321,7 +2322,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 @@ -2334,43 +2335,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): @@ -2390,7 +2391,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? @@ -2403,10 +2404,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): @@ -2784,94 +2785,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 @@ -3014,7 +3015,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 @@ -3023,66 +3024,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) - - -action_clauses = lru_cache(maxsize=2048)(action_clauses) + 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) -def estimate_beat_seconds(beat): +action_clauses = lru_cache(maxsize=2048)(action_clauses) + + +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)) - - -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)) + n = action_clauses(beat) + action = (BEAT_BASE_SEC + SECONDS_PER_ACTION * n) if n else 0.0 + return max(action, dialogue_seconds(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) +estimate_beat_seconds = lru_cache(maxsize=2048)(estimate_beat_seconds) -def dialogue_seconds(beat, pad=True): +@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): """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)) - - -dialogue_seconds = lru_cache(maxsize=4096)(dialogue_seconds) + 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)) -def beat_seconds_directive(beat): +dialogue_seconds = lru_cache(maxsize=4096)(dialogue_seconds) + + +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"): @@ -3094,12 +3095,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): @@ -3229,7 +3230,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, @@ -3253,70 +3254,71 @@ 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 = [] - 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): + 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): """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.""" @@ -3331,7 +3333,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 @@ -3381,21 +3383,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 @@ -3548,16 +3550,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) @@ -3659,22 +3661,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}" @@ -3682,14 +3684,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 @@ -4006,72 +4008,131 @@ 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 _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) # --- ref2va reference conditioning ---------------------------------------- @@ -4099,18 +4160,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}) @@ -4119,11 +4180,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 @@ -4271,230 +4332,268 @@ 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: - 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 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 _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): + 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: + 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)) + return matched + + def _matched_reference_slots(text, ref_slots): matched = [] 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, 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] - - + 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] + + 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)) @@ -4506,29 +4605,9 @@ 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. @@ -4538,34 +4617,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 ------------------------------------- @@ -5659,9 +5738,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. @@ -5671,13 +5750,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): @@ -5744,47 +5823,62 @@ class H3LongVideos: "tooltip": "Base H3 wants ~20 (res_multistep + simple). Drop to 6-8 ONLY with a " "working distill/turbo LoRA or a low-step MXFP8 checkpoint -- on the " "bare base model, low steps are the #1 cause of soft output."}), - "cfg": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 30.0, "step": 0.1}), - "sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"default": "res_multistep"}), - "scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"default": "simple"}), - "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "control_after_generate": True}), + "cfg": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 30.0, "step": 0.1, + "tooltip": "H3 is effectively CFG-free here. Leave this at 1.0 unless you are " + "deliberately testing unusual sampler behaviour; higher values do not act " + "like a normal SD/Flux CFG boost and are not the fix for weak identity."}), + "sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"default": "res_multistep", + "tooltip": "Main-pass sampler. Base H3 is tuned around res_multistep; changing this is " + "a real behaviour change, not a cosmetic preference. Treat it as an advanced " + "override."}), + "scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"default": "simple", + "tooltip": "Main-pass scheduler. Base H3 is tuned around simple when paired with " + "res_multistep and the default model-sampling shifts."}), + "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "control_after_generate": True, + "tooltip": "Base seed for the whole chain. With vary_seed_per_shot OFF, every beat is " + "sampled from the same noise field for better continuity. With it ON, this " + "becomes the first seed and later beats use seed+1, seed+2, ..."}), }, "optional": { - "first_frame": ("IMAGE",), - # 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."}), + "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."}), "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 " @@ -5846,21 +5940,27 @@ 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": "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."}), + "tooltip": "GLOBAL per-shot ceiling in seconds, not 'force every beat to exactly " + "this length'. H3 Shot Length is the intended source because it snaps to " + "H3's frame grid and reports the real duration.\n\n" + "Leave UNCONNECTED for auto: the node picks the largest shot that fits " + "the current size/VRAM budget. Connect a value to cap every beat at that " + "length. A beat's own `seconds:` directive can still ask for less, and any " + "request above H3's hard ~15.1s single-shot limit is clamped. Total video " + "length is the sum of the beat shots, not simply beat-count x this value."}), "allow_oversize_shots": ("BOOLEAN", {"default": False, "tooltip": "OFF (default): a forced shot_seconds that won't fit VRAM is clamped DOWN to " "what fits, and the clamp is reported in info. ON: honor the requested length " "even if it exceeds the budget -- the render may spill into system RAM (slow) " "or OOM. Only affects forced shot_seconds, not auto."}), - "vram_headroom_gb": ("FLOAT", {"default": 1.5, "min": 0.0, "max": 32.0, "step": 0.5}), + "vram_headroom_gb": ("FLOAT", {"default": 1.5, "min": 0.0, "max": 32.0, "step": 0.5, + "tooltip": "Safety margin RESERVED from free VRAM before the node budgets shot length. " + "Higher = shorter safer shots. Lower = longer shots but more risk of spill or " + "OOM during sampling/decode peaks. 1.5GB is the conservative default."}), "allow_res_backoff": ("BOOLEAN", {"default": True, - "tooltip": "If VRAM is tight, step resolution down instead of failing."}), + "tooltip": "If a render does not fit, try stepping the resolution down instead of just " + "failing. Helps salvage long jobs automatically, but the later shots may come " + "back smaller and the latent output can no longer join cleanly across sizes."}), # ON by default: the prompt-side clauses ASK H3 not to vocalize (and now # condition the soundscape field too), but asking is not a guarantee -- # babble under a silent shot was the one artifact that survived both. @@ -5942,30 +6042,33 @@ 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 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_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_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 " @@ -6073,7 +6176,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", @@ -6101,7 +6204,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 " @@ -6168,12 +6271,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 " @@ -6182,24 +6285,31 @@ 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. 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."}), - }, + "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."}), + }, # 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 @@ -6220,55 +6330,82 @@ 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): - 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, 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 # 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: - (out,) = nodes.common_ksampler(model, seed, steps, cfg, sn, sch, positive, negative, - latent, denoise=denoise) - except Exception as e: + 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: # 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: - (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) + 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) # 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) - 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 + 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 def run(self, model, clip, vae, audio_vae, prompt, resolution, steps, cfg, sampler_name, scheduler, seed, @@ -6286,24 +6423,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 @@ -6317,28 +6454,31 @@ 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 - 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 + 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 # 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. @@ -6367,17 +6507,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: @@ -6390,9 +6530,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 = ( @@ -6535,30 +6675,33 @@ 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. @@ -6573,40 +6716,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 @@ -6628,69 +6771,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(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 "")) + 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 "")) 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 @@ -6700,120 +6843,130 @@ 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_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 + 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 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_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) + 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) 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) @@ -6825,12 +6978,17 @@ 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: - 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) + 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) 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. @@ -6840,11 +6998,29 @@ 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") - 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) + 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, + }) if shot_latent is not None: latent_chunks.append(shot_latent) @@ -7001,7 +7177,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) " @@ -7036,33 +7212,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 "") @@ -7070,17 +7246,18 @@ 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 = "" + + ("" 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) 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}" @@ -7093,23 +7270,24 @@ 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" {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" {timing_note}." if timing_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 "") @@ -7124,19 +7302,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 58697db..eca4397 100644 --- a/tests/test_dumas_h3_longvideos.py +++ b/tests/test_dumas_h3_longvideos.py @@ -160,6 +160,41 @@ 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): @@ -191,6 +226,7 @@ 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): @@ -208,7 +244,7 @@ class DumasH3LongVideosHelperTests(unittest.TestCase): self.module._deep_cleanup = lambda: None result = self.module.H3LongVideos()._render( - model=object(), + model=sentinel_model, clip=types.SimpleNamespace( tokenize=lambda text, **kwargs: text, encode_from_tokens_scheduled=lambda tokens: tokens, @@ -254,6 +290,95 @@ 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 @@ -388,24 +513,26 @@ class DumasH3LongVideosHelperTests(unittest.TestCase): ) self.assertEqual(dropped, [4]) - def test_resolve_prompt_refs_keeps_named_character_images_alongside_tagged_location(self): + def test_resolve_shot_references_uses_named_characters_without_picture_tags(self): refs = [ {"kind": "character", "image": "img1", "name": "Mara"}, {"kind": "character", "image": "img2", "name": "Jon"}, {"kind": "location", "image": "img3", "name": "Hangar"}, ] - text, references, dropped = self.module.resolve_prompt_refs( - "Mara and Jon argue inside .", + text, references, dropped, shot_tag_driven, mode_eff = self.module.resolve_shot_references( + "[Generation 1] Mara crosses the hangar.", refs, + "auto ref2v", + 0, + None, ) - self.assertEqual(text, "Mara and Jon argue inside .") - self.assertEqual( - [self.module._reference_image(ref) for ref in references], - ["img3", "img1", "img2"], - ) + self.assertEqual(text, "[Generation 1] Mara crosses the hangar.") + self.assertEqual([self.module._reference_image(ref) for ref in references], ["img1"]) 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 = [ @@ -432,12 +559,24 @@ class DumasH3LongVideosHelperTests(unittest.TestCase): for index in range(1, 10): self.assertIn(f"ref_{index}", optional) + names = list(optional) + ref_positions = [names.index(f"ref_{index}") for index in range(1, 10)] + self.assertEqual(ref_positions, list(range(ref_positions[0], ref_positions[0] + 9))) + def test_input_types_keep_legacy_ref_image_aliases(self): optional = self.module.H3LongVideos.INPUT_TYPES()["optional"] for index in range(1, 10): self.assertIn(f"ref_image_{index}", optional) + def test_shot_seconds_tooltip_describes_ceiling_behavior(self): + optional = self.module.H3LongVideos.INPUT_TYPES()["optional"] + tooltip = optional["shot_seconds"][1]["tooltip"] + + self.assertIn("GLOBAL per-shot ceiling", tooltip) + self.assertIn("not 'force every beat to exactly this length'", tooltip) + self.assertIn("hard ~15.1s single-shot limit", tooltip) + def test_run_defaults_match_declared_ref_widget_defaults(self): node = self.module.H3LongVideos() optional = node.INPUT_TYPES()["optional"] @@ -497,6 +636,32 @@ 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 @@ -608,6 +773,36 @@ 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. "