Revert "Restore long videos release snapshot"

This reverts commit 4a0f5cfcd4.
This commit is contained in:
2026-08-30 21:36:50 +00:00
parent 4a0f5cfcd4
commit 58b1c77c19
2 changed files with 1587 additions and 1214 deletions
+256 -78
View File
@@ -47,6 +47,7 @@ import logging
import math import math
import os import os
import re import re
import time
import torch import torch
import nodes import nodes
@@ -267,7 +268,8 @@ def split_paragraphs(text, delimiter):
# Widgets added after the node's original 36-widget layout. Kept LAST in # 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 # 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). # 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. # 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 = ( ADDED_WIDGETS = (
"beat_split", "per_beat_length", "beat_split", "per_beat_length",
"watermark_text", "watermark_position", "watermark_size", "watermark_opacity", "watermark_text", "watermark_position", "watermark_size", "watermark_opacity",
@@ -277,7 +279,6 @@ ADDED_WIDGETS = (
"exposed_terms", "anatomy_guard", "lock_restraints", "solidity_guard", "exposed_terms", "anatomy_guard", "lock_restraints", "solidity_guard",
"motion_guard", "contact_guard", "motion_guard", "contact_guard",
"auto_soundscape", "allow_nonspeech_vocals", "auto_soundscape", "allow_nonspeech_vocals",
"ref_5", "ref_6", "ref_7", "ref_8", "ref_9",
"detail_pass", "detail_sampler_name", "detail_scheduler", "detail_pass", "detail_sampler_name", "detail_scheduler",
"detail_steps", "detail_denoise", "detail_steps", "detail_denoise",
) )
@@ -3288,9 +3289,10 @@ def annotate_script_debug(gens, anatomy_shots, anatomy_mode, ref_slots):
def annotate_script_refs(gens, ref_slots): def annotate_script_refs(gens, ref_slots):
"""Per-shot reference routing summary for the script socket.""" """Per-shot reference routing summary for the script socket."""
lines = [] lines = []
normalized_slots = _normalized_ref_slots(ref_slots)
for shot_index, gen in enumerate(gens or [], 1): for shot_index, gen in enumerate(gens or [], 1):
tagged = _slot_refs_for_text(gen, ref_slots) tagged = _slot_refs_for_text(gen, normalized_slots)
named = _named_character_refs_for_text(gen, ref_slots) named = _named_refs_for_text(gen, normalized_slots, kinds=("character", "location"))
seen = set() seen = set()
merged = [] merged = []
for slot_number, ref in tagged: for slot_number, ref in tagged:
@@ -4074,6 +4076,65 @@ def _coerce_bool_flag(value):
return bool(value) 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 ---------------------------------------- # --- ref2va reference conditioning ----------------------------------------
# H3's reference pipeline encodes a reference image at up to a 2048 short edge. # H3's reference pipeline encodes a reference image at up to a 2048 short edge.
# Reference rows ride through EVERY sampling step, so this is also the setting # Reference rows ride through EVERY sampling step, so this is also the setting
@@ -4282,6 +4343,9 @@ def _reference_slot(ref, slot_index=None):
def _reference_image(ref): def _reference_image(ref):
try: try:
if isinstance(ref, dict):
normalized = _image_nodes.normalize_reference(ref, allow_image_fallback=False)
else:
normalized = _reference_slot(ref) normalized = _reference_slot(ref)
except Exception: except Exception:
return None return None
@@ -4372,6 +4436,19 @@ def _reference_name_keys(ref):
return out 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): def _slot_refs_for_text(text, ref_slots):
refs = [] refs = []
for slot_number in picture_tags(text): for slot_number in picture_tags(text):
@@ -4399,6 +4476,27 @@ def _named_character_refs_for_text(text, ref_slots):
return matched 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): def _matched_reference_slots(text, ref_slots):
matched = [] matched = []
seen = set() seen = set()
@@ -4455,8 +4553,9 @@ def _inject_reference_context(block, context):
def _reference_character_memory(ref_slots): def _reference_character_memory(ref_slots):
lines = [] lines = []
seen = set() seen = set()
for slot_number, raw in enumerate(ref_slots or [], 1): for slot_number, ref in enumerate(ref_slots or [], 1):
ref = _reference_slot(raw, slot_number) if ref is None:
continue
if ref.get("kind") != "character": if ref.get("kind") != "character":
continue continue
wardrobe = _reference_text(ref.get("wardrobe")) wardrobe = _reference_text(ref.get("wardrobe"))
@@ -4508,26 +4607,6 @@ def resolve_tagged_refs(text, ref_list):
return out.strip(), [ref_list[n - 1] for n in live], dropped 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 <Picture N> 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 """Pure: which reference images shot `shot_index` is conditioned on, or [] when
the shot should use the keyframe handoff instead. the shot should use the keyframe handoff instead.
@@ -5744,13 +5823,26 @@ class H3LongVideos:
"tooltip": "Base H3 wants ~20 (res_multistep + simple). Drop to 6-8 ONLY with a " "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 " "working distill/turbo LoRA or a low-step MXFP8 checkpoint -- on the "
"bare base model, low steps are the #1 cause of soft output."}), "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}), "cfg": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 30.0, "step": 0.1,
"sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"default": "res_multistep"}), "tooltip": "H3 is effectively CFG-free here. Leave this at 1.0 unless you are "
"scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"default": "simple"}), "deliberately testing unusual sampler behaviour; higher values do not act "
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "control_after_generate": True}), "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": { "optional": {
"first_frame": ("IMAGE",), "first_frame": ("IMAGE", {"tooltip": "Optional opening keyframe for shot 1. Leave empty to "
"start from pure text/reference conditioning. On later shots the node normally uses the "
"previous shot's last frame automatically; this socket is only the initial handoff."}),
# ref2va inputs. SOCKET NUMBER matters: prompt tags refer to these # ref2va inputs. SOCKET NUMBER matters: prompt tags refer to these
# exact slots, even when some intermediate sockets are left empty. # exact slots, even when some intermediate sockets are left empty.
# A shot using only ref_image_7 is still tagged as <Picture 7> in the # A shot using only ref_image_7 is still tagged as <Picture 7> in the
@@ -5760,19 +5852,20 @@ class H3LongVideos:
# the order they are handed that shot. # the order they are handed that shot.
# Refer to socket tags in the prompt if you want a reference bound to # Refer to socket tags in the prompt if you want a reference bound to
# a named character ("Kristy, <Picture 7>, walks in"). # a named character ("Kristy, <Picture 7>, walks in").
"ref_1": ("REFERENCE", {"tooltip": "Reference object for <Picture 1> -- image plus identity/environment metadata " "ref_1": ("REFERENCE", {"tooltip": "Reference slot <Picture 1>. Feed a Dumas Character "
"carried into the shots. Which shots receive it is set by ref_mode (or <Picture N> " "Reference or Dumas Location Reference here. The image is used for real H3 visual "
"tags in the beats); a referenced shot ALSO carries the previous frame as its " "conditioning; the structured metadata is also used for prompt context."}),
"keyframe, so taking a reference never costs continuity."}), "ref_2": ("REFERENCE", {"tooltip": "Reference slot <Picture 2>. Socket numbers matter: "
"ref_2": ("REFERENCE", {"tooltip": "Reference object for <Picture 2>."}), "tag this exact slot as <Picture 2> when you want explicit placement in a beat."}),
"ref_3": ("REFERENCE", {"tooltip": "Reference object for <Picture 3>."}), "ref_3": ("REFERENCE", {"tooltip": "Reference slot <Picture 3>."}),
"ref_4": ("REFERENCE", {"tooltip": "Reference object for <Picture 4>."}), "ref_4": ("REFERENCE", {"tooltip": "Reference slot <Picture 4>."}),
"ref_5": ("REFERENCE", {"tooltip": "Reference object for <Picture 5>."}), "ref_5": ("REFERENCE", {"tooltip": "Reference slot <Picture 5>."}),
"ref_6": ("REFERENCE", {"tooltip": "Reference object for <Picture 6>."}), "ref_6": ("REFERENCE", {"tooltip": "Reference slot <Picture 6>."}),
"ref_7": ("REFERENCE", {"tooltip": "Reference object for <Picture 7>."}), "ref_7": ("REFERENCE", {"tooltip": "Reference slot <Picture 7>."}),
"ref_8": ("REFERENCE", {"tooltip": "Reference object for <Picture 8>."}), "ref_8": ("REFERENCE", {"tooltip": "Reference slot <Picture 8>."}),
"ref_9": ("REFERENCE", {"tooltip": "Reference object for <Picture 9>."}), "ref_9": ("REFERENCE", {"tooltip": "Reference slot <Picture 9>."}),
"ref_image_1": ("*", {"tooltip": "Legacy alias for ref_1. Accepts old IMAGE wiring or a REFERENCE payload."}), "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_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_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_4": ("*", {"tooltip": "Legacy alias for ref_4. Accepts old IMAGE wiring or a REFERENCE payload."}),
@@ -5782,9 +5875,10 @@ class H3LongVideos:
"ref_image_8": ("*", {"tooltip": "Legacy alias for ref_8. 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."}), "ref_image_9": ("*", {"tooltip": "Legacy alias for ref_9. Accepts old IMAGE wiring or a REFERENCE payload."}),
"plan_only": ("BOOLEAN", {"default": False, "plan_only": ("BOOLEAN", {"default": False,
"tooltip": "Preview the shot split WITHOUT rendering. Uses THIS node's own settings (no " "tooltip": "Preview the shot split and timing WITHOUT sampling pixels. Uses this "
"second node, no duplicate entry): returns the plan in 'info' and the " "node's current settings and returns the plan in `info` almost instantly. "
"shots/frames/seconds outputs near-instantly. Turn off to render for real."}), "Good for checking beat counts, shot ceilings, ref placement and clamps "
"before a long render."}),
"fps": ("INT", {"default": 24, "min": 1, "max": 60, "fps": ("INT", {"default": 24, "min": 1, "max": 60,
"tooltip": "DISPLAY ONLY -- H3 always renders 24 fps. The model's frame grid and its " "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 " "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."}), "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, "shot_seconds": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 15.1, "step": 0.5,
"forceInput": True, "forceInput": True,
"tooltip": "Length of EACH shot in seconds, taken from a connected input -- " "tooltip": "GLOBAL per-shot ceiling in seconds, not 'force every beat to exactly "
"H3 Shot Length is the intended source, since it also reports the " "this length'. H3 Shot Length is the intended source because it snaps to "
"matching frame count on the 17k+5 grid.\n\n" "H3's frame grid and reports the real duration.\n\n"
"Leave it UNCONNECTED for auto: the largest shot that fits at the " "Leave UNCONNECTED for auto: the node picks the largest shot that fits "
"chosen size, which is what a 0 in the old widget did. One " "the current size/VRAM budget. Connect a value to cap every beat at that "
"paragraph = one shot, so total video = (paragraph count) x this. " "length. A beat's own `seconds:` directive can still ask for less, and any "
"Max ~15s."}), "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, "allow_oversize_shots": ("BOOLEAN", {"default": False,
"tooltip": "OFF (default): a forced shot_seconds that won't fit VRAM is clamped DOWN to " "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 " "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) " "even if it exceeds the budget -- the render may spill into system RAM (slow) "
"or OOM. Only affects forced shot_seconds, not auto."}), "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, "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 # 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 -- # condition the soundscape field too), but asking is not a guarantee --
# babble under a silent shot was the one artifact that survived both. # babble under a silent shot was the one artifact that survived both.
@@ -5944,7 +6044,7 @@ class H3LongVideos:
"white as asked; 2-3 makes it survive a bright sky or a white wall."}), "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"], "ref_mode": (["auto ref2v", "where tagged", "first shot", "every shot", "every shot + handoff ref"],
{"default": "auto ref2v", {"default": "auto ref2v",
"tooltip": "Which shots the ref_image inputs condition. 'auto ref2v' (default) is " "tooltip": "Which shots the reference IMAGE inputs condition. 'auto ref2v' (default) is "
"the reference-to-video bias: if the prompt uses <Picture N> tags, those " "the reference-to-video bias: if the prompt uses <Picture N> tags, those "
"tags decide which shot gets which ref; if there are NO tags anywhere, the " "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 " "node conditions EVERY shot with the connected refs rather than collapsing "
@@ -5952,7 +6052,10 @@ class H3LongVideos:
"for long chains where identity drift matters more than strict per-shot " "for long chains where identity drift matters more than strict per-shot "
"routing. 'where tagged' keeps the old strict behavior, including the " "routing. 'where tagged' keeps the old strict behavior, including the "
"first-shot fallback when no tags are found. Tags are renumbered per shot, " "first-shot fallback when no tags are found. Tags are renumbered per shot, "
"so <Picture 2> alone still resolves. 'first shot' / 'every shot' / " "so <Picture 2> 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 " "'every shot + handoff ref' go purely by position. Ignored when no "
"ref_image is connected."}), "ref_image is connected."}),
"ref_noise_aug": ("FLOAT", {"default": 0.95, "min": 0.50, "max": 1.0, "step": 0.005, "ref_noise_aug": ("FLOAT", {"default": 0.95, "min": 0.50, "max": 1.0, "step": 0.005,
@@ -6189,16 +6292,23 @@ class H3LongVideos:
"detail_pass": ("BOOLEAN", {"default": False, "detail_pass": ("BOOLEAN", {"default": False,
"tooltip": "Run a second refinement sampler on each beat BEFORE any upscale. " "tooltip": "Run a second refinement sampler on each beat BEFORE any upscale. "
"It reuses the same conditioning and keeps the output video-only by " "It reuses the same conditioning and keeps the output video-only by "
"preserving the first pass's audio latent. Good for extra detail without " "preserving the first pass's audio latent. Use it for detail cleanup, not "
"building a separate graph."}), "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", "detail_sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"default": "euler",
"tooltip": "Sampler used for the optional refinement pass."}), "tooltip": "Sampler for the optional refinement pass. Euler is the maintained default "
"direction for this lane."}),
"detail_scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"default": "beta", "detail_scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"default": "beta",
"tooltip": "Scheduler used for the optional refinement pass."}), "tooltip": "Scheduler for the optional refinement pass. Beta is the maintained default "
"direction for the H3 enhancement lane."}),
"detail_steps": ("INT", {"default": 8, "min": 1, "max": 200, "detail_steps": ("INT", {"default": 8, "min": 1, "max": 200,
"tooltip": "Steps for the optional refinement pass."}), "tooltip": "Extra steps for the refinement pass only. Start around 4-8. More is not "
"automatically better; once the pass starts rewriting instead of polishing, "
"identity and continuity can drift."}),
"detail_denoise": ("FLOAT", {"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01, "detail_denoise": ("FLOAT", {"default": 0.4, "min": 0.0, "max": 1.0, "step": 0.01,
"tooltip": "How hard the refinement pass is allowed to rewrite the beat latent."}), "tooltip": "How hard the refinement pass is allowed to rewrite the beat latent. Start "
"around 0.20-0.35 for gentle cleanup; 0.4+ is stronger and can noticeably "
"change faces, motion or composition."}),
}, },
# Read-only graph access, for SLA-LoRA detection: a LoRA's filename is # 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 # the only thing that identifies an SLA build, and the graph is the only
@@ -6224,7 +6334,8 @@ class H3LongVideos:
handoff, decode_tile_frames=0, decode_tile_size=0, handoff, decode_tile_frames=0, decode_tile_size=0,
refs=None, ref_image_size="match", ref_noise_aug=None, silent=False, refs=None, ref_image_size="match", ref_noise_aug=None, silent=False,
detail_pass=False, detail_sampler_name="euler", detail_scheduler="beta", detail_pass=False, detail_sampler_name="euler", detail_scheduler="beta",
detail_steps=8, detail_denoise=0.4): 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, positive, latent = _build_shot_conditioning(clip, vae, prompt, w, h, ln, fps, handoff,
ref_images=refs, ref_image_size=ref_image_size, ref_images=refs, ref_image_size=ref_image_size,
ref_noise_aug=ref_noise_aug, ref_noise_aug=ref_noise_aug,
@@ -6234,8 +6345,10 @@ class H3LongVideos:
# whole sampling loop -- evict them and keep only the DiT on the card. # whole sampling loop -- evict them and keep only the DiT on the card.
_evict_all_but(model) _evict_all_but(model)
try: try:
sample_start = time.perf_counter()
(out,) = nodes.common_ksampler(model, seed, steps, cfg, sn, sch, positive, negative, (out,) = nodes.common_ksampler(model, seed, steps, cfg, sn, sch, positive, negative,
latent, denoise=denoise) latent, denoise=denoise)
timing["sample"] += time.perf_counter() - sample_start
except Exception as e: except Exception as e:
# Mark WHERE this failed. `tiled` only affects the DECODE, so the caller's # 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 # OOM retry cannot help an OOM raised here -- it just re-runs the whole
@@ -6249,9 +6362,11 @@ class H3LongVideos:
if detail_pass: if detail_pass:
detail_latent = _latent_with_replaced_samples(latent, out) detail_latent = _latent_with_replaced_samples(latent, out)
try: try:
detail_start = time.perf_counter()
(refined_out,) = nodes.common_ksampler( (refined_out,) = nodes.common_ksampler(
model, seed, int(detail_steps), cfg, detail_sampler_name, detail_scheduler, model, seed, int(detail_steps), cfg, detail_sampler_name, detail_scheduler,
positive, negative, detail_latent, denoise=float(detail_denoise)) positive, negative, detail_latent, denoise=float(detail_denoise))
timing["detail_sample"] += time.perf_counter() - detail_start
except Exception as e: except Exception as e:
if _is_oom(e): if _is_oom(e):
e._h3_stage = "sampling" e._h3_stage = "sampling"
@@ -6262,12 +6377,34 @@ class H3LongVideos:
# 1344x768 124f shot is ~1.5MB against ~1.5GB), so carrying one per shot for # 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 whole chain is free. Detached and moved off the card immediately, for
# the same reason the decoded frames are. # the same reason the decoded frames are.
decode_video_start = time.perf_counter()
shot_latent = _copy_sample_latent(refined_out) shot_latent = _copy_sample_latent(refined_out)
try:
video = _decode_video(vae, refined_out, tiled, free_first=model, video = _decode_video(vae, refined_out, tiled, free_first=model,
tile_t=decode_tile_frames, tile_xy=decode_tile_size) 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) 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 del out, refined_out, positive, latent
_deep_cleanup() _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 return video, audio, shot_latent
def run(self, model, clip, vae, audio_vae, prompt, resolution, def run(self, model, clip, vae, audio_vae, prompt, resolution,
@@ -6334,9 +6471,12 @@ class H3LongVideos:
), legacy_ref_slots) ), legacy_ref_slots)
) )
ref_slots = direct_ref_slots ref_slots = direct_ref_slots
direct_ref_count = len(_connected_refs(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) derived_character_memory = _reference_character_memory(ref_slots)
effective_character_memory = (character_memory or "").strip() or derived_character_memory effective_character_memory = explicit_character_memory or derived_character_memory
# A pixel budget overrides the preset's SIZE while keeping its aspect ratio, # 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 # 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 # the preset's own dimensions is what makes 1.00MP reproduce each native
@@ -6552,7 +6692,10 @@ class H3LongVideos:
count_auto=(subject_count_guard == "auto")) count_auto=(subject_count_guard == "auto"))
enriched_gens = [] enriched_gens = []
for block in gens: for block in gens:
context = _reference_context_for_text(block, ref_slots) context = _reference_context_for_text(
block,
ref_slots,
)
if context: if context:
block = _inject_reference_context(block, context) block = _inject_reference_context(block, context)
enriched_gens.append(block) enriched_gens.append(block)
@@ -6628,7 +6771,7 @@ class H3LongVideos:
else "prompt/soundscape silencing only")) else "prompt/soundscape silencing only"))
# Same reference accounting the render reports: which shots lose the # Same reference accounting the render reports: which shots lose the
# handoff is a composition decision, so it belongs in the preview. # handoff is a composition decision, so it belongs in the preview.
n_refs = len(_connected_refs(ref_slots)) n_refs = len(connected_refs)
plan_ref = "" plan_ref = ""
if n_refs: if n_refs:
# Mirror the render's placement exactly: 'where tagged' reads the # Mirror the render's placement exactly: 'where tagged' reads the
@@ -6641,7 +6784,7 @@ class H3LongVideos:
for shot_index, gen in enumerate(gens): 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 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 shot_mode in ("where tagged", "auto ref2v") and any_tags_anywhere:
if resolve_prompt_refs(gen, ref_slots)[1]: if resolve_tagged_refs(gen, ref_slots)[1]:
on.append(shot_index + 1) on.append(shot_index + 1)
tagged_used = True tagged_used = True
else: else:
@@ -6700,7 +6843,7 @@ class H3LongVideos:
latent_chunks = [] # per-shot sampled latents, pre-decode latent_chunks = [] # per-shot sampled latents, pre-decode
mouth_settled = [] # shots seeded from a settled (closed) mouth mouth_settled = [] # shots seeded from a settled (closed) mouth
handoff, sr = first_frame, None handoff, sr = first_frame, None
connected_ref_count = len(_connected_refs(ref_slots)) connected_ref_count = len(connected_refs)
ref_shots = [] # which shots ended up ref-conditioned ref_shots = [] # which shots ended up ref-conditioned
ref_missing = [] # <Picture N> tags naming an unconnected slot ref_missing = [] # <Picture N> tags naming an unconnected slot
ref_carried = [] # tagged shots that kept continuity as an extra ref ref_carried = [] # tagged shots that kept continuity as an extra ref
@@ -6708,6 +6851,7 @@ class H3LongVideos:
ref_mode_used = [] ref_mode_used = []
continuity_used = [] continuity_used = []
ref_aug_used = [] ref_aug_used = []
shot_timings = []
if cleanup_between_shots: if cleanup_between_shots:
_deep_cleanup() # start the first (heaviest) shot with max free VRAM _deep_cleanup() # start the first (heaviest) shot with max free VRAM
@@ -6735,7 +6879,7 @@ class H3LongVideos:
# The prompt itself says where each reference belongs: the shot whose # The prompt itself says where each reference belongs: the shot whose
# text names <Picture N> gets image N, renumbered to match what that # text names <Picture N> gets image N, renumbered to match what that
# shot actually carries. Every untagged shot keeps its handoff. # shot actually carries. Every untagged shot keeps its handoff.
gen_prompt, shot_refs, dropped = resolve_prompt_refs(gen_prompt, ref_slots) gen_prompt, shot_refs, dropped = resolve_tagged_refs(gen_prompt, ref_slots)
for n in dropped: for n in dropped:
if n not in ref_missing: if n not in ref_missing:
ref_missing.append(n) ref_missing.append(n)
@@ -6805,15 +6949,24 @@ class H3LongVideos:
ref_aug_used.append(shot_aug) ref_aug_used.append(shot_aug)
if shot_refs: if shot_refs:
ref_shots.append(i + 1) ref_shots.append(i + 1)
shot_total_start = time.perf_counter()
shot_retry_elapsed = 0.0
shot_attempts = 0
shot_timing = []
if i == 0: if i == 0:
while True: while True:
shot_attempts += 1
attempt_start = time.perf_counter()
try: 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, 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, shot_refs, ref_image_size, shot_aug, shot_silent,
detail_pass, detail_sampler_name, detail_scheduler, detail_pass, detail_sampler_name, detail_scheduler,
detail_steps, detail_denoise) detail_steps, detail_denoise, timing_sink=shot_timing)
break break
except (torch.cuda.OutOfMemoryError, RuntimeError) as e: except (torch.cuda.OutOfMemoryError, RuntimeError) as e:
shot_retry_elapsed += time.perf_counter() - attempt_start
if not _is_oom(e): if not _is_oom(e):
raise raise
mm.soft_empty_cache(True) mm.soft_empty_cache(True)
@@ -6826,11 +6979,16 @@ class H3LongVideos:
"Pick a smaller resolution, close other GPU apps, or use a smaller quant.") "Pick a smaller resolution, close other GPU apps, or use a smaller quant.")
else: else:
try: 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_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, shot_refs, ref_image_size, shot_aug, shot_silent,
detail_pass, detail_sampler_name, detail_scheduler, detail_pass, detail_sampler_name, detail_scheduler,
detail_steps, detail_denoise) detail_steps, detail_denoise, timing_sink=shot_timing)
except (torch.cuda.OutOfMemoryError, RuntimeError) as e: 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": if _is_oom(e) and getattr(e, "_h3_stage", "") == "sampling":
# Retrying with tiles would re-run the whole sampling pass and # Retrying with tiles would re-run the whole sampling pass and
# fail identically. Fail now, and say what actually shrinks it. # fail identically. Fail now, and say what actually shrinks it.
@@ -6841,10 +6999,28 @@ class H3LongVideos:
if not _is_oom(e) or tiled: if not _is_oom(e) or tiled:
raise raise
mm.soft_empty_cache(True); tiled = True; backoff.append(f"shot {i+1}: tiled") 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_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, shot_refs, ref_image_size, shot_aug, shot_silent,
detail_pass, detail_sampler_name, detail_scheduler, detail_pass, detail_sampler_name, detail_scheduler,
detail_steps, detail_denoise) 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: if shot_latent is not None:
latent_chunks.append(shot_latent) latent_chunks.append(shot_latent)
@@ -7081,6 +7257,7 @@ class H3LongVideos:
+ (f" (source {direct_ref_count} direct)" if direct_ref_count else "")) + (f" (source {direct_ref_count} direct)" if direct_ref_count else ""))
else: else:
ref_note = "" ref_note = ""
timing_note = _format_timing_note(shot_timings)
info = ((anchor_note + " ") if anchor_note else "") + \ info = ((anchor_note + " ") if anchor_note else "") + \
(f"{shape_str} at {w}x{h}; {all_frames.shape[0]} frames (~{actual:.1f}s actual). " (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}" f"decode {'tiled' if tiled else 'full'}. {vram_str}.{hoff_str}"
@@ -7109,6 +7286,7 @@ class H3LongVideos:
+ (" OVERRIDES -- " + "; ".join(override_notes) + "." + (" OVERRIDES -- " + "; ".join(override_notes) + "."
if override_notes else "") if override_notes else "")
+ (f"{ref_note}." if ref_note else "") + (f"{ref_note}." if ref_note else "")
+ (f" {timing_note}." if timing_note else "")
+ (f" {fps_note}." if fps_note else "") + (f" {fps_note}." if fps_note else "")
+ (f" {swap_note}." if swap_note else "") + (f" {swap_note}." if swap_note else "")
+ (f" free VRAM/shot: {vram_trace}." if len(vram_trace) > 1 else "") + (f" free VRAM/shot: {vram_trace}." if len(vram_trace) > 1 else "")
+204 -9
View File
@@ -160,6 +160,41 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
"keyframe carry", "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): def test_detail_pass_refines_video_but_preserves_audio(self):
class FakeTensor: class FakeTensor:
def __init__(self, name): def __init__(self, name):
@@ -191,6 +226,7 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
original_cleanup = self.module._deep_cleanup original_cleanup = self.module._deep_cleanup
original_nested = getattr(self.module.comfy.nested_tensor, "NestedTensor", None) original_nested = getattr(self.module.comfy.nested_tensor, "NestedTensor", None)
try: try:
sentinel_model = object()
self.module.comfy.nested_tensor.NestedTensor = FakeNestedTensor self.module.comfy.nested_tensor.NestedTensor = FakeNestedTensor
def common_ksampler(*args, **kwargs): def common_ksampler(*args, **kwargs):
@@ -208,7 +244,7 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
self.module._deep_cleanup = lambda: None self.module._deep_cleanup = lambda: None
result = self.module.H3LongVideos()._render( result = self.module.H3LongVideos()._render(
model=object(), model=sentinel_model,
clip=types.SimpleNamespace( clip=types.SimpleNamespace(
tokenize=lambda text, **kwargs: text, tokenize=lambda text, **kwargs: text,
encode_from_tokens_scheduled=lambda tokens: tokens, encode_from_tokens_scheduled=lambda tokens: tokens,
@@ -254,6 +290,95 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
else: else:
self.module.comfy.nested_tensor.NestedTensor = original_nested 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): def test_detail_pass_treats_falsey_strings_as_disabled(self):
calls = [] calls = []
original_common_ksampler = self.module.nodes.common_ksampler original_common_ksampler = self.module.nodes.common_ksampler
@@ -388,24 +513,26 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
) )
self.assertEqual(dropped, [4]) 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 = [ refs = [
{"kind": "character", "image": "img1", "name": "Mara"}, {"kind": "character", "image": "img1", "name": "Mara"},
{"kind": "character", "image": "img2", "name": "Jon"}, {"kind": "character", "image": "img2", "name": "Jon"},
{"kind": "location", "image": "img3", "name": "Hangar"}, {"kind": "location", "image": "img3", "name": "Hangar"},
] ]
text, references, dropped = self.module.resolve_prompt_refs( text, references, dropped, shot_tag_driven, mode_eff = self.module.resolve_shot_references(
"Mara and Jon argue inside <Picture 3>.", "[Generation 1] Mara crosses the hangar.",
refs, refs,
"auto ref2v",
0,
None,
) )
self.assertEqual(text, "Mara and Jon argue inside <Picture 1>.") self.assertEqual(text, "[Generation 1] Mara crosses the hangar.")
self.assertEqual( self.assertEqual([self.module._reference_image(ref) for ref in references], ["img1"])
[self.module._reference_image(ref) for ref in references],
["img3", "img1", "img2"],
)
self.assertEqual(dropped, []) self.assertEqual(dropped, [])
self.assertFalse(shot_tag_driven)
self.assertEqual(mode_eff, "auto ref2v")
def test_shot_references_uses_all_connected_sparse_slots(self): def test_shot_references_uses_all_connected_sparse_slots(self):
refs = [ refs = [
@@ -432,12 +559,24 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
for index in range(1, 10): for index in range(1, 10):
self.assertIn(f"ref_{index}", optional) 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): def test_input_types_keep_legacy_ref_image_aliases(self):
optional = self.module.H3LongVideos.INPUT_TYPES()["optional"] optional = self.module.H3LongVideos.INPUT_TYPES()["optional"]
for index in range(1, 10): for index in range(1, 10):
self.assertIn(f"ref_image_{index}", optional) 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): def test_run_defaults_match_declared_ref_widget_defaults(self):
node = self.module.H3LongVideos() node = self.module.H3LongVideos()
optional = node.INPUT_TYPES()["optional"] 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("Persistent wardrobe/style for Mara: red jacket.", context)
self.assertIn("Character notes for Mara: wears a long grey coat.", 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): def test_run_uses_legacy_ref_image_inputs_when_new_slots_are_empty(self):
calls = {} calls = {}
original_parse_resolution = self.module.parse_resolution 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 appearance for Mara: silver hair.", context)
self.assertIn("Persistent wardrobe/style for Mara: red jacket.", 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] <Picture 1> faces <Picture 2>.",
refs,
)
self.assertEqual(rewritten, "[Generation 1] faces <Picture 1>.")
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): def test_reference_context_injects_immediately_after_generation_label(self):
block = ( block = (
"[Generation 1] Classic sitcom lighting and staging. " "[Generation 1] Classic sitcom lighting and staging. "