Restore long videos release snapshot
This commit is contained in:
+87
-265
@@ -47,7 +47,6 @@ import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import torch
|
||||
|
||||
import nodes
|
||||
@@ -268,8 +267,7 @@ def split_paragraphs(text, delimiter):
|
||||
# Widgets added after the node's original 36-widget layout. Kept LAST in
|
||||
# INPUT_TYPES so a workflow saved before they existed still maps its stored values
|
||||
# onto the right widgets (ComfyUI matches them by position, not by name).
|
||||
# APPEND widget names to this tuple; never insert into the middle. Pure sockets
|
||||
# that carry no widget value can stay grouped in INPUT_TYPES without being listed.
|
||||
# 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",
|
||||
@@ -279,6 +277,7 @@ ADDED_WIDGETS = (
|
||||
"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",
|
||||
)
|
||||
@@ -3289,10 +3288,9 @@ def annotate_script_debug(gens, anatomy_shots, anatomy_mode, ref_slots):
|
||||
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"))
|
||||
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:
|
||||
@@ -4076,65 +4074,6 @@ def _coerce_bool_flag(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 ----------------------------------------
|
||||
# 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
|
||||
@@ -4343,10 +4282,7 @@ def _reference_slot(ref, slot_index=None):
|
||||
|
||||
def _reference_image(ref):
|
||||
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:
|
||||
return None
|
||||
return normalized.get("image")
|
||||
@@ -4436,19 +4372,6 @@ def _reference_name_keys(ref):
|
||||
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):
|
||||
@@ -4476,27 +4399,6 @@ def _named_character_refs_for_text(text, ref_slots):
|
||||
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()
|
||||
@@ -4553,9 +4455,8 @@ def _inject_reference_context(block, context):
|
||||
def _reference_character_memory(ref_slots):
|
||||
lines = []
|
||||
seen = set()
|
||||
for slot_number, ref in enumerate(ref_slots or [], 1):
|
||||
if ref is None:
|
||||
continue
|
||||
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"))
|
||||
@@ -4607,6 +4508,26 @@ def resolve_tagged_refs(text, ref_list):
|
||||
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):
|
||||
"""Pure: which reference images shot `shot_index` is conditioned on, or [] when
|
||||
the shot should use the keyframe handoff instead.
|
||||
@@ -5823,26 +5744,13 @@ class H3LongVideos:
|
||||
"tooltip": "Base H3 wants ~20 (res_multistep + simple). Drop to 6-8 ONLY with a "
|
||||
"working distill/turbo LoRA or a low-step MXFP8 checkpoint -- on the "
|
||||
"bare base model, low steps are the #1 cause of soft output."}),
|
||||
"cfg": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 30.0, "step": 0.1,
|
||||
"tooltip": "H3 is effectively CFG-free here. Leave this at 1.0 unless you are "
|
||||
"deliberately testing unusual sampler behaviour; higher values do not act "
|
||||
"like a normal SD/Flux CFG boost and are not the fix for weak identity."}),
|
||||
"sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"default": "res_multistep",
|
||||
"tooltip": "Main-pass sampler. Base H3 is tuned around res_multistep; changing this is "
|
||||
"a real behaviour change, not a cosmetic preference. Treat it as an advanced "
|
||||
"override."}),
|
||||
"scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"default": "simple",
|
||||
"tooltip": "Main-pass scheduler. Base H3 is tuned around simple when paired with "
|
||||
"res_multistep and the default model-sampling shifts."}),
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "control_after_generate": True,
|
||||
"tooltip": "Base seed for the whole chain. With vary_seed_per_shot OFF, every beat is "
|
||||
"sampled from the same noise field for better continuity. With it ON, this "
|
||||
"becomes the first seed and later beats use seed+1, seed+2, ..."}),
|
||||
"cfg": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 30.0, "step": 0.1}),
|
||||
"sampler_name": (comfy.samplers.KSampler.SAMPLERS, {"default": "res_multistep"}),
|
||||
"scheduler": (comfy.samplers.KSampler.SCHEDULERS, {"default": "simple"}),
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "control_after_generate": True}),
|
||||
},
|
||||
"optional": {
|
||||
"first_frame": ("IMAGE", {"tooltip": "Optional opening keyframe for shot 1. Leave empty to "
|
||||
"start from pure text/reference conditioning. On later shots the node normally uses the "
|
||||
"previous shot's last frame automatically; this socket is only the initial handoff."}),
|
||||
"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 <Picture 7> in the
|
||||
@@ -5852,20 +5760,19 @@ class H3LongVideos:
|
||||
# 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, <Picture 7>, walks in").
|
||||
"ref_1": ("REFERENCE", {"tooltip": "Reference slot <Picture 1>. 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 <Picture 2>. Socket numbers matter: "
|
||||
"tag this exact slot as <Picture 2> when you want explicit placement in a beat."}),
|
||||
"ref_3": ("REFERENCE", {"tooltip": "Reference slot <Picture 3>."}),
|
||||
"ref_4": ("REFERENCE", {"tooltip": "Reference slot <Picture 4>."}),
|
||||
"ref_5": ("REFERENCE", {"tooltip": "Reference slot <Picture 5>."}),
|
||||
"ref_6": ("REFERENCE", {"tooltip": "Reference slot <Picture 6>."}),
|
||||
"ref_7": ("REFERENCE", {"tooltip": "Reference slot <Picture 7>."}),
|
||||
"ref_8": ("REFERENCE", {"tooltip": "Reference slot <Picture 8>."}),
|
||||
"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. Keep old graphs working without rewiring."}),
|
||||
"ref_1": ("REFERENCE", {"tooltip": "Reference object for <Picture 1> -- image plus identity/environment metadata "
|
||||
"carried into the shots. Which shots receive it is set by ref_mode (or <Picture N> "
|
||||
"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 <Picture 2>."}),
|
||||
"ref_3": ("REFERENCE", {"tooltip": "Reference object for <Picture 3>."}),
|
||||
"ref_4": ("REFERENCE", {"tooltip": "Reference object for <Picture 4>."}),
|
||||
"ref_5": ("REFERENCE", {"tooltip": "Reference object for <Picture 5>."}),
|
||||
"ref_6": ("REFERENCE", {"tooltip": "Reference object for <Picture 6>."}),
|
||||
"ref_7": ("REFERENCE", {"tooltip": "Reference object for <Picture 7>."}),
|
||||
"ref_8": ("REFERENCE", {"tooltip": "Reference object for <Picture 8>."}),
|
||||
"ref_9": ("REFERENCE", {"tooltip": "Reference object for <Picture 9>."}),
|
||||
"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."}),
|
||||
@@ -5875,10 +5782,9 @@ class H3LongVideos:
|
||||
"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."}),
|
||||
"tooltip": "Preview the shot split WITHOUT rendering. Uses THIS node's own settings (no "
|
||||
"second node, no duplicate entry): returns the plan in 'info' and the "
|
||||
"shots/frames/seconds outputs near-instantly. Turn off to render for real."}),
|
||||
"fps": ("INT", {"default": 24, "min": 1, "max": 60,
|
||||
"tooltip": "DISPLAY ONLY -- H3 always renders 24 fps. The model's frame grid and its "
|
||||
"audio latent are both defined against 24, so this node computes every "
|
||||
@@ -5940,27 +5846,21 @@ class H3LongVideos:
|
||||
"mid-word open-mouth pose. Trims the matching audio tail too. 0 = last frame."}),
|
||||
"shot_seconds": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 15.1, "step": 0.5,
|
||||
"forceInput": True,
|
||||
"tooltip": "GLOBAL per-shot ceiling in seconds, not 'force every beat to exactly "
|
||||
"this length'. H3 Shot Length is the intended source because it snaps to "
|
||||
"H3's frame grid and reports the real duration.\n\n"
|
||||
"Leave UNCONNECTED for auto: the node picks the largest shot that fits "
|
||||
"the current size/VRAM budget. Connect a value to cap every beat at that "
|
||||
"length. A beat's own `seconds:` directive can still ask for less, and any "
|
||||
"request above H3's hard ~15.1s single-shot limit is clamped. Total video "
|
||||
"length is the sum of the beat shots, not simply beat-count x this value."}),
|
||||
"tooltip": "Length of EACH shot in seconds, taken from a connected input -- "
|
||||
"H3 Shot Length is the intended source, since it also reports the "
|
||||
"matching frame count on the 17k+5 grid.\n\n"
|
||||
"Leave it UNCONNECTED for auto: the largest shot that fits at the "
|
||||
"chosen size, which is what a 0 in the old widget did. One "
|
||||
"paragraph = one shot, so total video = (paragraph count) x this. "
|
||||
"Max ~15s."}),
|
||||
"allow_oversize_shots": ("BOOLEAN", {"default": False,
|
||||
"tooltip": "OFF (default): a forced shot_seconds that won't fit VRAM is clamped DOWN to "
|
||||
"what fits, and the clamp is reported in info. ON: honor the requested length "
|
||||
"even if it exceeds the budget -- the render may spill into system RAM (slow) "
|
||||
"or OOM. Only affects forced shot_seconds, not auto."}),
|
||||
"vram_headroom_gb": ("FLOAT", {"default": 1.5, "min": 0.0, "max": 32.0, "step": 0.5,
|
||||
"tooltip": "Safety margin RESERVED from free VRAM before the node budgets shot length. "
|
||||
"Higher = shorter safer shots. Lower = longer shots but more risk of spill or "
|
||||
"OOM during sampling/decode peaks. 1.5GB is the conservative default."}),
|
||||
"vram_headroom_gb": ("FLOAT", {"default": 1.5, "min": 0.0, "max": 32.0, "step": 0.5}),
|
||||
"allow_res_backoff": ("BOOLEAN", {"default": True,
|
||||
"tooltip": "If a render does not fit, try stepping the resolution down instead of just "
|
||||
"failing. Helps salvage long jobs automatically, but the later shots may come "
|
||||
"back smaller and the latent output can no longer join cleanly across sizes."}),
|
||||
"tooltip": "If VRAM is tight, step resolution down instead of failing."}),
|
||||
# ON by default: the prompt-side clauses ASK H3 not to vocalize (and now
|
||||
# condition the soundscape field too), but asking is not a guarantee --
|
||||
# babble under a silent shot was the one artifact that survived both.
|
||||
@@ -6044,7 +5944,7 @@ class H3LongVideos:
|
||||
"white as asked; 2-3 makes it survive a bright sky or a white wall."}),
|
||||
"ref_mode": (["auto ref2v", "where tagged", "first shot", "every shot", "every shot + handoff ref"],
|
||||
{"default": "auto ref2v",
|
||||
"tooltip": "Which shots the reference IMAGE inputs condition. 'auto ref2v' (default) is "
|
||||
"tooltip": "Which shots the ref_image inputs condition. 'auto ref2v' (default) is "
|
||||
"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 "
|
||||
"node conditions EVERY shot with the connected refs rather than collapsing "
|
||||
@@ -6052,10 +5952,7 @@ class H3LongVideos:
|
||||
"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 <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' / "
|
||||
"so <Picture 2> 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,
|
||||
@@ -6292,23 +6189,16 @@ class H3LongVideos:
|
||||
"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."}),
|
||||
"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 for the optional refinement pass. Euler is the maintained default "
|
||||
"direction for this lane."}),
|
||||
"tooltip": "Sampler used for the optional refinement pass."}),
|
||||
"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."}),
|
||||
"tooltip": "Scheduler used for the optional refinement pass."}),
|
||||
"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."}),
|
||||
"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. Start "
|
||||
"around 0.20-0.35 for gentle cleanup; 0.4+ is stronger and can noticeably "
|
||||
"change faces, motion or composition."}),
|
||||
"tooltip": "How hard the refinement pass is allowed to rewrite the beat latent."}),
|
||||
},
|
||||
# Read-only graph access, for SLA-LoRA detection: a LoRA's filename is
|
||||
# the only thing that identifies an SLA build, and the graph is the only
|
||||
@@ -6334,8 +6224,7 @@ class H3LongVideos:
|
||||
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}
|
||||
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,
|
||||
@@ -6345,10 +6234,8 @@ class H3LongVideos:
|
||||
# whole sampling loop -- evict them and keep only the DiT on the card.
|
||||
_evict_all_but(model)
|
||||
try:
|
||||
sample_start = time.perf_counter()
|
||||
(out,) = nodes.common_ksampler(model, seed, steps, cfg, sn, sch, positive, negative,
|
||||
latent, denoise=denoise)
|
||||
timing["sample"] += time.perf_counter() - sample_start
|
||||
except Exception as e:
|
||||
# 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
|
||||
@@ -6362,11 +6249,9 @@ class H3LongVideos:
|
||||
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"
|
||||
@@ -6377,34 +6262,12 @@ class H3LongVideos:
|
||||
# 1344x768 124f shot is ~1.5MB against ~1.5GB), so carrying one per shot for
|
||||
# the whole chain is free. Detached and moved off the card immediately, for
|
||||
# the same reason the decoded frames are.
|
||||
decode_video_start = time.perf_counter()
|
||||
shot_latent = _copy_sample_latent(refined_out)
|
||||
try:
|
||||
video = _decode_video(vae, refined_out, tiled, free_first=model,
|
||||
tile_t=decode_tile_frames, tile_xy=decode_tile_size)
|
||||
except Exception as e:
|
||||
# Decode is the biggest allocation in the run. If the straight path
|
||||
# fails, retry once with tiled decode and without the aggressive unload
|
||||
# so a marginal card can still finish the render.
|
||||
if not _is_oom(e) and "decode" not in str(e).lower():
|
||||
raise
|
||||
mm.soft_empty_cache(True)
|
||||
retry_tiled = True
|
||||
retry_tile_t = decode_tile_frames or 16
|
||||
retry_tile_xy = decode_tile_size or 256
|
||||
video = _decode_video(vae, refined_out, retry_tiled, free_first=None,
|
||||
tile_t=retry_tile_t, tile_xy=retry_tile_xy)
|
||||
timing["decode_video"] += time.perf_counter() - decode_video_start
|
||||
decode_audio_start = time.perf_counter()
|
||||
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)
|
||||
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,
|
||||
@@ -6471,12 +6334,9 @@ class H3LongVideos:
|
||||
), 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()
|
||||
direct_ref_count = len(_connected_refs(direct_ref_slots))
|
||||
derived_character_memory = _reference_character_memory(ref_slots)
|
||||
effective_character_memory = explicit_character_memory or derived_character_memory
|
||||
effective_character_memory = (character_memory or "").strip() or derived_character_memory
|
||||
# A pixel budget overrides the preset's SIZE while keeping its aspect ratio,
|
||||
# so the dropdown chooses the shape and this chooses how big. Scaling from
|
||||
# the preset's own dimensions is what makes 1.00MP reproduce each native
|
||||
@@ -6692,10 +6552,7 @@ class H3LongVideos:
|
||||
count_auto=(subject_count_guard == "auto"))
|
||||
enriched_gens = []
|
||||
for block in gens:
|
||||
context = _reference_context_for_text(
|
||||
block,
|
||||
ref_slots,
|
||||
)
|
||||
context = _reference_context_for_text(block, ref_slots)
|
||||
if context:
|
||||
block = _inject_reference_context(block, context)
|
||||
enriched_gens.append(block)
|
||||
@@ -6771,7 +6628,7 @@ 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)
|
||||
n_refs = len(_connected_refs(ref_slots))
|
||||
plan_ref = ""
|
||||
if n_refs:
|
||||
# Mirror the render's placement exactly: 'where tagged' reads the
|
||||
@@ -6784,7 +6641,7 @@ class H3LongVideos:
|
||||
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]:
|
||||
if resolve_prompt_refs(gen, ref_slots)[1]:
|
||||
on.append(shot_index + 1)
|
||||
tagged_used = True
|
||||
else:
|
||||
@@ -6843,7 +6700,7 @@ 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)
|
||||
connected_ref_count = len(_connected_refs(ref_slots))
|
||||
ref_shots = [] # which shots ended up ref-conditioned
|
||||
ref_missing = [] # <Picture N> tags naming an unconnected slot
|
||||
ref_carried = [] # tagged shots that kept continuity as an extra ref
|
||||
@@ -6851,7 +6708,6 @@ class H3LongVideos:
|
||||
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
|
||||
|
||||
@@ -6879,7 +6735,7 @@ class H3LongVideos:
|
||||
# The prompt itself says where each reference belongs: the shot whose
|
||||
# text names <Picture N> 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)
|
||||
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)
|
||||
@@ -6949,24 +6805,15 @@ class H3LongVideos:
|
||||
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)
|
||||
frames, audio, shot_latent = self._render(model, clip, vae, audio_vae, negative, gen_prompt, w, h, ln_i, fps, tiled, sa, shot_handoff, decode_tile_frames, decode_tile_size,
|
||||
shot_refs, ref_image_size, shot_aug, shot_silent,
|
||||
detail_pass, detail_sampler_name, detail_scheduler,
|
||||
detail_steps, detail_denoise)
|
||||
break
|
||||
except (torch.cuda.OutOfMemoryError, RuntimeError) as e:
|
||||
shot_retry_elapsed += time.perf_counter() - attempt_start
|
||||
if not _is_oom(e):
|
||||
raise
|
||||
mm.soft_empty_cache(True)
|
||||
@@ -6979,16 +6826,11 @@ class H3LongVideos:
|
||||
"Pick a smaller resolution, close other GPU apps, or use a smaller quant.")
|
||||
else:
|
||||
try:
|
||||
shot_attempts += 1
|
||||
attempt_start = time.perf_counter()
|
||||
frames, audio, shot_latent = self._render(
|
||||
model, clip, vae, audio_vae, negative, gen_prompt, w, h, ln_i, fps,
|
||||
tiled, sa, shot_handoff, decode_tile_frames, decode_tile_size,
|
||||
shot_refs, ref_image_size, shot_aug, shot_silent,
|
||||
detail_pass, detail_sampler_name, detail_scheduler,
|
||||
detail_steps, detail_denoise, timing_sink=shot_timing)
|
||||
frames, audio, shot_latent = self._render(model, clip, vae, audio_vae, negative, gen_prompt, w, h, ln_i, fps, tiled, sa, shot_handoff, decode_tile_frames, decode_tile_size,
|
||||
shot_refs, ref_image_size, shot_aug, shot_silent,
|
||||
detail_pass, detail_sampler_name, detail_scheduler,
|
||||
detail_steps, detail_denoise)
|
||||
except (torch.cuda.OutOfMemoryError, RuntimeError) as e:
|
||||
shot_retry_elapsed += time.perf_counter() - attempt_start
|
||||
if _is_oom(e) and getattr(e, "_h3_stage", "") == "sampling":
|
||||
# Retrying with tiles would re-run the whole sampling pass and
|
||||
# fail identically. Fail now, and say what actually shrinks it.
|
||||
@@ -6999,28 +6841,10 @@ class H3LongVideos:
|
||||
if not _is_oom(e) or tiled:
|
||||
raise
|
||||
mm.soft_empty_cache(True); tiled = True; backoff.append(f"shot {i+1}: tiled")
|
||||
shot_attempts += 1
|
||||
attempt_start = time.perf_counter()
|
||||
frames, audio, shot_latent = self._render(
|
||||
model, clip, vae, audio_vae, negative, gen_prompt, w, h, ln_i, fps,
|
||||
tiled, sa, shot_handoff, decode_tile_frames, decode_tile_size,
|
||||
shot_refs, ref_image_size, shot_aug, shot_silent,
|
||||
detail_pass, detail_sampler_name, detail_scheduler,
|
||||
detail_steps, detail_denoise, timing_sink=shot_timing)
|
||||
shot_retry_elapsed += time.perf_counter() - attempt_start
|
||||
|
||||
shot_total = time.perf_counter() - shot_total_start
|
||||
if shot_timing:
|
||||
render_timing = dict(shot_timing[-1])
|
||||
else:
|
||||
render_timing = {}
|
||||
shot_timings.append({
|
||||
"shot": i + 1,
|
||||
"total": shot_total,
|
||||
"retry_elapsed": shot_retry_elapsed,
|
||||
"attempts": shot_attempts,
|
||||
**render_timing,
|
||||
})
|
||||
frames, audio, shot_latent = self._render(model, clip, vae, audio_vae, negative, gen_prompt, w, h, ln_i, fps, tiled, sa, shot_handoff, decode_tile_frames, decode_tile_size,
|
||||
shot_refs, ref_image_size, shot_aug, shot_silent,
|
||||
detail_pass, detail_sampler_name, detail_scheduler,
|
||||
detail_steps, detail_denoise)
|
||||
|
||||
if shot_latent is not None:
|
||||
latent_chunks.append(shot_latent)
|
||||
@@ -7257,7 +7081,6 @@ class H3LongVideos:
|
||||
+ (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}"
|
||||
@@ -7286,7 +7109,6 @@ class H3LongVideos:
|
||||
+ (" 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 "")
|
||||
|
||||
@@ -160,41 +160,6 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
"keyframe carry",
|
||||
)
|
||||
|
||||
def test_timing_summary_reports_retry_and_bucket_totals(self):
|
||||
note = self.module._format_timing_note([
|
||||
{
|
||||
"shot": 1,
|
||||
"total": 12.4,
|
||||
"retry_elapsed": 1.2,
|
||||
"attempts": 2,
|
||||
"sample": 8.0,
|
||||
"detail_sample": 0.5,
|
||||
"decode_video": 2.1,
|
||||
"decode_audio": 0.4,
|
||||
"cleanup": 0.2,
|
||||
},
|
||||
{
|
||||
"shot": 2,
|
||||
"total": 7.6,
|
||||
"retry_elapsed": 0.0,
|
||||
"attempts": 1,
|
||||
"sample": 6.5,
|
||||
"decode_video": 0.5,
|
||||
"decode_audio": 0.3,
|
||||
"cleanup": 0.1,
|
||||
},
|
||||
])
|
||||
|
||||
self.assertIn("timing: 2 shot(s) total 20.0s", note)
|
||||
self.assertIn("sample 14.5s", note)
|
||||
self.assertIn("decode video 2.6s", note)
|
||||
self.assertIn("decode audio 0.7s", note)
|
||||
self.assertIn("cleanup 0.3s", note)
|
||||
self.assertIn("retry elapsed 1.2s", note)
|
||||
self.assertIn("detail 0.5s", note)
|
||||
self.assertIn("retries 1", note)
|
||||
self.assertIn("slowest shot 1 12.4s", note)
|
||||
|
||||
def test_detail_pass_refines_video_but_preserves_audio(self):
|
||||
class FakeTensor:
|
||||
def __init__(self, name):
|
||||
@@ -226,7 +191,6 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
original_cleanup = self.module._deep_cleanup
|
||||
original_nested = getattr(self.module.comfy.nested_tensor, "NestedTensor", None)
|
||||
try:
|
||||
sentinel_model = object()
|
||||
self.module.comfy.nested_tensor.NestedTensor = FakeNestedTensor
|
||||
|
||||
def common_ksampler(*args, **kwargs):
|
||||
@@ -244,7 +208,7 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
self.module._deep_cleanup = lambda: None
|
||||
|
||||
result = self.module.H3LongVideos()._render(
|
||||
model=sentinel_model,
|
||||
model=object(),
|
||||
clip=types.SimpleNamespace(
|
||||
tokenize=lambda text, **kwargs: text,
|
||||
encode_from_tokens_scheduled=lambda tokens: tokens,
|
||||
@@ -290,95 +254,6 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
else:
|
||||
self.module.comfy.nested_tensor.NestedTensor = original_nested
|
||||
|
||||
def test_render_retries_decode_with_tiling_after_decode_oom(self):
|
||||
class FakeTensor:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def detach(self):
|
||||
return self
|
||||
|
||||
def to(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
class FakeNestedTensor:
|
||||
def __init__(self, parts):
|
||||
self._parts = tuple(parts)
|
||||
self.is_nested = True
|
||||
|
||||
def unbind(self):
|
||||
return self._parts
|
||||
|
||||
decode_calls = []
|
||||
cleanup_calls = []
|
||||
first_out = {"samples": FakeNestedTensor((FakeTensor("v1"), FakeTensor("a1")))}
|
||||
|
||||
original_common_ksampler = self.module.nodes.common_ksampler
|
||||
original_build = self.module._build_shot_conditioning
|
||||
original_evict = self.module._evict_all_but
|
||||
original_decode_video = self.module._decode_video
|
||||
original_decode_audio = self.module._decode_audio
|
||||
original_cleanup = self.module._deep_cleanup
|
||||
original_soft_empty_cache = self.module.mm.soft_empty_cache
|
||||
original_nested = getattr(self.module.comfy.nested_tensor, "NestedTensor", None)
|
||||
try:
|
||||
sentinel_model = object()
|
||||
self.module.comfy.nested_tensor.NestedTensor = FakeNestedTensor
|
||||
self.module.nodes.common_ksampler = lambda *args, **kwargs: (first_out,)
|
||||
self.module._build_shot_conditioning = lambda *_args, **_kwargs: (
|
||||
"cond",
|
||||
{"samples": FakeNestedTensor((FakeTensor("basev"), FakeTensor("basea")))},
|
||||
)
|
||||
self.module._evict_all_but = lambda *_args, **_kwargs: None
|
||||
|
||||
def decode_video(_vae, out_latent, tiled, free_first=None, tile_t=None, tile_xy=None):
|
||||
decode_calls.append((tiled, free_first, tile_t, tile_xy))
|
||||
if len(decode_calls) == 1:
|
||||
raise RuntimeError("CUDA out of memory during decode")
|
||||
return out_latent
|
||||
|
||||
self.module._decode_video = decode_video
|
||||
self.module._decode_audio = lambda _vae, out_latent: out_latent
|
||||
self.module.mm.soft_empty_cache = lambda *args, **kwargs: cleanup_calls.append((args, kwargs))
|
||||
self.module._deep_cleanup = lambda: None
|
||||
|
||||
result = self.module.H3LongVideos()._render(
|
||||
model=sentinel_model,
|
||||
clip=types.SimpleNamespace(
|
||||
tokenize=lambda text, **kwargs: text,
|
||||
encode_from_tokens_scheduled=lambda tokens: tokens,
|
||||
),
|
||||
vae=object(),
|
||||
audio_vae=object(),
|
||||
negative="negative",
|
||||
prompt="beat",
|
||||
w=128,
|
||||
h=64,
|
||||
ln=24,
|
||||
fps=24,
|
||||
tiled=False,
|
||||
sa=(123, 20, 1.0, "res_multistep", "simple", 1.0),
|
||||
handoff=None,
|
||||
)
|
||||
|
||||
self.assertEqual(len(decode_calls), 2)
|
||||
self.assertEqual(decode_calls[0], (False, sentinel_model, 0, 0))
|
||||
self.assertEqual(decode_calls[1], (True, None, 16, 256))
|
||||
self.assertTrue(cleanup_calls)
|
||||
self.assertIs(result[1], first_out)
|
||||
finally:
|
||||
self.module.nodes.common_ksampler = original_common_ksampler
|
||||
self.module._build_shot_conditioning = original_build
|
||||
self.module._evict_all_but = original_evict
|
||||
self.module._decode_video = original_decode_video
|
||||
self.module._decode_audio = original_decode_audio
|
||||
self.module._deep_cleanup = original_cleanup
|
||||
self.module.mm.soft_empty_cache = original_soft_empty_cache
|
||||
if original_nested is None:
|
||||
delattr(self.module.comfy.nested_tensor, "NestedTensor")
|
||||
else:
|
||||
self.module.comfy.nested_tensor.NestedTensor = original_nested
|
||||
|
||||
def test_detail_pass_treats_falsey_strings_as_disabled(self):
|
||||
calls = []
|
||||
original_common_ksampler = self.module.nodes.common_ksampler
|
||||
@@ -513,26 +388,24 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(dropped, [4])
|
||||
|
||||
def test_resolve_shot_references_uses_named_characters_without_picture_tags(self):
|
||||
def test_resolve_prompt_refs_keeps_named_character_images_alongside_tagged_location(self):
|
||||
refs = [
|
||||
{"kind": "character", "image": "img1", "name": "Mara"},
|
||||
{"kind": "character", "image": "img2", "name": "Jon"},
|
||||
{"kind": "location", "image": "img3", "name": "Hangar"},
|
||||
]
|
||||
|
||||
text, references, dropped, shot_tag_driven, mode_eff = self.module.resolve_shot_references(
|
||||
"[Generation 1] Mara crosses the hangar.",
|
||||
text, references, dropped = self.module.resolve_prompt_refs(
|
||||
"Mara and Jon argue inside <Picture 3>.",
|
||||
refs,
|
||||
"auto ref2v",
|
||||
0,
|
||||
None,
|
||||
)
|
||||
|
||||
self.assertEqual(text, "[Generation 1] Mara crosses the hangar.")
|
||||
self.assertEqual([self.module._reference_image(ref) for ref in references], ["img1"])
|
||||
self.assertEqual(text, "Mara and Jon argue inside <Picture 1>.")
|
||||
self.assertEqual(
|
||||
[self.module._reference_image(ref) for ref in references],
|
||||
["img3", "img1", "img2"],
|
||||
)
|
||||
self.assertEqual(dropped, [])
|
||||
self.assertFalse(shot_tag_driven)
|
||||
self.assertEqual(mode_eff, "auto ref2v")
|
||||
|
||||
def test_shot_references_uses_all_connected_sparse_slots(self):
|
||||
refs = [
|
||||
@@ -559,24 +432,12 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
for index in range(1, 10):
|
||||
self.assertIn(f"ref_{index}", optional)
|
||||
|
||||
names = list(optional)
|
||||
ref_positions = [names.index(f"ref_{index}") for index in range(1, 10)]
|
||||
self.assertEqual(ref_positions, list(range(ref_positions[0], ref_positions[0] + 9)))
|
||||
|
||||
def test_input_types_keep_legacy_ref_image_aliases(self):
|
||||
optional = self.module.H3LongVideos.INPUT_TYPES()["optional"]
|
||||
|
||||
for index in range(1, 10):
|
||||
self.assertIn(f"ref_image_{index}", optional)
|
||||
|
||||
def test_shot_seconds_tooltip_describes_ceiling_behavior(self):
|
||||
optional = self.module.H3LongVideos.INPUT_TYPES()["optional"]
|
||||
tooltip = optional["shot_seconds"][1]["tooltip"]
|
||||
|
||||
self.assertIn("GLOBAL per-shot ceiling", tooltip)
|
||||
self.assertIn("not 'force every beat to exactly this length'", tooltip)
|
||||
self.assertIn("hard ~15.1s single-shot limit", tooltip)
|
||||
|
||||
def test_run_defaults_match_declared_ref_widget_defaults(self):
|
||||
node = self.module.H3LongVideos()
|
||||
optional = node.INPUT_TYPES()["optional"]
|
||||
@@ -636,32 +497,6 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
self.assertIn("Persistent wardrobe/style for Mara: red jacket.", context)
|
||||
self.assertIn("Character notes for Mara: wears a long grey coat.", context)
|
||||
|
||||
def test_reference_context_matches_location_names_without_picture_tag(self):
|
||||
refs = [
|
||||
{"kind": "location", "image": "img2", "name": "Hangar", "aliases": ["loading bay"], "description": "wet concrete floor", "general": "cold industrial lighting"},
|
||||
]
|
||||
|
||||
context = self.module._reference_context_for_text(
|
||||
"[Generation 1] They argue in the hangar near the loading bay.",
|
||||
refs,
|
||||
)
|
||||
|
||||
self.assertIn("Location context for Hangar: wet concrete floor.", context)
|
||||
self.assertIn("Location notes for Hangar: cold industrial lighting.", context)
|
||||
|
||||
def test_reference_context_skips_ambiguous_name_matches(self):
|
||||
refs = [
|
||||
{"kind": "character", "image": "img1", "name": "Alex", "description": "short dark hair"},
|
||||
{"kind": "character", "image": "img2", "name": "Alex", "description": "tall blond hair"},
|
||||
]
|
||||
|
||||
context = self.module._reference_context_for_text(
|
||||
"[Generation 1] Alex enters the room.",
|
||||
refs,
|
||||
)
|
||||
|
||||
self.assertEqual(context, "")
|
||||
|
||||
def test_run_uses_legacy_ref_image_inputs_when_new_slots_are_empty(self):
|
||||
calls = {}
|
||||
original_parse_resolution = self.module.parse_resolution
|
||||
@@ -773,36 +608,6 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
self.assertIn("Persistent appearance for Mara: silver hair.", context)
|
||||
self.assertIn("Persistent wardrobe/style for Mara: red jacket.", context)
|
||||
|
||||
def test_reference_context_can_skip_character_wardrobe_when_live_memory_is_explicit(self):
|
||||
refs = [
|
||||
{"kind": "character", "image": "img1", "name": "Mara", "description": "silver hair", "wardrobe": "red jacket"},
|
||||
]
|
||||
|
||||
context = self.module._reference_context_for_text(
|
||||
"[Generation 1] Mara walks into the room.",
|
||||
refs,
|
||||
include_character_wardrobe=False,
|
||||
)
|
||||
|
||||
self.assertIn("Persistent appearance for Mara: silver hair.", context)
|
||||
self.assertNotIn("Persistent wardrobe/style for Mara: red jacket.", context)
|
||||
|
||||
def test_resolve_tagged_refs_drops_reference_without_image(self):
|
||||
refs = [
|
||||
{"kind": "character", "image": None, "name": "Mara"},
|
||||
{"kind": "character", "image": "img2", "name": "Jon"},
|
||||
]
|
||||
|
||||
rewritten, matched, dropped = self.module.resolve_tagged_refs(
|
||||
"[Generation 1] <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):
|
||||
block = (
|
||||
"[Generation 1] Classic sitcom lighting and staging. "
|
||||
|
||||
Reference in New Issue
Block a user