Revert "Restore long videos release snapshot"
This reverts commit 4a0f5cfcd4.
This commit is contained in:
+265
-87
@@ -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,7 +4343,10 @@ def _reference_slot(ref, slot_index=None):
|
|||||||
|
|
||||||
def _reference_image(ref):
|
def _reference_image(ref):
|
||||||
try:
|
try:
|
||||||
normalized = _reference_slot(ref)
|
if isinstance(ref, dict):
|
||||||
|
normalized = _image_nodes.normalize_reference(ref, allow_image_fallback=False)
|
||||||
|
else:
|
||||||
|
normalized = _reference_slot(ref)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
return normalized.get("image")
|
return normalized.get("image")
|
||||||
@@ -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)
|
||||||
video = _decode_video(vae, refined_out, tiled, free_first=model,
|
try:
|
||||||
tile_t=decode_tile_frames, tile_xy=decode_tile_size)
|
video = _decode_video(vae, refined_out, tiled, free_first=model,
|
||||||
|
tile_t=decode_tile_frames, tile_xy=decode_tile_size)
|
||||||
|
except Exception as e:
|
||||||
|
# Decode is the biggest allocation in the run. If the straight path
|
||||||
|
# fails, retry once with tiled decode and without the aggressive unload
|
||||||
|
# so a marginal card can still finish the render.
|
||||||
|
if not _is_oom(e) and "decode" not in str(e).lower():
|
||||||
|
raise
|
||||||
|
mm.soft_empty_cache(True)
|
||||||
|
retry_tiled = True
|
||||||
|
retry_tile_t = decode_tile_frames or 16
|
||||||
|
retry_tile_xy = decode_tile_size or 256
|
||||||
|
video = _decode_video(vae, refined_out, retry_tiled, free_first=None,
|
||||||
|
tile_t=retry_tile_t, tile_xy=retry_tile_xy)
|
||||||
|
timing["decode_video"] += time.perf_counter() - decode_video_start
|
||||||
|
decode_audio_start = time.perf_counter()
|
||||||
audio = _decode_audio(audio_vae, out)
|
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(
|
||||||
shot_refs, ref_image_size, shot_aug, shot_silent,
|
model, clip, vae, audio_vae, negative, gen_prompt, w, h, ln_i, fps,
|
||||||
detail_pass, detail_sampler_name, detail_scheduler,
|
tiled, sa, shot_handoff, decode_tile_frames, decode_tile_size,
|
||||||
detail_steps, detail_denoise)
|
shot_refs, ref_image_size, shot_aug, shot_silent,
|
||||||
|
detail_pass, detail_sampler_name, detail_scheduler,
|
||||||
|
detail_steps, detail_denoise, timing_sink=shot_timing)
|
||||||
break
|
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
|
||||||
shot_refs, ref_image_size, shot_aug, shot_silent,
|
attempt_start = time.perf_counter()
|
||||||
detail_pass, detail_sampler_name, detail_scheduler,
|
frames, audio, shot_latent = self._render(
|
||||||
detail_steps, detail_denoise)
|
model, clip, vae, audio_vae, negative, gen_prompt, w, h, ln_i, fps,
|
||||||
|
tiled, sa, shot_handoff, decode_tile_frames, decode_tile_size,
|
||||||
|
shot_refs, ref_image_size, shot_aug, shot_silent,
|
||||||
|
detail_pass, detail_sampler_name, detail_scheduler,
|
||||||
|
detail_steps, detail_denoise, timing_sink=shot_timing)
|
||||||
except (torch.cuda.OutOfMemoryError, RuntimeError) as e:
|
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
|
||||||
shot_refs, ref_image_size, shot_aug, shot_silent,
|
attempt_start = time.perf_counter()
|
||||||
detail_pass, detail_sampler_name, detail_scheduler,
|
frames, audio, shot_latent = self._render(
|
||||||
detail_steps, detail_denoise)
|
model, clip, vae, audio_vae, negative, gen_prompt, w, h, ln_i, fps,
|
||||||
|
tiled, sa, shot_handoff, decode_tile_frames, decode_tile_size,
|
||||||
|
shot_refs, ref_image_size, shot_aug, shot_silent,
|
||||||
|
detail_pass, detail_sampler_name, detail_scheduler,
|
||||||
|
detail_steps, detail_denoise, timing_sink=shot_timing)
|
||||||
|
shot_retry_elapsed += time.perf_counter() - attempt_start
|
||||||
|
|
||||||
|
shot_total = time.perf_counter() - shot_total_start
|
||||||
|
if shot_timing:
|
||||||
|
render_timing = dict(shot_timing[-1])
|
||||||
|
else:
|
||||||
|
render_timing = {}
|
||||||
|
shot_timings.append({
|
||||||
|
"shot": i + 1,
|
||||||
|
"total": shot_total,
|
||||||
|
"retry_elapsed": shot_retry_elapsed,
|
||||||
|
"attempts": shot_attempts,
|
||||||
|
**render_timing,
|
||||||
|
})
|
||||||
|
|
||||||
if shot_latent is not None:
|
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 "")
|
||||||
|
|||||||
@@ -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. "
|
||||||
|
|||||||
Reference in New Issue
Block a user