Sandpies Claude Opus 5 commited on
Commit
44f76f4
·
1 Parent(s): a21095e

Three reference videos and three voices, and the clips keep their sound

Browse files

H3 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 CHANGED
@@ -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
- for _name, _got in (("start_image", start_image_file and start_image is None),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = None if reference_video is None else {"ref_video_1": reference_video}
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = None if voice is None else {"ref_audio_1": voice}
 
 
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
- "voice": _store.audio_digest(voice),
2067
- "refvid": _store.tensor_digest(reference_video),
 
 
 
 
 
 
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]
js/editor/media_strip.js CHANGED
@@ -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
media.py CHANGED
@@ -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
- path = resolve(name, kinds={"audio"})
 
 
 
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:
tools/check_all.py CHANGED
@@ -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
  ]
tools/check_media_slots.py ADDED
@@ -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())
workflows/HandTieClips_Showcase.json CHANGED
@@ -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
  },
workflows/HandTieClips_Starter.json CHANGED
@@ -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
  },