Sandpies Claude Opus 5 commited on
Commit
d07b19b
·
1 Parent(s): ddcc824

0.4.5: the hop cache stops shelling out to ffmpeg

Browse files

store.py ran an ffmpeg binary through subprocess.Popen to write and read
its lossless FFV1 cache. That cost two things, and the registry flag was
the smaller one.

_ffmpeg() raised if no ffmpeg was on PATH, and ComfyUI never requires
one -- so the feature that makes a tone A/B cost 14 s instead of 164 s
hard-failed for a large share of users, on the pack's fastest path.

The Comfy registry's YARA scan also flagged all three published versions
for it: rule python_command_injection_risk, "detects ALL os.system and
subprocess usage", severity info, recommendation null. It does no taint
analysis, so a static argument list built from shutil.which and run with
shell=False matches as hard as a shell injection. The reason is not in
the web UI -- it is behind
api.comfy.org/versions?nodeId=<id>&include_status_reason=true.

Now encoded in process with PyAV, a hard dependency of ComfyUI itself
(SaveVideo and CreateVideo are built on it). The format is deliberately
unchanged: ffv1 / rgb48le / level 3 / coder 1 / context 1, in matroska.

Verified before writing any of it, and again through the real HopStore:

- PyAV encode -> PyAV decode is bit exact, including 0 and 65535
- ffmpeg encode -> PyAV decode is bit exact, so caches already on disk
still read and a resumed chain still matches an uninterrupted one
- output sizes within 44 bytes (145,780 vs 145,824)
- a frame-count mismatch still raises instead of returning a short clip

The decode path also stopped building a list of frames to stack, and
decodes into one preallocated array instead -- the geometry is already
in the meta file, so there is no second full-size copy.

Five documents claimed ffmpeg was required (CLAUDE.md, PROMPTING.md,
README twice, and the Starter's on-canvas card via tools/notes.py). All
corrected; README's existing "No dependencies to install" line, which
already listed av, is now true rather than nearly true.

Devlog section 23.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015MGjcAV8bDy93qJfmLi9kw

CLAUDE.md CHANGED
@@ -93,7 +93,7 @@ Fixes two real bugs:
93
 
94
  ### 4. Hop store (`store.py`)
95
 
96
- Lossless FFV1 (`rgb48le`) video + a float32 `.npy` waveform + a `.latent.pt` sidecar per hop under ComfyUI's temp dir, enabled by `cache_hops`, LRU-evicted above `cache_budget_gb`. **Needs `ffmpeg` on PATH** or it raises.
97
 
98
  **The latent sidecar is what makes the cache useful past hop 1** (added 2026-08-27). It stores this hop's sampler output so a hit can seed the *next* hop's Motion-Context pin. Without it, a hit left `prev_sampled` empty, the next hop predicted the AddGuide fallback, and because the mechanism is in the per-hop key that key no longer matched what was on disk -- so **nothing past hop 1 could ever hit, and the hop after a hit was joined by the weaker mechanism.** `cache_hops=on` was measurably worse than off. Verified in-browser: hop 1 hit, hop 2 logged `previous hop has no sampler latent (cache hit); AddGuide pixel pin`, hops 2-3 re-rendered.
99
 
 
93
 
94
  ### 4. Hop store (`store.py`)
95
 
96
+ Lossless FFV1 (`rgb48le`) video + a float32 `.npy` waveform + a `.latent.pt` sidecar per hop under ComfyUI's temp dir, enabled by `cache_hops`, LRU-evicted above `cache_budget_gb`. Encoded in process with PyAV, which ComfyUI already depends on -- no `ffmpeg` binary on PATH is needed, and nothing here shells out.
97
 
98
  **The latent sidecar is what makes the cache useful past hop 1** (added 2026-08-27). It stores this hop's sampler output so a hit can seed the *next* hop's Motion-Context pin. Without it, a hit left `prev_sampled` empty, the next hop predicted the AddGuide fallback, and because the mechanism is in the per-hop key that key no longer matched what was on disk -- so **nothing past hop 1 could ever hit, and the hop after a hit was joined by the weaker mechanism.** `cache_hops=on` was measurably worse than off. Verified in-browser: hop 1 hit, hop 2 logged `previous hop has no sampler latent (cache hit); AddGuide pixel pin`, hops 2-3 re-rendered.
99
 
PROMPTING.md CHANGED
@@ -519,8 +519,9 @@ Eight hops in, hop 6 is wrong and the other seven are fine. `cache_hops` is how
519
  you fix 6 without re-rendering 1 through 5.
520
 
521
  Turn it **on before the first run of a plan** -- it is `off` by default, and a
522
- hop that was never cached cannot be reused. It needs `ffmpeg` on PATH; without
523
- it the node raises rather than quietly carrying on without a cache.
 
524
 
525
  ### Editing a hop invalidates that hop and everything after it
526
 
 
519
  you fix 6 without re-rendering 1 through 5.
520
 
521
  Turn it **on before the first run of a plan** -- it is `off` by default, and a
522
+ hop that was never cached cannot be reused. It needs nothing installed --
523
+ the lossless FFV1 encode runs in process through PyAV, which ComfyUI already
524
+ ships.
525
 
526
  ### Editing a hop invalidates that hop and everything after it
527
 
README.md CHANGED
@@ -50,6 +50,18 @@ of a second, separate generation. The pack exists so that you cannot tell which.
50
  > also has its tooltips back — it read them from `widget.options`, where this
51
  > frontend does not keep them. Section 22 of the devlog has the A/B.
52
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  **Writing for it:** [PROMPTING.md](PROMPTING.md) is the authoring guide — the rules that come from what this model actually does, not from taste. [prompt_pack/](prompt_pack/) has a copy-paste prompt that gets a language model to write plans for you.
54
 
55
  Each hop is native **MiniMax H3 Reference-to-Video**. Hops after the first are guided by the **previous hop's sampler AV latent** via `ComfyUI-H3-Motion-Context` when that pack is installed (22 picture frames + 24-frame end-aligned audio). Stock `MiniMaxH3AddGuide` is the fallback when Motion-Context is missing or the previous hop was a pixel cache hit. Voice stays as a reference every hop. Identity stills ride hop 1; later hops use the pin for wardrobe and room unless a ref lists those hops in `shots`. A 5 s hop drops the airlock on a continuous join — validate seams at 8 s or 15 s.
@@ -133,7 +145,6 @@ The two example graphs are in `workflows/` inside the pack folder -- open them w
133
  - ComfyUI new enough to include **Add Guide for MiniMax H3** (`MiniMaxH3AddGuide`)
134
  - A **ref2va** (or hybrid ref2va) checkpoint — fl2va has no reference rows
135
  - Video VAE + audio VAE + MiniMax text encoder
136
- - `ffmpeg` on PATH, if you turn `cache_hops` on
137
  - **Optional but recommended:** [ComfyUI-H3-Motion-Context](https://github.com/NikoDemon80/ComfyUI-H3-Motion-Context). Hops after the first are guided by the previous hop's sampler AV latent through it. Without it the node falls back to stock `MiniMaxH3AddGuide`, which still works and still chains -- it is a different join, so a seam you are comparing against someone else's render may not be the same code path. The node detects it at runtime and prints which one it took.
138
 
139
  The two shipped workflows wire the **turbo stack** this node is actually run with, because an example without it is not the graph anyone uses:
@@ -335,7 +346,7 @@ The key **chains** — each hop's key includes the previous hop's — because ho
335
 
336
  That last one is worth knowing about. The node cannot read the settings on your LoRA and attention nodes, so instead it fingerprints what they *did* to the model — which weight keys were patched, at what strengths, and the attention overrides. Change a LoRA strength and the cache correctly invalidates. Two different LoRAs touching exactly the same keys at exactly the same strengths would look identical to it; that is the one gap.
337
 
338
- Set `locked: true` on a shot to pin it to its last render regardless. Needs `ffmpeg` on PATH.
339
 
340
  ## Reading a plan before you render it
341
 
 
50
  > also has its tooltips back — it read them from `widget.options`, where this
51
  > frontend does not keep them. Section 22 of the devlog has the A/B.
52
 
53
+ > **0.4.5 — 2026-08-30.** The hop cache no longer needs `ffmpeg` on PATH. It
54
+ > used to shell out to an ffmpeg binary to write its lossless FFV1, so the
55
+ > feature that makes a tone A/B cost 14 seconds instead of 164 raised a
56
+ > `RuntimeError` for anyone who did not happen to have ffmpeg installed —
57
+ > which ComfyUI itself never requires. It now encodes in process through PyAV,
58
+ > which ComfyUI already depends on. **The format is unchanged** (ffv1 /
59
+ > `rgb48le` / level 3), verified bit-exact in both directions, so caches
60
+ > written by the old path still read and a resumed chain still matches an
61
+ > uninterrupted one. This also clears the Comfy registry's security scan,
62
+ > which flags every `subprocess` call in a custom node regardless of how it is
63
+ > invoked. Section 23 of the devlog.
64
+
65
  **Writing for it:** [PROMPTING.md](PROMPTING.md) is the authoring guide — the rules that come from what this model actually does, not from taste. [prompt_pack/](prompt_pack/) has a copy-paste prompt that gets a language model to write plans for you.
66
 
67
  Each hop is native **MiniMax H3 Reference-to-Video**. Hops after the first are guided by the **previous hop's sampler AV latent** via `ComfyUI-H3-Motion-Context` when that pack is installed (22 picture frames + 24-frame end-aligned audio). Stock `MiniMaxH3AddGuide` is the fallback when Motion-Context is missing or the previous hop was a pixel cache hit. Voice stays as a reference every hop. Identity stills ride hop 1; later hops use the pin for wardrobe and room unless a ref lists those hops in `shots`. A 5 s hop drops the airlock on a continuous join — validate seams at 8 s or 15 s.
 
145
  - ComfyUI new enough to include **Add Guide for MiniMax H3** (`MiniMaxH3AddGuide`)
146
  - A **ref2va** (or hybrid ref2va) checkpoint — fl2va has no reference rows
147
  - Video VAE + audio VAE + MiniMax text encoder
 
148
  - **Optional but recommended:** [ComfyUI-H3-Motion-Context](https://github.com/NikoDemon80/ComfyUI-H3-Motion-Context). Hops after the first are guided by the previous hop's sampler AV latent through it. Without it the node falls back to stock `MiniMaxH3AddGuide`, which still works and still chains -- it is a different join, so a seam you are comparing against someone else's render may not be the same code path. The node detects it at runtime and prints which one it took.
149
 
150
  The two shipped workflows wire the **turbo stack** this node is actually run with, because an example without it is not the graph anyone uses:
 
346
 
347
  That last one is worth knowing about. The node cannot read the settings on your LoRA and attention nodes, so instead it fingerprints what they *did* to the model — which weight keys were patched, at what strengths, and the attention overrides. Change a LoRA strength and the cache correctly invalidates. Two different LoRAs touching exactly the same keys at exactly the same strengths would look identical to it; that is the one gap.
348
 
349
+ Set `locked: true` on a shot to pin it to its last render regardless.
350
 
351
  ## Reading a plan before you render it
352
 
docs/DEVLOG.md CHANGED
@@ -969,3 +969,56 @@ trip and says the two spellings are one string; `refs.py` rejects an `@tag` in
969
  `name`, `locked` or `context`. Not fixed, because it is a brief-writing lesson
970
  rather than a bug: ending a chain in a location no plate describes earns the
971
  place-handoff warning, and that was the brief's fault, not either model's.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
969
  `name`, `locked` or `context`. Not fixed, because it is a brief-writing lesson
970
  rather than a bug: ending a chain in a location no plate describes earns the
971
  place-handoff warning, and that was the brief's fault, not either model's.
972
+
973
+ ## 23. The hop cache stops shelling out (2026-08-30)
974
+
975
+ The Comfy registry flagged all three published versions. The reason is not in
976
+ the web UI and `status_detail` on the node is empty; it is behind
977
+ `https://api.comfy.org/versions?nodeId=<id>&include_status_reason=true`, which
978
+ returns the actual findings:
979
+
980
+ scanner yara_scan
981
+ issue_type python_command_injection_risk
982
+ file_path store.py lines 131 and 215
983
+ description "Detects all os.system and subprocess usage"
984
+ severity info
985
+ recommendation null
986
+ admin_tags any-code-execute
987
+
988
+ Two findings, both the `subprocess.Popen` calls that ran `ffmpeg` for the FFV1
989
+ hop cache. The rule does no taint analysis, so a static argument list built from
990
+ `shutil.which` and run with `shell=False` matches exactly as hard as a shell
991
+ injection would. Its 95% confidence is confidence that the call *is* a
992
+ subprocess call, not that it is exploitable.
993
+
994
+ Appealing looked like the wrong move. `plaguekind-nodes` -- 22.5k downloads --
995
+ has 1.3.8 through 1.4.0 flagged with the same two findings and 1.4.1 onward
996
+ `Active` with `status_reason` = **"Passed automated checks"**, the string the
997
+ scanner writes when it finds nothing. That is a code change, not an admin
998
+ override.
999
+
1000
+ **But the registry is the weakest reason to have done this.** `_ffmpeg()` raised
1001
+ if no ffmpeg binary was on PATH, and ComfyUI never requires one -- so the
1002
+ feature that makes a tone A/B cost 14 s instead of 164 s hard-failed for a large
1003
+ share of users, on the pack's fastest path, at the exact moment a CivitAI post
1004
+ would send new people at it.
1005
+
1006
+ PyAV is a hard dependency of ComfyUI itself (SaveVideo and CreateVideo are built
1007
+ on it) and its ffv1 encoder lists `rgb48le` among 61 pixel formats, so the
1008
+ format did not have to change: ffv1 / rgb48le / level 3 / coder 1 / context 1,
1009
+ in matroska. Verified before writing any of it, and again through the real
1010
+ `HopStore`:
1011
+
1012
+ - PyAV encode -> PyAV decode: **bit exact**, including 0, 65535 and midpoints
1013
+ - **ffmpeg encode -> PyAV decode: bit exact** -- existing caches on disk still
1014
+ read, which is the part that protects users
1015
+ - file sizes within 44 bytes of each other (145,780 vs 145,824)
1016
+ - a frame-count mismatch still raises rather than returning a short clip
1017
+
1018
+ The decode path also got slightly better on the way: it decodes into one
1019
+ preallocated `(n, h, w, 3)` array instead of building a list of frames and
1020
+ stacking, so there is no second full-size copy. Five documents claimed ffmpeg
1021
+ was required -- CLAUDE.md, PROMPTING.md, README twice, and the on-canvas card in
1022
+ the Starter workflow via `tools/notes.py`. All corrected. README's existing
1023
+ "No dependencies to install" line, which already listed `av`, is now true rather
1024
+ than nearly true.
pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
  [project]
2
  name = "comfyui-hand-tie-clips"
3
- version = "0.4.4"
4
  description = "Native MiniMax H3 Ref2VA chain: persistent references, previous-segment clip+audio pin, one queue."
5
  readme = "README.md"
6
  # SPDX string, not `{ file = "LICENSE" }`. The registry does not resolve the file
 
1
  [project]
2
  name = "comfyui-hand-tie-clips"
3
+ version = "0.4.5"
4
  description = "Native MiniMax H3 Ref2VA chain: persistent references, previous-segment clip+audio pin, one queue."
5
  readme = "README.md"
6
  # SPDX string, not `{ file = "LICENSE" }`. The registry does not resolve the file
store.py CHANGED
@@ -24,10 +24,9 @@ and concatenates once at the end.
24
  import hashlib
25
  import json
26
  import os
27
- import shutil
28
- import subprocess
29
  import time
30
 
 
31
  import numpy as np
32
  import torch
33
 
@@ -38,15 +37,28 @@ AUDIO_EXT = ".npy"
38
  META_EXT = ".json"
39
  LATENT_EXT = ".latent.pt"
40
 
41
-
42
- def _ffmpeg():
43
- exe = shutil.which("ffmpeg")
44
- if not exe:
45
- raise RuntimeError(
46
- f"{TAG}: ffmpeg is not on PATH. The hop store needs it to write "
47
- f"lossless FFV1. Install ffmpeg or set cache to off."
48
- )
49
- return exe
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
 
52
  def tensor_digest(t):
@@ -115,32 +127,35 @@ class HopStore:
115
  past hop 1.
116
  """
117
  n, hgt, wid = int(imgs.shape[0]), int(imgs.shape[1]), int(imgs.shape[2])
 
 
 
118
  vid_tmp = self._p(key, VIDEO_EXT + ".part")
119
- cmd = [
120
- _ffmpeg(), "-y", "-v", "error",
121
- "-f", "rawvideo", "-pix_fmt", "rgb48le",
122
- "-s", f"{wid}x{hgt}", "-r", str(self.fps),
123
- "-i", "-",
124
- "-c:v", "ffv1", "-level", "3", "-coder", "1", "-context", "1",
125
- "-pix_fmt", "rgb48le",
126
- # The .part suffix defeats extension-based format detection, so the
127
- # muxer is named explicitly. Writing to .part and renaming on success
128
- # keeps a killed render from leaving a half-file that `has()` trusts.
129
- "-f", "matroska", vid_tmp,
130
- ]
131
- proc = subprocess.Popen(cmd, stdin=subprocess.PIPE,
132
- stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
133
  try:
134
- # Frame at a time: never materialise a second full-size copy.
135
- for i in range(n):
136
- f = (imgs[i].clamp(0, 1) * 65535.0).round().to(torch.int32)
137
- proc.stdin.write(f.numpy().astype("<u2").tobytes())
138
- proc.stdin.close()
139
- except BrokenPipeError:
140
- pass
141
- err = proc.stderr.read().decode(errors="replace")
142
- if proc.wait() != 0:
143
- raise RuntimeError(f"{TAG}: FFV1 encode failed for hop {key}: {err.strip()}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  os.replace(vid_tmp, self._p(key, VIDEO_EXT))
145
 
146
  np.save(self._p(key, AUDIO_EXT),
@@ -203,25 +218,31 @@ class HopStore:
203
  with open(self._p(key, META_EXT), encoding="utf-8") as fh:
204
  info = json.load(fh)
205
  n, hgt, wid = int(info["frames"]), int(info["height"]), int(info["width"])
206
- cmd = [
207
- _ffmpeg(), "-v", "error", "-i", self._p(key, VIDEO_EXT),
208
- "-f", "rawvideo", "-pix_fmt", "rgb48le", "-",
209
- ]
210
- want = n * hgt * wid * 3 * 2
211
- # communicate(), not sequential reads: draining stdout to EOF while
212
- # stderr is an unread pipe deadlocks the moment ffmpeg emits more than
213
- # the pipe buffer on stderr -- which is exactly what a corrupt FFV1
214
- # does, i.e. the one case where the error actually matters.
215
- proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
216
- raw, err_raw = proc.communicate()
217
- err = err_raw.decode(errors="replace")
218
- if proc.returncode != 0:
219
- raise RuntimeError(f"{TAG}: FFV1 decode failed for hop {key}: {err.strip()}")
220
- if len(raw) != want:
 
 
 
 
 
 
 
221
  raise RuntimeError(
222
- f"{TAG}: cached hop {key} is {len(raw)} bytes, expected {want}. "
223
  f"Delete the cache entry and re-render.")
224
- arr = np.frombuffer(raw, dtype="<u2").reshape(n, hgt, wid, 3)
225
  imgs = torch.from_numpy(arr.astype(np.float32) / 65535.0)
226
  wav = torch.from_numpy(np.load(self._p(key, AUDIO_EXT)))
227
  os.utime(self._p(key, VIDEO_EXT), None) # LRU touch
 
24
  import hashlib
25
  import json
26
  import os
 
 
27
  import time
28
 
29
+ import av
30
  import numpy as np
31
  import torch
32
 
 
37
  META_EXT = ".json"
38
  LATENT_EXT = ".latent.pt"
39
 
40
+ # FFV1 through PyAV, in process. This used to shell out to an `ffmpeg` binary
41
+ # on PATH, which cost two things:
42
+ #
43
+ # 1. The cache -- the feature that makes a tone A/B 14 seconds instead of
44
+ # 164 -- hard-failed with a RuntimeError for anyone who did not happen to
45
+ # have ffmpeg installed. ComfyUI itself never needs it on PATH, so that is
46
+ # most users, and the failure landed on the pack's fastest path.
47
+ # 2. The Comfy registry's YARA scan flags every `subprocess` call in a custom
48
+ # node (`python_command_injection_risk`, "detects ALL os.system and
49
+ # subprocess usage") with no taint analysis, so a static argument list with
50
+ # shell=False still flagged all three published versions.
51
+ #
52
+ # `av` is a hard dependency of ComfyUI itself -- SaveVideo and CreateVideo are
53
+ # built on it -- so this trades an optional external binary for a library that
54
+ # is already guaranteed present.
55
+ #
56
+ # The format is deliberately UNCHANGED: ffv1 / rgb48le / level 3 / coder 1 /
57
+ # context 1, in matroska. Verified bit-exact in both directions, so caches
58
+ # written by the old path stay readable and a resumed chain still matches an
59
+ # uninterrupted one.
60
+ FFV1_OPTIONS = {"level": "3", "coder": "1", "context": "1"}
61
+ PIX_FMT = "rgb48le"
62
 
63
 
64
  def tensor_digest(t):
 
127
  past hop 1.
128
  """
129
  n, hgt, wid = int(imgs.shape[0]), int(imgs.shape[1]), int(imgs.shape[2])
130
+ # The .part suffix defeats extension-based format detection, so the
131
+ # muxer is named explicitly. Writing to .part and renaming on success
132
+ # keeps a killed render from leaving a half-file that `has()` trusts.
133
  vid_tmp = self._p(key, VIDEO_EXT + ".part")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  try:
135
+ container = av.open(vid_tmp, mode="w", format="matroska")
136
+ try:
137
+ stream = container.add_stream("ffv1", rate=self.fps)
138
+ stream.width, stream.height = wid, hgt
139
+ stream.pix_fmt = PIX_FMT
140
+ stream.options = dict(FFV1_OPTIONS)
141
+ # Frame at a time: never materialise a second full-size copy.
142
+ for i in range(n):
143
+ f = (imgs[i].clamp(0, 1) * 65535.0).round().to(torch.int32)
144
+ frame = av.VideoFrame.from_ndarray(
145
+ f.numpy().astype("<u2"), format=PIX_FMT)
146
+ for packet in stream.encode(frame):
147
+ container.mux(packet)
148
+ for packet in stream.encode(): # flush the encoder
149
+ container.mux(packet)
150
+ finally:
151
+ container.close()
152
+ except Exception as e: # noqa: BLE001
153
+ # Leave no .part behind for the next run to trip over.
154
+ try:
155
+ os.remove(vid_tmp)
156
+ except OSError:
157
+ pass
158
+ raise RuntimeError(f"{TAG}: FFV1 encode failed for hop {key}: {e}") from e
159
  os.replace(vid_tmp, self._p(key, VIDEO_EXT))
160
 
161
  np.save(self._p(key, AUDIO_EXT),
 
218
  with open(self._p(key, META_EXT), encoding="utf-8") as fh:
219
  info = json.load(fh)
220
  n, hgt, wid = int(info["frames"]), int(info["height"]), int(info["width"])
221
+ # Decoded straight into one preallocated array rather than a list of
222
+ # frames: the meta file already states the geometry, so the destination
223
+ # is known up front and there is never a second full-size copy to stack.
224
+ arr = np.empty((n, hgt, wid, 3), dtype=np.uint16)
225
+ seen = 0
226
+ try:
227
+ container = av.open(self._p(key, VIDEO_EXT))
228
+ try:
229
+ for frame in container.decode(container.streams.video[0]):
230
+ if seen >= n: # more frames than meta
231
+ seen += 1
232
+ break
233
+ arr[seen] = frame.to_ndarray(format=PIX_FMT)
234
+ seen += 1
235
+ finally:
236
+ container.close()
237
+ except Exception as e: # noqa: BLE001
238
+ raise RuntimeError(f"{TAG}: FFV1 decode failed for hop {key}: {e}") from e
239
+ if seen != n:
240
+ # Same contract as the old byte-count check: a truncated or
241
+ # over-long cache entry is a hard error, not a short clip, because
242
+ # a hop silently missing its tail would poison every hop after it.
243
  raise RuntimeError(
244
+ f"{TAG}: cached hop {key} decoded {seen} frame(s), expected {n}. "
245
  f"Delete the cache entry and re-render.")
 
246
  imgs = torch.from_numpy(arr.astype(np.float32) / 65535.0)
247
  wav = torch.from_numpy(np.load(self._p(key, AUDIO_EXT)))
248
  os.utime(self._p(key, VIDEO_EXT), None) # LRU touch
tools/notes.py CHANGED
@@ -350,7 +350,7 @@ write negations. Skim the beats before queueing.
350
  ## Fixing one hop without re-rendering the rest
351
 
352
  Set **`cache_hops` to `on` before your first run.** It is off by default, and a
353
- hop that was never cached cannot be reused. Needs `ffmpeg` on PATH.
354
 
355
  The cache key **chains**, so editing shot 5 of 8 re-renders 5 to 8 and reuses 1
356
  to 4 off disk. Hop 6 was rendered *from* hop 5, so it has to. **Edit the
 
350
  ## Fixing one hop without re-rendering the rest
351
 
352
  Set **`cache_hops` to `on` before your first run.** It is off by default, and a
353
+ hop that was never cached cannot be reused. Nothing to install.
354
 
355
  The cache key **chains**, so editing shot 5 of 8 re-renders 5 to 8 and reuses 1
356
  to 4 off disk. Hop 6 was rendered *from* hop 5, so it has to. **Edit the
workflows/HandTieClips_Starter.json CHANGED
@@ -1378,7 +1378,7 @@
1378
  "htc_card": "author"
1379
  },
1380
  "widgets_values": [
1381
- "## Let a model write your plan\n\n`prompt_pack/` in the pack folder turns any chat model into a plan writer. In\nLM Studio, or anything with a system-prompt box:\n\n1. Load a model with **context 16384 or more**. The prompt is ~4,700 tokens and\n the reply another 1,000-2,000; a small window truncates the rules and you get\n invented directive names.\n2. Paste **`prompt_pack/SYSTEM_PROMPT.md`** into the **System Prompt** box.\n Nothing else goes in that box.\n3. **Temperature 0.3-0.5.** Higher and the JSON grows trailing commas and smart\n quotes.\n4. Describe the scene, and say how many hops and what pictures you have:\n\n > Six hops. A cook in a kitchen; she says one line, walks out into a hallway,\n > waits by a window, then comes back. I have a face photo, a photo of her\n > apron, and a photo of the kitchen.\n\n5. Each panel section has its own **JSON** disclosure at the bottom. The first\n ```json``` block goes in the one under **SCRIPT** (`shot_plan`), the second\n in the one under **REFERENCES** (`ref_plan`). Bad JSON keeps the last good\n version on screen and says so, rather than discarding your paste.\n6. **If the node rejects it, paste the error straight back into the chat.** One\n round trip usually fixes it.\n\nWant it to match a shape? Paste `prompt_pack/EXAMPLE_6_HOP.md` first.\n\nSmall models (7B-8B) hold the JSON schema but drift on the prose rules -- they\nwrite negations. Skim the beats before queueing.\n\n## Fixing one hop without re-rendering the rest\n\nSet **`cache_hops` to `on` before your first run.** It is off by default, and a\nhop that was never cached cannot be reused. Needs `ffmpeg` on PATH.\n\nThe cache key **chains**, so editing shot 5 of 8 re-renders 5 to 8 and reuses 1\nto 4 off disk. Hop 6 was rendered *from* hop 5, so it has to. **Edit the\nearliest hop you dislike and work forward** -- that way each hop is paid for\nonce.\n\nAnything chain-wide re-renders everything: resolution, aspect, overlap, sampler,\nscheduler, either shift, `ref_image_size`, `pin_to_qwen`, the LoRA stack, or\n**any reference picture** (keyed on pixels, so a re-crop counts even under the\nsame filename). That is the usual reason the cache looks broken.\n\nLoved a hop? Put `\"locked\": true` and a stable `\"id\"` on that shot and it keeps\nthat exact take even when its inputs move. Unrelated to `subjects.N.locked`,\nwhich is identity text.\n\nFull detail in `PROMPTING.md`, under *Re-rolling one hop*."
1382
  ],
1383
  "color": "#432",
1384
  "bgcolor": "#653"
 
1378
  "htc_card": "author"
1379
  },
1380
  "widgets_values": [
1381
+ "## Let a model write your plan\n\n`prompt_pack/` in the pack folder turns any chat model into a plan writer. In\nLM Studio, or anything with a system-prompt box:\n\n1. Load a model with **context 16384 or more**. The prompt is ~4,700 tokens and\n the reply another 1,000-2,000; a small window truncates the rules and you get\n invented directive names.\n2. Paste **`prompt_pack/SYSTEM_PROMPT.md`** into the **System Prompt** box.\n Nothing else goes in that box.\n3. **Temperature 0.3-0.5.** Higher and the JSON grows trailing commas and smart\n quotes.\n4. Describe the scene, and say how many hops and what pictures you have:\n\n > Six hops. A cook in a kitchen; she says one line, walks out into a hallway,\n > waits by a window, then comes back. I have a face photo, a photo of her\n > apron, and a photo of the kitchen.\n\n5. Each panel section has its own **JSON** disclosure at the bottom. The first\n ```json``` block goes in the one under **SCRIPT** (`shot_plan`), the second\n in the one under **REFERENCES** (`ref_plan`). Bad JSON keeps the last good\n version on screen and says so, rather than discarding your paste.\n6. **If the node rejects it, paste the error straight back into the chat.** One\n round trip usually fixes it.\n\nWant it to match a shape? Paste `prompt_pack/EXAMPLE_6_HOP.md` first.\n\nSmall models (7B-8B) hold the JSON schema but drift on the prose rules -- they\nwrite negations. Skim the beats before queueing.\n\n## Fixing one hop without re-rendering the rest\n\nSet **`cache_hops` to `on` before your first run.** It is off by default, and a\nhop that was never cached cannot be reused. Nothing to install.\n\nThe cache key **chains**, so editing shot 5 of 8 re-renders 5 to 8 and reuses 1\nto 4 off disk. Hop 6 was rendered *from* hop 5, so it has to. **Edit the\nearliest hop you dislike and work forward** -- that way each hop is paid for\nonce.\n\nAnything chain-wide re-renders everything: resolution, aspect, overlap, sampler,\nscheduler, either shift, `ref_image_size`, `pin_to_qwen`, the LoRA stack, or\n**any reference picture** (keyed on pixels, so a re-crop counts even under the\nsame filename). That is the usual reason the cache looks broken.\n\nLoved a hop? Put `\"locked\": true` and a stable `\"id\"` on that shot and it keeps\nthat exact take even when its inputs move. Unrelated to `subjects.N.locked`,\nwhich is identity text.\n\nFull detail in `PROMPTING.md`, under *Re-rolling one hop*."
1382
  ],
1383
  "color": "#432",
1384
  "bgcolor": "#653"