0.4.5: the hop cache stops shelling out to ffmpeg
Browse filesstore.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 +1 -1
- PROMPTING.md +3 -2
- README.md +13 -2
- docs/DEVLOG.md +53 -0
- pyproject.toml +1 -1
- store.py +73 -52
- tools/notes.py +1 -1
- workflows/HandTieClips_Starter.json +1 -1
|
@@ -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`.
|
| 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 |
|
|
@@ -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
|
| 523 |
-
|
|
|
|
| 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 |
|
|
@@ -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.
|
| 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 |
|
|
@@ -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.
|
|
@@ -1,6 +1,6 @@
|
|
| 1 |
[project]
|
| 2 |
name = "comfyui-hand-tie-clips"
|
| 3 |
-
version = "0.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
|
|
@@ -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 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
raise RuntimeError(
|
| 222 |
-
f"{TAG}: cached hop {key}
|
| 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
|
|
@@ -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.
|
| 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
|
|
@@ -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.
|
| 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"
|