Three reference videos and three voices, and the clips keep their sound
Browse filesH3 takes 9 reference images, 3 reference videos and 3 standalone reference
audios (comfy_extras/nodes_minimax_h3.py:265-280). The pack matched the 9 and
hardcoded exactly one of each of the others, two lines apart:
base_videos = {"ref_video_1": reference_video}
ref_audios = {"ref_audio_1": voice}
So two whole channels ran at a third of capacity. This adds slots 2 and 3 for
both -- twelve widgets, APPENDED, because widgets_values is positional.
Numbering is DENSE. Core numbers reference blocks by iteration order and the
prompt cites those ordinals, so filling slots 1 and 3 must produce <Video 1>
and <Video 2>, never a gap -- otherwise a beat naming "the second clip" cites
something else and nothing reports it. _dense_media() does that and is tested.
ref_video_audios was never passed at all, so a reference clip reached the model
SILENT even when the file had a soundtrack. Each clip's own audio is now
decoded and paired to the same ordinal, trimmed to the same window as its
picture. load_audio() grew a `kinds` parameter so it can be pointed at a video;
the default is unchanged. A clip with no usable audio track logs and passes
silent rather than failing the run.
Cache: chain_salt digested only slot 1 of each. It now digests all three plus
which clips carried sound, or a chain rendered with a second voice could be
served to a run that dropped it. IS_CHANGED covers the new filenames too, so
overwriting clip2.mp4 in place re-runs.
Slots 2 and 3 share slot 1's reference_video_size -- it is a decode area
budget, not a per-clip creative choice, and three clips is already three times
the RAM.
Adds tools/check_media_slots.py: dense numbering including the gap case, that
every file widget has its trim widgets, and -- the guard that did not exist --
that every `*_file` widget is claimed by media_strip.js. A Python file widget
with no slot in the strip falls through to a native dial, which is the 0.4.0
failure recorded at run_panel.js:53 and has recurred since.
Not rendered. The plumbing is checked; whether three voices behave needs a GPU.
13/13 checks pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013orqT1xNDezvdwv4f51MdW
- h3_ref_chain.py +186 -7
- js/editor/media_strip.js +18 -0
- media.py +5 -2
- tools/check_all.py +1 -0
- tools/check_media_slots.py +111 -0
- workflows/HandTieClips_Showcase.json +13 -1
- workflows/HandTieClips_Starter.json +13 -1
|
@@ -902,6 +902,22 @@ def _condition_pin_latent(lat, anchor, mode="off", noise=0.0, seed=0):
|
|
| 902 |
return new, anchor
|
| 903 |
|
| 904 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 905 |
def _pin_mech_for(hop_index, overlap_n, prev_sampled):
|
| 906 |
"""Which mechanism `_pin_continue` will pick, without doing the work.
|
| 907 |
|
|
@@ -1639,6 +1655,84 @@ class HandTieClips:
|
|
| 1639 |
"rather than by edge."
|
| 1640 |
),
|
| 1641 |
}),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1642 |
},
|
| 1643 |
"hidden": {
|
| 1644 |
"unique_id": "UNIQUE_ID",
|
|
@@ -1665,7 +1759,9 @@ class HandTieClips:
|
|
| 1665 |
@classmethod
|
| 1666 |
def IS_CHANGED(cls, ref_plan="", start_image_file="",
|
| 1667 |
reference_video_file="", voice_file="",
|
| 1668 |
-
soundtrack_file="",
|
|
|
|
|
|
|
| 1669 |
"""Re-run when a reference file changes underneath its name.
|
| 1670 |
|
| 1671 |
Every picture now arrives as a basename, and a basename is a stable
|
|
@@ -1676,7 +1772,9 @@ class HandTieClips:
|
|
| 1676 |
would force a full re-render of an expensive node on every queue.
|
| 1677 |
"""
|
| 1678 |
names = [start_image_file, reference_video_file, voice_file,
|
| 1679 |
-
soundtrack_file
|
|
|
|
|
|
|
| 1680 |
try:
|
| 1681 |
for r in (_refs.parse_ref_plan(ref_plan).get("refs") or []):
|
| 1682 |
if r.get("file"):
|
|
@@ -1705,6 +1803,12 @@ class HandTieClips:
|
|
| 1705 |
music_start_s=0.0, music_end_s=0.0, render_from=0,
|
| 1706 |
reference_video_desc="",
|
| 1707 |
reference_video_size=None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1708 |
unique_id=None):
|
| 1709 |
# First thing, before a single model is touched: hand the writer's VRAM
|
| 1710 |
# back. The plan writer stays resident between plans now, which is the
|
|
@@ -1893,7 +1997,56 @@ class HandTieClips:
|
|
| 1893 |
voice = (_media.load_audio(voice_file,
|
| 1894 |
start=float(voice_start_s), end=float(voice_end_s))
|
| 1895 |
if voice_file else None)
|
| 1896 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1897 |
("reference_video", reference_video_file and reference_video is None),
|
| 1898 |
("voice", voice_file and voice is None),
|
| 1899 |
("soundtrack", soundtrack_file and soundtrack is None
|
|
@@ -1949,7 +2102,20 @@ class HandTieClips:
|
|
| 1949 |
f"character(s) {_chars}. Both inject identity text -- drop "
|
| 1950 |
f"the characters block and keep setting only.", flush=True)
|
| 1951 |
ref_images = _collect_ref_images(slot_images)
|
| 1952 |
-
base_videos =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1953 |
# The author's clip is always <Video 1>: _attach_pin_to_qwen APPENDS the
|
| 1954 |
# pinned tail (`live_v = len(videos) + 1`), so unlike the stills nothing
|
| 1955 |
# shifts it. One line, built once, and empty unless the field is filled.
|
|
@@ -1962,7 +2128,9 @@ class HandTieClips:
|
|
| 1962 |
"description, so it goes to the encoder as <Video 1> with "
|
| 1963 |
"nothing saying why. Fill reference_video_desc if the render "
|
| 1964 |
"keeps drifting toward the clip.", flush=True)
|
| 1965 |
-
ref_audios =
|
|
|
|
|
|
|
| 1966 |
|
| 1967 |
# Fingerprint the model as it arrives -- after whatever LoRA and
|
| 1968 |
# attention nodes are drawn upstream, before this node touches it.
|
|
@@ -2063,8 +2231,14 @@ class HandTieClips:
|
|
| 2063 |
# chain. Changing one late reference cost a full run. A reference
|
| 2064 |
# can only change the pixels of a hop it is actually handed to, so
|
| 2065 |
# that is where it belongs.
|
| 2066 |
-
|
| 2067 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2068 |
# tensor_digest already covers the pixels, and the pixels change
|
| 2069 |
# with the size -- but only once the clip is decoded. Naming the
|
| 2070 |
# setting keeps the key readable when a cache miss has to be
|
|
@@ -2419,6 +2593,11 @@ class HandTieClips:
|
|
| 2419 |
ref_image_size=ref_image_size,
|
| 2420 |
ref_images=hop_images,
|
| 2421 |
ref_videos=hop_videos,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2422 |
ref_audios=(ref_audios if hop_voice else None),
|
| 2423 |
)
|
| 2424 |
cond, latent = _result(packed)[0], _result(packed)[1]
|
|
|
|
| 902 |
return new, anchor
|
| 903 |
|
| 904 |
|
| 905 |
+
def _dense_media(prefix, items):
|
| 906 |
+
"""Number the filled slots 1..N with no gaps. -> dict or None.
|
| 907 |
+
|
| 908 |
+
`<Video N>` and `<Audio N>` are POSITIONAL: core numbers reference blocks
|
| 909 |
+
by the order it iterates them, and the prompt cites those ordinals. A gap
|
| 910 |
+
would hand core `ref_video_1` and `ref_video_3`, and a beat written about
|
| 911 |
+
"the second clip" would then name something else. So slot 3 becomes
|
| 912 |
+
<Video 2> when slot 2 is empty, and the tooltips say so.
|
| 913 |
+
"""
|
| 914 |
+
out = {}
|
| 915 |
+
for x in items:
|
| 916 |
+
if x is not None:
|
| 917 |
+
out[f"{prefix}{len(out) + 1}"] = x
|
| 918 |
+
return out or None
|
| 919 |
+
|
| 920 |
+
|
| 921 |
def _pin_mech_for(hop_index, overlap_n, prev_sampled):
|
| 922 |
"""Which mechanism `_pin_continue` will pick, without doing the work.
|
| 923 |
|
|
|
|
| 1655 |
"rather than by edge."
|
| 1656 |
),
|
| 1657 |
}),
|
| 1658 |
+
# APPENDED, never inserted -- see the note at the top of this
|
| 1659 |
+
# block. H3 natively takes 9 reference images, 3 reference
|
| 1660 |
+
# videos and 3 standalone reference audios; the pack matched
|
| 1661 |
+
# the 9 and passed exactly one of each of the others.
|
| 1662 |
+
"reference_video_2_file": ("STRING", {
|
| 1663 |
+
"default": "",
|
| 1664 |
+
"tooltip": (
|
| 1665 |
+
"Reference clip 2 of 3. H3 takes three; this pack "
|
| 1666 |
+
"passed one until now. Cited as <Video 2> when every "
|
| 1667 |
+
"earlier slot is filled -- the numbering is dense, so "
|
| 1668 |
+
"clearing slot 2 renumbers slot 3. Decoded at the same "
|
| 1669 |
+
"reference video size as slot 1. Set in the panel."
|
| 1670 |
+
),
|
| 1671 |
+
}),
|
| 1672 |
+
"reference_video_2_start_s": ("FLOAT", {
|
| 1673 |
+
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
|
| 1674 |
+
"tooltip": "Trim in, seconds, for reference clip 2.",
|
| 1675 |
+
}),
|
| 1676 |
+
"reference_video_2_end_s": ("FLOAT", {
|
| 1677 |
+
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
|
| 1678 |
+
"tooltip": "Trim out, seconds, for reference clip 2. 0 = to the end.",
|
| 1679 |
+
}),
|
| 1680 |
+
"reference_video_3_file": ("STRING", {
|
| 1681 |
+
"default": "",
|
| 1682 |
+
"tooltip": (
|
| 1683 |
+
"Reference clip 3 of 3. H3 takes three; this pack "
|
| 1684 |
+
"passed one until now. Cited as <Video 3> when every "
|
| 1685 |
+
"earlier slot is filled -- the numbering is dense, so "
|
| 1686 |
+
"clearing slot 2 renumbers slot 3. Decoded at the same "
|
| 1687 |
+
"reference video size as slot 1. Set in the panel."
|
| 1688 |
+
),
|
| 1689 |
+
}),
|
| 1690 |
+
"reference_video_3_start_s": ("FLOAT", {
|
| 1691 |
+
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
|
| 1692 |
+
"tooltip": "Trim in, seconds, for reference clip 3.",
|
| 1693 |
+
}),
|
| 1694 |
+
"reference_video_3_end_s": ("FLOAT", {
|
| 1695 |
+
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
|
| 1696 |
+
"tooltip": "Trim out, seconds, for reference clip 3. 0 = to the end.",
|
| 1697 |
+
}),
|
| 1698 |
+
"voice_2_file": ("STRING", {
|
| 1699 |
+
"default": "",
|
| 1700 |
+
"tooltip": (
|
| 1701 |
+
"Voice reference 2 of 3, cited as <Audio 2>. H3 takes "
|
| 1702 |
+
"three standalone reference audios; this pack passed one "
|
| 1703 |
+
"until now. Dense numbering, so clearing slot 2 renumbers "
|
| 1704 |
+
"slot 3 -- and a beat that names an ordinal would then "
|
| 1705 |
+
"cite the wrong voice. Every reference audio is attended "
|
| 1706 |
+
"on every step of every hop, so trim them. Set in the panel."
|
| 1707 |
+
),
|
| 1708 |
+
}),
|
| 1709 |
+
"voice_2_start_s": ("FLOAT", {
|
| 1710 |
+
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
|
| 1711 |
+
"tooltip": "Trim in, seconds, for voice 2.",
|
| 1712 |
+
}),
|
| 1713 |
+
"voice_2_end_s": ("FLOAT", {
|
| 1714 |
+
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
|
| 1715 |
+
"tooltip": "Trim out, seconds, for voice 2. 0 = to the end.",
|
| 1716 |
+
}),
|
| 1717 |
+
"voice_3_file": ("STRING", {
|
| 1718 |
+
"default": "",
|
| 1719 |
+
"tooltip": (
|
| 1720 |
+
"Voice reference 3 of 3, cited as <Audio 3>. H3 takes "
|
| 1721 |
+
"three standalone reference audios; this pack passed one "
|
| 1722 |
+
"until now. Dense numbering, so clearing slot 2 renumbers "
|
| 1723 |
+
"slot 3 -- and a beat that names an ordinal would then "
|
| 1724 |
+
"cite the wrong voice. Every reference audio is attended "
|
| 1725 |
+
"on every step of every hop, so trim them. Set in the panel."
|
| 1726 |
+
),
|
| 1727 |
+
}),
|
| 1728 |
+
"voice_3_start_s": ("FLOAT", {
|
| 1729 |
+
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
|
| 1730 |
+
"tooltip": "Trim in, seconds, for voice 3.",
|
| 1731 |
+
}),
|
| 1732 |
+
"voice_3_end_s": ("FLOAT", {
|
| 1733 |
+
"default": 0.0, "min": 0.0, "max": 3600.0, "step": 0.1,
|
| 1734 |
+
"tooltip": "Trim out, seconds, for voice 3. 0 = to the end.",
|
| 1735 |
+
}),
|
| 1736 |
},
|
| 1737 |
"hidden": {
|
| 1738 |
"unique_id": "UNIQUE_ID",
|
|
|
|
| 1759 |
@classmethod
|
| 1760 |
def IS_CHANGED(cls, ref_plan="", start_image_file="",
|
| 1761 |
reference_video_file="", voice_file="",
|
| 1762 |
+
soundtrack_file="",
|
| 1763 |
+
reference_video_2_file="", reference_video_3_file="",
|
| 1764 |
+
voice_2_file="", voice_3_file="", **_):
|
| 1765 |
"""Re-run when a reference file changes underneath its name.
|
| 1766 |
|
| 1767 |
Every picture now arrives as a basename, and a basename is a stable
|
|
|
|
| 1772 |
would force a full re-render of an expensive node on every queue.
|
| 1773 |
"""
|
| 1774 |
names = [start_image_file, reference_video_file, voice_file,
|
| 1775 |
+
soundtrack_file,
|
| 1776 |
+
reference_video_2_file, reference_video_3_file,
|
| 1777 |
+
voice_2_file, voice_3_file]
|
| 1778 |
try:
|
| 1779 |
for r in (_refs.parse_ref_plan(ref_plan).get("refs") or []):
|
| 1780 |
if r.get("file"):
|
|
|
|
| 1803 |
music_start_s=0.0, music_end_s=0.0, render_from=0,
|
| 1804 |
reference_video_desc="",
|
| 1805 |
reference_video_size=None,
|
| 1806 |
+
reference_video_2_file="", reference_video_2_start_s=0.0,
|
| 1807 |
+
reference_video_2_end_s=0.0,
|
| 1808 |
+
reference_video_3_file="", reference_video_3_start_s=0.0,
|
| 1809 |
+
reference_video_3_end_s=0.0,
|
| 1810 |
+
voice_2_file="", voice_2_start_s=0.0, voice_2_end_s=0.0,
|
| 1811 |
+
voice_3_file="", voice_3_start_s=0.0, voice_3_end_s=0.0,
|
| 1812 |
unique_id=None):
|
| 1813 |
# First thing, before a single model is touched: hand the writer's VRAM
|
| 1814 |
# back. The plan writer stays resident between plans now, which is the
|
|
|
|
| 1997 |
voice = (_media.load_audio(voice_file,
|
| 1998 |
start=float(voice_start_s), end=float(voice_end_s))
|
| 1999 |
if voice_file else None)
|
| 2000 |
+
|
| 2001 |
+
# Slots 2 and 3. All three decode at the same reference_video_size --
|
| 2002 |
+
# it is an area budget for the decode, not a per-clip creative choice,
|
| 2003 |
+
# and three of them is already three times the RAM.
|
| 2004 |
+
def _more_video(fname, t0, t1):
|
| 2005 |
+
return (_media.load_video(
|
| 2006 |
+
fname, max_frames=max(lengths) if lengths else length,
|
| 2007 |
+
start=float(t0), end=float(t1),
|
| 2008 |
+
size=reference_video_size or _media.DEFAULT_VIDEO_SIZE)
|
| 2009 |
+
if fname else None)
|
| 2010 |
+
|
| 2011 |
+
def _more_voice(fname, t0, t1):
|
| 2012 |
+
return (_media.load_audio(fname, start=float(t0), end=float(t1))
|
| 2013 |
+
if fname else None)
|
| 2014 |
+
|
| 2015 |
+
reference_video_2 = _more_video(reference_video_2_file,
|
| 2016 |
+
reference_video_2_start_s, reference_video_2_end_s)
|
| 2017 |
+
reference_video_3 = _more_video(reference_video_3_file,
|
| 2018 |
+
reference_video_3_start_s, reference_video_3_end_s)
|
| 2019 |
+
voice_2 = _more_voice(voice_2_file, voice_2_start_s, voice_2_end_s)
|
| 2020 |
+
voice_3 = _more_voice(voice_3_file, voice_3_start_s, voice_3_end_s)
|
| 2021 |
+
|
| 2022 |
+
# Each clip's own soundtrack, paired to the same ordinal. Core takes
|
| 2023 |
+
# ref_video_audios beside ref_videos and this pack never passed them,
|
| 2024 |
+
# so a reference clip reached the model silent even when the file had
|
| 2025 |
+
# sound. Trimmed to the same window as its picture.
|
| 2026 |
+
def _clip_audio(fname, t0, t1):
|
| 2027 |
+
if not fname:
|
| 2028 |
+
return None
|
| 2029 |
+
try:
|
| 2030 |
+
return _media.load_audio(fname, start=float(t0), end=float(t1),
|
| 2031 |
+
kinds=("audio", "video"))
|
| 2032 |
+
except Exception as e: # noqa: BLE001 -- a silent clip is not a failure
|
| 2033 |
+
print(f"[{TAG}] reference clip {fname}: no usable audio track "
|
| 2034 |
+
f"({e!r}); passing it silent", flush=True)
|
| 2035 |
+
return None
|
| 2036 |
+
|
| 2037 |
+
_vid_slots = [
|
| 2038 |
+
(reference_video, _clip_audio(reference_video_file,
|
| 2039 |
+
reference_video_start_s, reference_video_end_s)),
|
| 2040 |
+
(reference_video_2, _clip_audio(reference_video_2_file,
|
| 2041 |
+
reference_video_2_start_s, reference_video_2_end_s)),
|
| 2042 |
+
(reference_video_3, _clip_audio(reference_video_3_file,
|
| 2043 |
+
reference_video_3_start_s, reference_video_3_end_s)),
|
| 2044 |
+
]
|
| 2045 |
+
for _name, _got in (("reference_video_2", reference_video_2_file and reference_video_2 is None),
|
| 2046 |
+
("reference_video_3", reference_video_3_file and reference_video_3 is None),
|
| 2047 |
+
("voice_2", voice_2_file and voice_2 is None),
|
| 2048 |
+
("voice_3", voice_3_file and voice_3 is None),
|
| 2049 |
+
("start_image", start_image_file and start_image is None),
|
| 2050 |
("reference_video", reference_video_file and reference_video is None),
|
| 2051 |
("voice", voice_file and voice is None),
|
| 2052 |
("soundtrack", soundtrack_file and soundtrack is None
|
|
|
|
| 2102 |
f"character(s) {_chars}. Both inject identity text -- drop "
|
| 2103 |
f"the characters block and keep setting only.", flush=True)
|
| 2104 |
ref_images = _collect_ref_images(slot_images)
|
| 2105 |
+
base_videos, base_video_audios = {}, {}
|
| 2106 |
+
for _v, _a in _vid_slots:
|
| 2107 |
+
if _v is None:
|
| 2108 |
+
continue
|
| 2109 |
+
_n = len(base_videos) + 1
|
| 2110 |
+
base_videos[f"ref_video_{_n}"] = _v
|
| 2111 |
+
if _a is not None:
|
| 2112 |
+
base_video_audios[f"ref_video_audio_{_n}"] = _a
|
| 2113 |
+
base_videos = base_videos or None
|
| 2114 |
+
base_video_audios = base_video_audios or None
|
| 2115 |
+
if base_videos and len(base_videos) > 1:
|
| 2116 |
+
print(f"[{TAG}] {len(base_videos)} reference clips"
|
| 2117 |
+
+ (f", {len(base_video_audios)} with sound" if base_video_audios else "")
|
| 2118 |
+
, flush=True)
|
| 2119 |
# The author's clip is always <Video 1>: _attach_pin_to_qwen APPENDS the
|
| 2120 |
# pinned tail (`live_v = len(videos) + 1`), so unlike the stills nothing
|
| 2121 |
# shifts it. One line, built once, and empty unless the field is filled.
|
|
|
|
| 2128 |
"description, so it goes to the encoder as <Video 1> with "
|
| 2129 |
"nothing saying why. Fill reference_video_desc if the render "
|
| 2130 |
"keeps drifting toward the clip.", flush=True)
|
| 2131 |
+
ref_audios = _dense_media("ref_audio_", [voice, voice_2, voice_3])
|
| 2132 |
+
if ref_audios and len(ref_audios) > 1:
|
| 2133 |
+
print(f"[{TAG}] {len(ref_audios)} voice references", flush=True)
|
| 2134 |
|
| 2135 |
# Fingerprint the model as it arrives -- after whatever LoRA and
|
| 2136 |
# attention nodes are drawn upstream, before this node touches it.
|
|
|
|
| 2231 |
# chain. Changing one late reference cost a full run. A reference
|
| 2232 |
# can only change the pixels of a hop it is actually handed to, so
|
| 2233 |
# that is where it belongs.
|
| 2234 |
+
# All three slots, not just the first. Digesting only slot 1 would
|
| 2235 |
+
# let a chain rendered with a second voice be served to a run that
|
| 2236 |
+
# dropped it -- the silently-wrong-frames class this key exists to
|
| 2237 |
+
# prevent.
|
| 2238 |
+
"voice": [_store.audio_digest(v) for v in (voice, voice_2, voice_3)],
|
| 2239 |
+
"refvid": [_store.tensor_digest(v) for v in
|
| 2240 |
+
(reference_video, reference_video_2, reference_video_3)],
|
| 2241 |
+
"refvid_audio": sorted(base_video_audios or {}),
|
| 2242 |
# tensor_digest already covers the pixels, and the pixels change
|
| 2243 |
# with the size -- but only once the clip is decoded. Naming the
|
| 2244 |
# setting keeps the key readable when a cache miss has to be
|
|
|
|
| 2593 |
ref_image_size=ref_image_size,
|
| 2594 |
ref_images=hop_images,
|
| 2595 |
ref_videos=hop_videos,
|
| 2596 |
+
# Paired by ordinal with ref_videos. The pin clip, when
|
| 2597 |
+
# pin_to_qwen appends one, takes the next number and simply
|
| 2598 |
+
# has no entry here -- it is the previous hop's picture and
|
| 2599 |
+
# has no soundtrack of its own.
|
| 2600 |
+
ref_video_audios=base_video_audios,
|
| 2601 |
ref_audios=(ref_audios if hop_voice else None),
|
| 2602 |
)
|
| 2603 |
cond, latent = _result(packed)[0], _result(packed)[1]
|
|
@@ -35,6 +35,24 @@ const SLOTS = [
|
|
| 35 |
["voice_file", "audio", "voice",
|
| 36 |
"Voice or timbre reference for hop 1 as <Audio 1>. Later hops use the pin.",
|
| 37 |
"voice", null, null],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
// Not a reference at all: this one is never shown to the model. It is mixed
|
| 39 |
// under the finished chain after the last hop is joined, so it sits here
|
| 40 |
// because this is where you look for audio -- not because it behaves like
|
|
|
|
| 35 |
["voice_file", "audio", "voice",
|
| 36 |
"Voice or timbre reference for hop 1 as <Audio 1>. Later hops use the pin.",
|
| 37 |
"voice", null, null],
|
| 38 |
+
// Slots 2 and 3. H3 takes three reference videos and three standalone
|
| 39 |
+
// reference audios; the pack passed one of each until now. Numbering is
|
| 40 |
+
// DENSE -- clearing slot 2 renumbers slot 3, so a beat that cites an
|
| 41 |
+
// ordinal would then name a different clip. Slots 2 and 3 share slot 1's
|
| 42 |
+
// reference video size, which is a decode budget rather than a creative
|
| 43 |
+
// choice.
|
| 44 |
+
["reference_video_2_file", "video", "reference clip 2",
|
| 45 |
+
"Second motion/look plate, cited as <Video 2>. Shares slot 1's decode size.",
|
| 46 |
+
"reference_video_2", null, null],
|
| 47 |
+
["reference_video_3_file", "video", "reference clip 3",
|
| 48 |
+
"Third motion/look plate, cited as <Video 3>. Shares slot 1's decode size.",
|
| 49 |
+
"reference_video_3", null, null],
|
| 50 |
+
["voice_2_file", "audio", "voice 2",
|
| 51 |
+
"Second voice reference, cited as <Audio 2>. Every reference audio is attended on every step of every hop -- trim it.",
|
| 52 |
+
"voice_2", null, null],
|
| 53 |
+
["voice_3_file", "audio", "voice 3",
|
| 54 |
+
"Third voice reference, cited as <Audio 3>. Trim it.",
|
| 55 |
+
"voice_3", null, null],
|
| 56 |
// Not a reference at all: this one is never shown to the model. It is mixed
|
| 57 |
// under the finished chain after the last hop is joined, so it sits here
|
| 58 |
// because this is where you look for audio -- not because it behaves like
|
|
@@ -446,7 +446,7 @@ def load_video(name, max_frames=None, start=0.0, end=0.0,
|
|
| 446 |
return None
|
| 447 |
|
| 448 |
|
| 449 |
-
def load_audio(name, start=0.0, end=0.0):
|
| 450 |
"""A take as ComfyUI's AUDIO dict, or None.
|
| 451 |
|
| 452 |
Shape is `[batch, channels, samples]`, which is what every AUDIO consumer
|
|
@@ -469,7 +469,10 @@ def load_audio(name, start=0.0, end=0.0):
|
|
| 469 |
token the DiT attends over on every step of every hop. An untrimmed
|
| 470 |
three-minute take is a large, silent, permanent tax on the render.
|
| 471 |
"""
|
| 472 |
-
|
|
|
|
|
|
|
|
|
|
| 473 |
if path is None:
|
| 474 |
return None
|
| 475 |
try:
|
|
|
|
| 446 |
return None
|
| 447 |
|
| 448 |
|
| 449 |
+
def load_audio(name, start=0.0, end=0.0, kinds=("audio",)):
|
| 450 |
"""A take as ComfyUI's AUDIO dict, or None.
|
| 451 |
|
| 452 |
Shape is `[batch, channels, samples]`, which is what every AUDIO consumer
|
|
|
|
| 469 |
token the DiT attends over on every step of every hop. An untrimmed
|
| 470 |
three-minute take is a large, silent, permanent tax on the render.
|
| 471 |
"""
|
| 472 |
+
# `kinds` is a parameter so a VIDEO can be asked for its own soundtrack:
|
| 473 |
+
# core takes ref_video_audios paired with each reference clip, and a clip
|
| 474 |
+
# handed over silent is a channel left at zero. Default is unchanged.
|
| 475 |
+
path = resolve(name, kinds=set(kinds))
|
| 476 |
if path is None:
|
| 477 |
return None
|
| 478 |
try:
|
|
@@ -39,6 +39,7 @@ CHECKS = [
|
|
| 39 |
("check_texture.py", []),
|
| 40 |
("check_workflows.py", []),
|
| 41 |
("check_features.py", []),
|
|
|
|
| 42 |
("check_planner.py", []),
|
| 43 |
("gen_schema.py", ["--check"]),
|
| 44 |
]
|
|
|
|
| 39 |
("check_texture.py", []),
|
| 40 |
("check_workflows.py", []),
|
| 41 |
("check_features.py", []),
|
| 42 |
+
("check_media_slots.py", []),
|
| 43 |
("check_planner.py", []),
|
| 44 |
("gen_schema.py", ["--check"]),
|
| 45 |
]
|
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
r"""The media slots: dense numbering, and the JS half exists.
|
| 2 |
+
|
| 3 |
+
D:\ComfyUI\venv\Scripts\python.exe tools\check_media_slots.py
|
| 4 |
+
|
| 5 |
+
H3 takes 9 reference images, 3 reference videos and 3 standalone reference
|
| 6 |
+
audios. The pack matched the 9 and passed exactly one of the others until the
|
| 7 |
+
slots were added, so two whole channels sat at a third of capacity.
|
| 8 |
+
|
| 9 |
+
Two things about that are easy to get wrong and impossible to see afterwards.
|
| 10 |
+
|
| 11 |
+
**Numbering is dense.** Core numbers reference blocks by the order it iterates
|
| 12 |
+
them and the prompt cites those ordinals, so a gap must not survive: filling
|
| 13 |
+
slots 1 and 3 has to produce <Video 1> and <Video 2>, not 1 and 3. If it ever
|
| 14 |
+
produced a gap, a beat naming "the second clip" would cite something else and
|
| 15 |
+
nothing would report it.
|
| 16 |
+
|
| 17 |
+
**A file widget with no slot in the strip falls through to a native dial.**
|
| 18 |
+
That is the 0.4.0 failure recorded at run_panel.js:53 and it has recurred since.
|
| 19 |
+
Nothing checked it, so this does: every `*_file` widget the node declares must
|
| 20 |
+
be claimed by `media_strip.js`, and every widget the strip expects must exist.
|
| 21 |
+
"""
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import importlib.util
|
| 25 |
+
import io
|
| 26 |
+
import os
|
| 27 |
+
import re
|
| 28 |
+
import sys
|
| 29 |
+
|
| 30 |
+
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 31 |
+
COMFY = os.path.dirname(os.path.dirname(HERE))
|
| 32 |
+
sys.path.insert(0, COMFY)
|
| 33 |
+
|
| 34 |
+
FAIL = []
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def ck(name, cond, detail=""):
|
| 38 |
+
print(" %-4s %-52s %s" % ("ok" if cond else "FAIL", name, detail))
|
| 39 |
+
if not cond:
|
| 40 |
+
FAIL.append(name)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def load_pack():
|
| 44 |
+
spec = importlib.util.spec_from_file_location(
|
| 45 |
+
"htcpack", os.path.join(HERE, "__init__.py"),
|
| 46 |
+
submodule_search_locations=[HERE])
|
| 47 |
+
m = importlib.util.module_from_spec(spec)
|
| 48 |
+
sys.modules["htcpack"] = m
|
| 49 |
+
spec.loader.exec_module(m)
|
| 50 |
+
return m
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def main():
|
| 54 |
+
load_pack()
|
| 55 |
+
H3 = sys.modules["htcpack.h3_ref_chain"]
|
| 56 |
+
dense = H3._dense_media
|
| 57 |
+
|
| 58 |
+
print("dense numbering")
|
| 59 |
+
ck("all three present number 1..3",
|
| 60 |
+
list(dense("ref_audio_", ["a", "b", "c"])) == ["ref_audio_1", "ref_audio_2", "ref_audio_3"])
|
| 61 |
+
ck("a gap in the middle does NOT survive",
|
| 62 |
+
list(dense("ref_video_", ["a", None, "c"])) == ["ref_video_1", "ref_video_2"],
|
| 63 |
+
"slot 3 becomes <Video 2>")
|
| 64 |
+
ck("only the last slot filled still starts at 1",
|
| 65 |
+
list(dense("ref_audio_", [None, None, "c"])) == ["ref_audio_1"])
|
| 66 |
+
ck("order is preserved, not sorted",
|
| 67 |
+
list(dense("x", ["z", "a"]).values()) == ["z", "a"])
|
| 68 |
+
ck("nothing filled is None, not an empty dict",
|
| 69 |
+
dense("ref_audio_", [None, None, None]) is None,
|
| 70 |
+
"core takes None to mean the channel is unused")
|
| 71 |
+
|
| 72 |
+
print("the node declares what core accepts")
|
| 73 |
+
it = H3.HandTieClips.INPUT_TYPES()
|
| 74 |
+
allw = dict(it.get("required", {})); allw.update(it.get("optional", {}))
|
| 75 |
+
vids = [w for w in allw if re.fullmatch(r"reference_video(_[23])?_file", w)]
|
| 76 |
+
auds = [w for w in allw if re.fullmatch(r"voice(_[23])?_file", w)]
|
| 77 |
+
ck("three reference video slots", len(vids) == 3, str(sorted(vids)))
|
| 78 |
+
ck("three voice slots", len(auds) == 3, str(sorted(auds)))
|
| 79 |
+
for w in vids + auds:
|
| 80 |
+
trim = w[:-len("_file")]
|
| 81 |
+
ck(f"{w} has both trim widgets",
|
| 82 |
+
f"{trim}_start_s" in allw and f"{trim}_end_s" in allw)
|
| 83 |
+
|
| 84 |
+
print("the JS half exists for every file widget")
|
| 85 |
+
js = io.open(os.path.join(HERE, "js/editor/media_strip.js"), encoding="utf-8").read()
|
| 86 |
+
block = js.split("const SLOTS = [", 1)[1].split("\n];", 1)[0]
|
| 87 |
+
slot_files = re.findall(r'\["([a-z0-9_]+_file)"', block)
|
| 88 |
+
trims = re.findall(r'"([a-z0-9_]+)",\s*null,\s*null\]', block)
|
| 89 |
+
|
| 90 |
+
unclaimed = [w for w in allw
|
| 91 |
+
if w.endswith("_file") and w not in slot_files]
|
| 92 |
+
ck("no file widget falls through to a native dial",
|
| 93 |
+
not unclaimed, f"unclaimed: {unclaimed}" if unclaimed else f"{len(slot_files)} slots")
|
| 94 |
+
|
| 95 |
+
expected = set(slot_files)
|
| 96 |
+
for t in trims:
|
| 97 |
+
expected |= {f"{t}_start_s", f"{t}_end_s"}
|
| 98 |
+
missing = sorted(w for w in expected if w not in allw)
|
| 99 |
+
ck("every widget the strip expects exists in Python",
|
| 100 |
+
not missing, f"missing: {missing}" if missing else f"{len(expected)} checked")
|
| 101 |
+
|
| 102 |
+
print()
|
| 103 |
+
if FAIL:
|
| 104 |
+
print("%d FAILURE(S): %s" % (len(FAIL), ", ".join(FAIL)))
|
| 105 |
+
return 1
|
| 106 |
+
print("ALL PASS")
|
| 107 |
+
return 0
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
if __name__ == "__main__":
|
| 111 |
+
raise SystemExit(main())
|
|
@@ -611,7 +611,19 @@
|
|
| 611 |
0,
|
| 612 |
0,
|
| 613 |
"",
|
| 614 |
-
"MAX"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 615 |
],
|
| 616 |
"title": "H3 Ref2VA Chain - showcase 6x7s"
|
| 617 |
},
|
|
|
|
| 611 |
0,
|
| 612 |
0,
|
| 613 |
"",
|
| 614 |
+
"MAX",
|
| 615 |
+
"",
|
| 616 |
+
0,
|
| 617 |
+
0,
|
| 618 |
+
"",
|
| 619 |
+
0,
|
| 620 |
+
0,
|
| 621 |
+
"",
|
| 622 |
+
0,
|
| 623 |
+
0,
|
| 624 |
+
"",
|
| 625 |
+
0,
|
| 626 |
+
0
|
| 627 |
],
|
| 628 |
"title": "H3 Ref2VA Chain - showcase 6x7s"
|
| 629 |
},
|
|
@@ -614,7 +614,19 @@
|
|
| 614 |
0,
|
| 615 |
0,
|
| 616 |
"",
|
| 617 |
-
"MAX"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 618 |
],
|
| 619 |
"title": "H3 Ref2VA Chain - starter"
|
| 620 |
},
|
|
|
|
| 614 |
0,
|
| 615 |
0,
|
| 616 |
"",
|
| 617 |
+
"MAX",
|
| 618 |
+
"",
|
| 619 |
+
0,
|
| 620 |
+
0,
|
| 621 |
+
"",
|
| 622 |
+
0,
|
| 623 |
+
0,
|
| 624 |
+
"",
|
| 625 |
+
0,
|
| 626 |
+
0,
|
| 627 |
+
"",
|
| 628 |
+
0,
|
| 629 |
+
0
|
| 630 |
],
|
| 631 |
"title": "H3 Ref2VA Chain - starter"
|
| 632 |
},
|