Sandpies Claude Opus 5 commited on
Commit
abccaae
·
1 Parent(s): ff9b717

writer: count speech in the unit the line is written in

Browse files

First live write after the lead-in lint shipped was the proof it wanted: asked
for a 3-hop Korean vlog, the writer opened every hop with a named sound before
the line, and the lint stayed quiet on all three -- correctly.

The same run broke the length arithmetic. SPEECH_WPS counts whitespace tokens,
which is only a speech rate in a script that puts spaces between words. The
Korean line was 8 tokens and 27 syllables: the lint called it 3.2s against a
real 4-5s, warned on all three hops when two were fine, and told the author to
write "roughly 25 words" -- about 85 syllables in Hangul, three times the hop.

speech_seconds() counts CJK by syllable at SPEECH_SPS = 5.5 and everything else
by word, adding the two so a mixed beat is estimated correctly rather than in
whichever script dominates. Punctuation stranded by the split no longer counts
as a spoken word, which was buying every CJK line most of a second. The warning
and build_user_turn both name the target in the line's own unit.

Neither rate is measured on this model; DEVLOG 33 says so.

Also: docs/HANDOVER_*.md untracked and gitignored. They are working notes
between sessions, they name machine specifics, and one is always the live
queue -- kept on disk, out of the repo.

All 9 checks pass.

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

.gitignore CHANGED
@@ -15,3 +15,8 @@ node.zip
15
 
16
  # Local LLM connection settings -- a machine property, not a release.
17
  htc_llm.json
 
 
 
 
 
 
15
 
16
  # Local LLM connection settings -- a machine property, not a release.
17
  htc_llm.json
18
+
19
+ # Session handover docs. Working notes between sessions -- they name renders,
20
+ # dead ends and machine specifics, and one of them is always the live queue.
21
+ # The root HANDOVER.md was already ignored; these are the same thing, dated.
22
+ docs/HANDOVER_*.md
docs/DEVLOG.md CHANGED
@@ -1774,3 +1774,30 @@ smile and the wave are what filled the frames.
1774
  six words, 1.8 s voiced). Hops 2 and 3 of chain_00060 are confirmed-good
1775
  dialogue and would make a better basis. `LEAD_IN_MAX_WORDS = 8` is reasoned
1776
  from the length table, not measured at all.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1774
  six words, 1.8 s voiced). Hops 2 and 3 of chain_00060 are confirmed-good
1775
  dialogue and would make a better basis. `LEAD_IN_MAX_WORDS = 8` is reasoned
1776
  from the length table, not measured at all.
1777
+
1778
+ ## 33. The word rate is an English number (2026-09-02)
1779
+
1780
+ The first live write after section 32 was the proof it wanted: asked for a
1781
+ three-hop Korean vlog, the writer opened every hop with a named sound before
1782
+ the line -- *"The sound of heavy footsteps and distant city traffic fills the
1783
+ air before she speaks"* -- and the lead-in lint stayed quiet on all three,
1784
+ correctly.
1785
+
1786
+ The same run broke something else. `SPEECH_WPS` counts whitespace tokens, which
1787
+ is only a speech rate in a script that puts spaces between words. The Korean
1788
+ line was 8 tokens and 27 syllables: the lint called it 3.2 s against a real
1789
+ 4-5 s, warned on all three hops when two were fine, and -- the harmful half --
1790
+ told the author to write "roughly 25 words", which in Hangul is about 85
1791
+ syllables and three times the hop it has to fit in.
1792
+
1793
+ `speech_seconds()` now counts CJK by syllable at `SPEECH_SPS = 5.5` and
1794
+ everything else by word, adding the two so a mixed beat is estimated correctly
1795
+ rather than in whichever script dominates. Punctuation stranded by the split
1796
+ stops counting as a spoken word, which was quietly buying every CJK line most
1797
+ of a second. The shortfall warning and `build_user_turn` both name the target in
1798
+ the unit the line is written in.
1799
+
1800
+ Nothing here is measured on this model. 5.5 syllables a second is a reference
1801
+ figure for conversational Korean, Japanese and Mandarin alike, and `SPEECH_WPS`
1802
+ still rests on the one English measurement in section 32. Both are honest
1803
+ starting points and neither is evidence.
docs/HANDOVER_2026-09-01.md DELETED
@@ -1,314 +0,0 @@
1
- # Handover — 2026-09-01 (trim bars → LM branch → merge) — CLOSED
2
-
3
- **Superseded by `docs/HANDOVER_2026-09-02.md`.** Soundtrack + LM + texture are
4
- on local `main`; Grok's live queue is the writer-rail/vision leftover. Keep
5
- this file as the closed task list. Do not resume the merge.
6
-
7
- Written for a second agent (Grok) to pick up mid-flight. Root `HANDOVER.md`
8
- is still the general orientation doc.
9
-
10
- Read order if you need the closed queue: this file, then `CLAUDE.md`, then
11
- `docs/DEVLOG.md` §30.
12
-
13
- **Closed 2026-09-02.** TASK 1 `19cd072`. TASK 2.6 bit-identical vs `efd6a3e`;
14
- 2.8 widgets are a prefix. TASK 3 4b `b81504e`; 4a eviction confirmed live.
15
- TASK 4 merge on `main` (`185f1f4`+). Not pushed.
16
-
17
- ---
18
-
19
- ## 0. Rules that are not negotiable
20
-
21
- These are not style preferences. Each one has already cost a session.
22
-
23
- 1. **`widgets_values` is positional.** New widgets go at the **bottom** of
24
- `optional`, never in the middle, or every saved workflow silently misaligns.
25
- `tools/check_workflows.py` catches it — it has caught it three times.
26
- 2. **Never `rm` untracked things.** The `.bak` pile, `input/h3_refs/` media,
27
- `user/default/workflows/`. Move to `D:\ComfyUI\_disabled_custom_nodes\`.
28
- 3. **No `subprocess` anywhere in this pack.** It is registry-scanned and was
29
- flagged `python_command_injection_risk` for 0.4.1–0.4.3. `llm.py`'s docstring
30
- spells this out.
31
- 4. **aiohttp handlers share ComfyUI's event loop.** Anything that decodes,
32
- reads a file or talks to a model goes through `run_in_executor`. Blocking it
33
- freezes the canvas, the queue and the progress bar together.
34
- 5. **Never disable Smart App Control** on this machine — it is a one-way door
35
- that requires a clean Windows reinstall. If an unsigned `.pyd` is blocked,
36
- change the package version instead. (This is why the audio decoder is PyAV,
37
- which is already installed and proven, and not `torchcodec`.)
38
- 6. **Do not pass `--token` to `comfy node publish`** — it lands in
39
- `PSReadLine\ConsoleHost_history.txt`. Let it prompt.
40
- 7. Python is `D:/ComfyUI/venv/Scripts/python.exe`. PowerShell 5.1 — no `&&`.
41
- 8. `user/default/workflows/*.json` are on legacy node ids **on purpose**. Do
42
- not migrate them.
43
-
44
- ---
45
-
46
- ## 1. State
47
-
48
- | | |
49
- |---|---|
50
- | path | `D:\ComfyUI\custom_nodes\ComfyUI-Hand-Tie-Clips` |
51
- | current branch | **`soundtrack`**, 5 commits ahead of `main`, **not pushed** |
52
- | `main` | `d07b19b` — 0.4.5, pushed to both remotes |
53
- | also open | `texture-ratchet` (3 ahead, pushed), `llm-plan-writer` (9 ahead, **not pushed**) |
54
- | working tree | clean at `082eb91` |
55
-
56
- ```
57
- 082eb91 the trim bar, and the rail's pixel budget
58
- a2cfa42 trim windows on every media input, and a per-reference pixel budget
59
- efd6a3e media: decode audio with PyAV, because torchaudio.load no longer decodes
60
- 6c5d3ea soundtrack: put it in the panel, where the other media already lives
61
- 7d5c81d soundtrack: a music bed under the chain, ducked under the dialogue
62
- ```
63
-
64
- All six checkers on this branch pass:
65
-
66
- ```
67
- D:/ComfyUI/venv/Scripts/python.exe tools/check_waveform.py
68
- D:/ComfyUI/venv/Scripts/python.exe tools/check_music.py
69
- D:/ComfyUI/venv/Scripts/python.exe tools/check_workflows.py
70
- D:/ComfyUI/venv/Scripts/python.exe tools/check_features.py
71
- D:/ComfyUI/venv/Scripts/python.exe tools/check_prompts.py
72
- D:/ComfyUI/venv/Scripts/python.exe tools/check_templates.py
73
- ```
74
-
75
- Run all six before every commit. There is no JS runtime on this machine (no
76
- node, no deno), so **JavaScript is verified only by opening the browser** —
77
- say so plainly rather than claiming a JS change is tested.
78
-
79
- **What shipped in these 5 commits:** a music bed mixed under the finished
80
- chain; the soundtrack as a filename in the MEDIA strip (its AUDIO socket
81
- remains, and wins when both are set); PyAV audio decoding; trim windows
82
- (`in`/`out` seconds) on the reference clip, the voice and the soundtrack, with
83
- a waveform scrub bar per slot; and a per-reference megapixel cap in the
84
- REFERENCES rail. Rationale for all of it: `docs/DEVLOG.md` §30.
85
-
86
- ---
87
-
88
- ## 2. TASK 1 — the bar does not appear until the browser is refreshed
89
-
90
- **Confirmed by the user, 2026-09-01:** "after inserting a ref clip/audio/
91
- soundtrack, i have to refresh the browser before it appears. visually all the
92
- bars are moving well though." So the control itself works; only its first
93
- appearance is wrong.
94
-
95
- **Cause, and it is certain — no guessing needed.** In
96
- `js/h3_ref_chain_ui.js:126`, the media strip is constructed with
97
- `onChange: () => node.graph?.setDirtyCanvas?.(true, true)`. That repaints the
98
- litegraph node; it does **not** re-render this DOM. `createMediaStrip`'s own
99
- `render()` is called once at construction (`media_strip.js:136`) and then only
100
- from the UI's own render path. A trim bar is `display: none` until its file has
101
- a name (`trim_bar.js:261`), so picking a file leaves the bar hidden until a
102
- reload rebuilds the strip. The header `count` ("none set" / "2 set") is stale
103
- for exactly the same reason.
104
-
105
- **The fix, in `js/editor/media_strip.js` only.** Four edits:
106
-
107
- 1. Line 16 — also import the cache-drop helper, which already exists and is
108
- currently imported by nobody:
109
-
110
- ```js
111
- import { createTrimBar, forgetPeaks } from "./trim_bar.js";
112
- ```
113
-
114
- 2. Add these two functions **above** the `for (const [name, kind, ...] of SLOTS)`
115
- loop (i.e. after `let missing = 0;`):
116
-
117
- ```js
118
- function syncCount() {
119
- let set = 0;
120
- for (const [name] of SLOTS) {
121
- if (String(widgetByName(node, name)?.value || "")) set += 1;
122
- }
123
- count.textContent = set ? `${set} set` : "none set";
124
- }
125
-
126
- // The bars and the header, deliberately NOT the pickers: this runs from
127
- // inside a picker's own `set`, and re-entering that picker's render in the
128
- // middle of its own callback is how you get a control that fights itself.
129
- function afterPick() {
130
- syncCount();
131
- for (const b of bars) b.render();
132
- }
133
- ```
134
-
135
- 3. In the picker construction (currently `set: (v) => commit(node, w, v),`),
136
- redraw after the commit:
137
-
138
- ```js
139
- // A pick has to redraw the bar under it. The strip's `onChange` is
140
- // only `setDirtyCanvas`, which repaints the litegraph node and not
141
- // this DOM, so before this a newly picked file left its bar hidden
142
- // until a browser reload rebuilt the strip from scratch.
143
- //
144
- // `forgetPeaks` because the client cache is keyed by NAME while the
145
- // route's is keyed by (name, mtime): re-uploading different audio
146
- // under a basename already on the canvas would otherwise draw the
147
- // old file's waveform over the new one's duration.
148
- set: (v) => { commit(node, w, v); forgetPeaks(v); afterPick(); },
149
- ```
150
-
151
- 4. `render()` loses its inlined counting and calls `syncCount()` instead —
152
- one definition of the count, not two:
153
-
154
- ```js
155
- function render() {
156
- syncCount();
157
- for (const p of pickers) p.render();
158
- for (const b of bars) b.render();
159
- }
160
- ```
161
-
162
- **Verify (browser, no restart needed — it is JS only; hard-reload the tab):**
163
- open the panel, MEDIA tab, pick a voice file. The waveform row must appear
164
- immediately, the header must go to "1 set", and the grips must drag on the
165
- first try. Then pick a *different* file into the same slot: the waveform must
166
- change. No F5 at any point.
167
-
168
- **Do not** fix this by calling the strip's full `render()` from the picker, and
169
- **do not** re-render anything while a grip is being dragged — rebuilding the
170
- DOM destroys the pointer-captured element and the drag dies halfway. That rule
171
- is why the bar paints its grips directly during a drag and only calls `set()`
172
- on release.
173
-
174
- Commit alone, on `soundtrack`:
175
- `media: a picked file redraws its own bar, without a reload`
176
-
177
- ---
178
-
179
- ## 3. TASK 2 — verification the user must run (agents cannot do these)
180
-
181
- Nothing has been rendered since `efd6a3e`. These are from the approved plan and
182
- each needs a human at the ComfyUI GUI. **Restart ComfyUI first** — there are new
183
- Python widgets and new JS modules. Before any A/B, set
184
- `control_after_generate=fixed`.
185
-
186
- 3. **Header-lie check.** Peaks route against `Track 2 master test.mp3`;
187
- `seconds` must be ≈ **173.5**, not a doubled Xing figure. (173.49 s is the
188
- decoded truth, already measured.)
189
- 4. **Trim a voice.** Same seed, same everything, voice trimmed to a different
190
- 6 s window. The render **must** differ — that is the proof it reaches the
191
- conditioning — and the console must report a hop-cache miss.
192
- 5. **Trim the soundtrack** to a window recognisable by ear; the bed must start
193
- there, not at 0.
194
- 6. **Null check.** Every window at 0/0 → bit-identical to a render from
195
- `efd6a3e`. Untouched inputs must cost nothing.
196
- 7. **Per-ref mp.** Drop one reference to 0.3 MP; `info` must name the resize and
197
- the hop cache must miss.
198
- 8. **Old workflow.** Load one saved before this change; confirm no widget
199
- shifted.
200
-
201
- If 4 or 6 fails, stop and report — that is the half where being wrong is
202
- *silent*, and it is worth more than any new feature.
203
-
204
- ---
205
-
206
- ## 4. TASK 3 — the LM branch (`llm-plan-writer`)
207
-
208
- Nine commits ahead of `main`, never pushed, an **alpha**. It adds an LM Studio
209
- plan writer: `llm.py` (647), `planner.py` (371), `js/editor/writer_bar.js`
210
- (311), `routes.py` +162, `tools/check_planner.py` (322),
211
- `tools/grade_plan.py` (368), `tools/check_all.py` (93), DEVLOG §29.
212
-
213
- ```
214
- 0faf168 llm: keep the writer warm, and evict it when a render is queued
215
- 68b13a4 tools: check_all.py, the pre-commit sweep that kept being retyped
216
- edc1f8f writer bar: the model dropdown now actually selects the model
217
- 6d1996d planner: warn when the final shot leaves `tail` unset
218
- 440ffcb tools: rescue grade_plan.py from a scratch directory
219
- fcbaf66 ALPHA: a Free VRAM killswitch, not just an automatic unload
220
- 2450d96 ALPHA: the panel writes and repairs its own plans
221
- ```
222
-
223
- Two items are open on it. **Read `llm.py`, `planner.py` and the branch's
224
- `CLAUDE.md` before touching either** — they were queued in an earlier session
225
- and `2450d96` may already have moved the ground under the second one.
226
-
227
- **4a. Warm/evict timing check (measurement, not code).** `0faf168` keeps the
228
- writer resident between plans and evicts it when a render is queued;
229
- `h3_ref_chain.py`'s `run()` calls `llm.free_for_render()` as its first act,
230
- blocking on purpose (it is the execution worker thread, not the event loop).
231
- What has never been measured is whether the wait is long enough on this card:
232
- write a plan, queue a render immediately, and confirm from the console that the
233
- eviction is reported *and* the diffusion load does not OOM. The dial if it is
234
- short is the post-evict wait in `llm.py`'s config block (near `keep_warm`), and
235
- the honest fix is a longer wait, not a smaller model.
236
-
237
- **4b. "Suggest, don't set."** The writer currently writes plan fields directly.
238
- The intent recorded earlier is that it should *propose* and the user accept —
239
- the model is a drafting aid, and a plan silently rewritten under you is worse
240
- than no plan. Confirm the current behaviour first; if `2450d96` already
241
- proposes rather than writes, close this item in the DEVLOG rather than
242
- building it twice.
243
-
244
- ---
245
-
246
- ## 5. TASK 4 — the merge, and what will conflict
247
-
248
- Order: verify `soundtrack` (§3) → merge to `main` → then merge
249
- `llm-plan-writer` → then `texture-ratchet`. Do not merge anything before the
250
- null check in §3 passes.
251
-
252
- **Known conflicts when `llm-plan-writer` meets `soundtrack`/`main`:**
253
-
254
- - **`h3_ref_chain.py`, the top of `run()`** — the real one. `llm-plan-writer`
255
- inserts its `free_for_render()` block immediately after the `run()`
256
- signature, and `soundtrack` has lengthened that signature by seven
257
- parameters (`soundtrack_file`, then the six window floats). **Resolution:
258
- keep `soundtrack`'s longer signature verbatim, and put the LM block at the
259
- top of the body.** Do not let a merge tool drop a parameter — that is a
260
- silent `widgets_values` misalignment in every saved workflow.
261
- - **`routes.py`** — both branches append routes. Keep both.
262
- - **`js/h3_ref_chain_ui.js`, `js/h3_ref_chain.css`** — both append. Keep both.
263
- - **`docs/DEVLOG.md`** — §29 is on `llm-plan-writer`, §30 on `soundtrack`.
264
- Keep both, in number order.
265
- - **`CLAUDE.md`** — both add sections. Keep both.
266
-
267
- **Then register the new checkers.** `tools/check_all.py` exists only on
268
- `llm-plan-writer` and does not know about `check_music.py`,
269
- `check_texture.py` (on `texture-ratchet`) or `check_waveform.py`. Add all
270
- three once the branches are together, and confirm `check_all.py` runs green.
271
-
272
- ---
273
-
274
- ## 6. Also open, not scheduled
275
-
276
- - **`pin_renorm=band` A/B** on `texture-ratchet`: 3 hops,
277
- `control_after_generate=fixed`, `cache_hops=on`, read the face box with
278
- `tools/texture_probe.py --box`. Baseline is ×1.042 per join.
279
- - **`match_band` partial pull** — mirror `tone.anchor_pull`'s
280
- `ANCHOR_STRENGTH = 0.35`, **only** if faces go waxy after that A/B.
281
- - **A crop box for the first frame.** `MiniMaxH3AddGuide` does
282
- `_resize(image, width, height, "center")`, so a 9:16 source on a 16:9 canvas
283
- silently loses both sides. A megapixel control there would be inert — this is
284
- the control it actually lacks. Noted, not planned.
285
- - **Nothing on `soundtrack` or `llm-plan-writer` is pushed.** The remote has
286
- only `main` and `texture-ratchet`.
287
- - Six `.npy` scratch files remain in `D:\downloads\`.
288
-
289
- ---
290
-
291
- ## 7. Facts worth not re-deriving
292
-
293
- - The pack's media story is **filenames, not sockets**. Sixteen sockets were
294
- collapsed to five; `start_image_file` / `reference_video_file` / `voice_file` /
295
- `soundtrack_file` are STRING widgets naming files under
296
- `<ComfyUI input>/h3_refs`.
297
- - **torchaudio ≥ 2.9 cannot decode.** `torchaudio.load` is a thin wrapper over
298
- `torchcodec`, which is not installed, so it raised on every file — this had
299
- been silently breaking `voice_file` too, not just the soundtrack.
300
- `torchaudio.functional.resample` still works. Audio decoding is PyAV now.
301
- - **`_encode_ref_audio` has no cap** (`comfy_extras/nodes_minimax_h3.py:71`).
302
- The whole voice file becomes latent frames the DiT attends over on every step
303
- of every hop. This is why trimming the voice is a cost fix, not a convenience.
304
- - **`chain_salt` digests tensors, not filenames or settings.** So trim windows
305
- and per-reference megapixels are hop-cache-correct with zero cache changes: a
306
- trimmed voice is simply a different tensor.
307
- - **`end == 0` means "to the end of the file."** `media.clip_window()` is the
308
- single definition, shared by four readers, and it can never return an empty
309
- span — reversed, negative, past-the-end and shorter-than-50 ms all fall back
310
- to the whole file.
311
- - **Peaks are bucket-`max`, not mean.** A mean flattens transients, and
312
- transients are the only landmarks you can trim against.
313
- - Bash heredocs in this environment mangle backslashes. Write scripts
314
- containing regex escapes to a file instead.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/HANDOVER_2026-09-02.md DELETED
@@ -1,186 +0,0 @@
1
- # Handover — 2026-09-02 (merged main, writer looks at the rail)
2
-
3
- Written by Grok for Claude to take over. Root `HANDOVER.md` is orientation.
4
- `docs/HANDOVER_2026-09-01.md` is the **closed** soundtrack/LM/merge queue.
5
- This file is **the live queue** and supersedes both wherever they disagree.
6
-
7
- Read order: this file, then `CLAUDE.md`, then `docs/DEVLOG.md` §29 (writer)
8
- and §30 (trim / null check). Plan files under `~/.claude/plans/` are historical.
9
-
10
- ---
11
-
12
- ## 0. Where you are
13
-
14
- | | |
15
- |---|---|
16
- | path | `D:\ComfyUI\custom_nodes\ComfyUI-Hand-Tie-Clips` |
17
- | branch | **`main`** at `495335d` *writer: desc and context are the point of looking* |
18
- | remotes | **not pushed.** `main` is **35 commits ahead of `origin/main`** (`d07b19b`, 0.4.5) |
19
- | working tree | clean |
20
- | version | `pyproject.toml` still **0.4.5** — no bump, no publish |
21
- | JS | `js/h3_ref_chain_ui.js` `VERSION = "v1.5.3"` |
22
- | Python | `D:/ComfyUI/venv/Scripts/python.exe` |
23
- | shell | PowerShell 5.1 — no `&&` |
24
-
25
- ```
26
- 495335d writer: desc and context are the point of looking ← HEAD, untested in GUI
27
- 1fb2293 writer: empty subjects is a patch, not a rewrite
28
- 2077f42 writer: the rail's pictures are the scene
29
- 406e242 schema: the planner knows about per-ref mp
30
- 185f1f4 Merge branch 'texture-ratchet'
31
- c810f83 Merge branch 'llm-plan-writer'
32
- 3def9a0 docs: TASK 2 null check and old-workflow widgets passed
33
- ```
34
-
35
- `soundtrack` (`3def9a0`) and `llm-plan-writer` (`b81504e`) are **merged and stale**. Do not commit on them. `texture-ratchet` is on origin; its commits are in `main` via `185f1f4`.
36
-
37
- **Restart ComfyUI and hard-reload the tab before any GUI check.** Python + JS both changed. The last live Write-plan the user saw was **before** `495335d`.
38
-
39
- ---
40
-
41
- ## 1. Rules that are not negotiable
42
-
43
- Same as 09-01. Each has already cost a session.
44
-
45
- 1. **`widgets_values` is positional.** New widgets at the **bottom** of `optional` only. `tools/check_workflows.py` is the catcher.
46
- 2. **Never `rm` untracked things.** Move to `D:\ComfyUI\_disabled_custom_nodes\`.
47
- 3. **No `subprocess` in this pack** (registry `python_command_injection_risk`). `tools/check_all.py` may use it; `llm.py` / `store.py` must not.
48
- 4. **aiohttp handlers share the event loop.** Decode / disk / model I/O goes through `run_in_executor`.
49
- 5. **Never disable Smart App Control.**
50
- 6. **Do not pass `--token` to `comfy node publish`.**
51
- 7. Python / PowerShell as above.
52
- 8. `user/default/workflows/*.json` stay on **legacy node ids** (`H3RefChain`). Do not migrate them.
53
-
54
- Checkers before every commit:
55
-
56
- ```
57
- D:/ComfyUI/venv/Scripts/python.exe tools/check_all.py
58
- ```
59
-
60
- Nine children: templates, prompts, waveform, music, texture, workflows, features, planner, `gen_schema.py --check`. All green at `495335d`.
61
-
62
- No node/deno on this machine. **JS is verified only in the browser.** Say so; do not claim a JS change is tested.
63
-
64
- ---
65
-
66
- ## 2. Closed — do not redo
67
-
68
- ### Merge (TASK 4 from 09-01)
69
-
70
- Order was: verify soundtrack → `main` ← soundtrack ← llm-plan-writer ← texture-ratchet.
71
-
72
- Done on local `main`. Conflicts resolved as specified:
73
-
74
- - `h3_ref_chain.py` `run()`: **soundtrack's longer signature kept** (`soundtrack` … `music_end_s`), `llm.free_for_render()` at the **top of the body**.
75
- - `routes.py` / JS / CSS: both sides kept.
76
- - DEVLOG: §§24–30 in number order.
77
- - `check_all.py` taught music / waveform / texture.
78
- - Planner schema knows per-ref `mp` (`406e242`).
79
-
80
- ### TASK 1 — trim bar first appearance
81
-
82
- `19cd072`. User signed off.
83
-
84
- ### TASK 2 — GUI verification (soundtrack vs `efd6a3e`)
85
-
86
- | # | item | result |
87
- |---|---|---|
88
- | 2.3 | Header-lie (peaks `seconds` ≈ 173.5 on *Track 2 master test.mp3*) | pass (user) |
89
- | 2.4 | Voice trim reaches encoder | pass; male timbre will not override a woman in frame at 8-step turbo |
90
- | 2.5 | Soundtrack mix | pass (user; −6 dB under speech) |
91
- | 2.6 | Null check 0/0 vs `efd6a3e` | **pass, bit-identical hops** |
92
- | 2.7 | Per-ref mp | pass (user) |
93
- | 2.8 | Old workflow widgets | **pass** — names from 0.4.5 (34) and `efd6a3e` (39) are a prefix of 45 |
94
-
95
- **2.6 detail (do not re-render).** A's graph vs `efd6a3e` with every window 0/0, empty `voice_file` / `soundtrack_file`. Hop 1 and hop 2 FFV1 frames and `.npy` audio identical (contact_sheet_00039 vs 00043; pin σ 0.9578/0.4434). Hop **keys** differ because this tree added `voice_on`; that is a key-field change, not a generate change.
96
-
97
- Two agent B renders **did not count**: Starter graph without Parasyte LoRA, and a beat truncated at the 400-char hop-meta. Real B used A's API prompt from `contact_sheet_00039_.png` metadata.
98
-
99
- A's copies: `D:\downloads\_nullcheck_A\`.
100
-
101
- ### TASK 3 4b — suggest, don't set
102
-
103
- `b81504e`. Write plan holds a draft; Accept writes widgets; Discard leaves cards. Still the rule.
104
-
105
- ### TASK 3 4a — warm/evict vs OOM
106
-
107
- **Measured this session, pass.** Keep-warm was on. Queue called `free_for_render`. LM Studio log: `POST /api/v1/models/unload`. User: "the model did get evicted." No OOM reported.
108
-
109
- The bug that made eviction a no-op: `asyncio.run(unload_all(...))` inside ComfyUI's **already-running** execute loop → `RuntimeError` + `coroutine never awaited`. Fixed in `2077f42` as `llm._run_coro` (side thread, own loop). Proven offline in `tools/check_planner.py` (`free_for_render from a running loop does not raise`).
110
-
111
- ---
112
-
113
- ## 3. LIVE — writer looks at the rail (take over here)
114
-
115
- User intent: pictures already in the REFERENCES boxes are the scene. Write plan must look at them (vision), keep those tags/files, fill retention / subject / desc / subjects from the stills + the brief. Not pick other files from `h3_refs`.
116
-
117
- ### What landed
118
-
119
- | commit | what |
120
- |---|---|
121
- | `2077f42` | POST current rail rows; pin tags+files; attach JPEG stills on attempt 1; vision 400 → filenames-only fallback |
122
- | `1fb2293` | empty `subjects: {}` is a subjects-only repair + merge; stub name/locked from desc after 3 misses; panel shows a partial draft on give-up |
123
- | `495335d` | **untested in GUI.** Empty values do not wipe a filled register. Pinned writes **require** `desc` on every ref and `name`/`locked`/`context` on every subject |
124
-
125
- Key files: `js/editor/writer_bar.js` (`railRefs()`), `routes.py` (`_pinned_refs`, executor encode), `planner.py` (`build_user_turn` pinned path, `attach_images`, `_merge_register`, validate), `llm.py` (multimodal `content`, `_nudge_no_think`, `_run_coro`), `media.py` (`vision_data_url`, `VISION_SIDE = 1024`).
126
-
127
- No new `INPUT_TYPES`. Draft still waits for Accept.
128
-
129
- ### Last live run (before `495335d`)
130
-
131
- User: two random stills in the rail (`pic2_1.jpg` girl, `h3_stress_kitchen_2.jpg` kitchen), both rows still on setting/prop + reference, brief about a beautiful Asian girl talking, 2 hops. Qwen3.8-27b-uncensored (VLM, JIT-loaded).
132
-
133
- **Worked:** tags/files kept; `@ref_1` became Subject 1 / fully preserved; `@ref_2` stayed setting/reference; shots listed 1 and 2; script was a talking scene; vision parts were in the POST (`image_url` data-URLs); model evicted on queue.
134
-
135
- **Failed:** `desc` on both refs and `context` on Subject 1 stayed empty. Subject name/locked were the **stub** (`the person` / `the same face and hair`) because qwen emitted `"subjects": {}` on every attempt, including a repair that re-sent refs **without** `desc` and wiped attempt 1's `"head and shoulders, even light"`.
136
-
137
- LM Studio also logged: `Qwen-VL models require at minimum 1024 image tokens` (`--image-min-tokens 1024`). `VISION_SIDE` is now 1024 px; that is pixels, not llama.cpp image tokens. If desc/context stay generic after `495335d`, the next lever is LM Studio's load flag `--image-min-tokens 1024`, not more prompt text.
138
-
139
- ### Your first GUI job
140
-
141
- 1. Restart ComfyUI, hard-reload. Confirm console `[HandTieClips] editor ui v1.5.3 loaded`.
142
- 2. Same two pictures, same brief, hops=2, duration **7 s** is the RUN panel not the hops box.
143
- 3. Write plan. Expect:
144
- - same two filenames, same tags
145
- - `@ref_1` fully_preserved + subject 1
146
- - `@ref_2` reference, no subject
147
- - **non-empty `desc` on both rows** (one sentence of what the photo shows)
148
- - Subject 1: `name` + `locked` + **`context`** (wardrobe/pose), not the stub
149
- 4. If the status line says *filled from the photo descriptions*, the model still omitted prose and we stubbed — read the three boxes before Accept.
150
- 5. Accept, queue, confirm unload line `[HandTieClips] freed the writer` (or silent if already unloaded).
151
-
152
- If desc/context are still stub/empty after that, the structured-output schema still lets `"subjects": {}` and omitted `desc` through. Do **not** put vision rules in `SYSTEM_PROMPT.md` (that file is also the manual LM Studio paste). Keep pinning + "look at the stills" in `build_user_turn`. A tighter repair that asks **only** for `desc`/`context` (and merges) is the next code move; a second tiny vision turn is the one after that.
153
-
154
- Do not scan the whole `h3_refs` folder with vision. Do not auto-Accept.
155
-
156
- ---
157
-
158
- ## 4. Open, not the writer
159
-
160
- - **`pin_renorm=band` A/B** (was texture-ratchet, now on `main`): 3 hops, `control_after_generate=fixed`, `cache_hops=on`, `tools/texture_probe.py --box`. Baseline +4.2% mid-band per join. `sigma` is a no-op / wrong way; `band` is the lever. Only pull `match_band` toward `tone.anchor_pull`'s 0.35 **if faces go waxy**.
161
- - **First-frame crop box.** `MiniMaxH3AddGuide` center-crops; a 9:16 still on 16:9 loses the sides. Noted, not planned. A megapixel cap there is inert.
162
- - **Push.** Nothing on `main` is on GitHub/HF except the old 0.4.5. If the push touches `docs/img/`: `git lfs push https://github.com/dntpi/ComfyUI-Hand-Tie-Clips.git main` **then** `git push origin main`. `origin` has two push URLs; LFS only follows the first.
163
- - **Version / publish.** Still 0.4.5. Do not `comfy node publish` with `--token`.
164
- - **`user/default/workflows`** remain legacy `H3RefChain`. Shipped examples are `workflows/HandTieClips_Starter.json` / `_Showcase.json` (45 widgets).
165
-
166
- ---
167
-
168
- ## 5. Facts worth not re-deriving
169
-
170
- - Pack media is **filenames under `<input>/h3_refs`**, not sockets. `end==0` means to EOF; `media.clip_window()` is the only window definition.
171
- - `chain_salt` digests **tensors**, not filenames. Trim / mp already invalidate the hop cache. `voice_on` is an extra key field on this tree (why 2.6 keys differ, pixels did not).
172
- - Hop meta `block` is stored as `block[:400]`. Do not reconstruct a beat from that.
173
- - H264 SaveVideo is not bit-identical; 2.6 compared hop FFV1 `.mkv` + `.npy`.
174
- - Writer: `htc_llm.json` is gitignored, machine-local. No API keys. `keep_warm` default on; eviction is `run()` → `free_for_render`. `tools/check_planner.py` redirects `_conn_path` to a temp file so it cannot overwrite the user's settings.
175
- - ComfyUI execute is **async**. Never `asyncio.run` from `run()`.
176
- - 8-step turbo is the validation regime. Do not report 14-step as the result.
177
-
178
- ---
179
-
180
- ## 6. Also on disk (scratch, do not `rm`)
181
-
182
- - `D:\downloads\_nullcheck_A\` — A's lossless hops for 2.6
183
- - `D:\downloads\_nullcheck_A_prompt.json` — A's API graph
184
- - `D:\downloads\_queue_nullcheck_b.py` / `_queue_nullcheck_b3.py` / `_compare_*.py` — agent B scripts
185
- - `D:\ComfyUI\output\HandTieClips\contact_sheet_00039_.png` — A's sheet (has embedded prompt+workflow)
186
- - Six `.npy` scratch files in `D:\downloads\` from an older note; leave them
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/HANDOVER_2026-09-02_2.md DELETED
@@ -1,273 +0,0 @@
1
- # Handover — 2026-09-02, second session (writer: the rail is read, the GUI is not)
2
-
3
- Written by Claude at the end of the 02:30 session. **This file is the live
4
- queue.** It supersedes `docs/HANDOVER_2026-09-02.md` (Grok's, 01:36) and
5
- `docs/HANDOVER_2026-09-01.md` (closed) wherever they disagree.
6
-
7
- Read order: this file, then `CLAUDE.md`, then `docs/DEVLOG.md` §29–§30.
8
- **DEVLOG §31 is not written yet** — see §4. Plan files under `~/.claude/plans/`
9
- are historical.
10
-
11
- ---
12
-
13
- ## 0. Where you are
14
-
15
- | | |
16
- |---|---|
17
- | path | `D:\ComfyUI\custom_nodes\ComfyUI-Hand-Tie-Clips` |
18
- | branch | **`main`** at `4f756a7` *a likeness plate and a wardrobe plate are not the same picture* |
19
- | remotes | **not pushed.** `main` is **40 commits ahead of `origin/main`** |
20
- | working tree | clean |
21
- | version | `pyproject.toml` still **0.4.5** — no bump, no publish |
22
- | JS | `js/h3_ref_chain_ui.js` `VERSION = "v1.5.3"` — **untouched this session** |
23
- | Python | `D:/ComfyUI/venv/Scripts/python.exe` |
24
- | checkers | `tools/check_all.py` — **all 9 green at `4f756a7`** |
25
-
26
- ```
27
- 4f756a7 writer: a likeness plate and a wardrobe plate are not the same picture
28
- 369d3b1 writer: a cut-off reply is the context, not the formatting
29
- bbade17 writer: take the empty answer out of the grammar
30
- 9cf1a64 writer: the filename is the identity, not the tag
31
- 2517543 docs: handover for Claude, writer-rail is the live queue <- Grok's
32
- 495335d writer: desc and context are the point of looking
33
- ```
34
-
35
- ### The LM Studio settings are load-bearing and they do not survive a reload
36
-
37
- **Context `32768`, reasoning OFF.** Both were correct for the one fully
38
- successful run and both had reverted to defaults (8192, thinking on) by the
39
- next one. Check them before reading anything into a failure. The arithmetic
40
- that proves it is in the error message itself: `prompt 8159 + completion 31 =
41
- 8190 ≈ 8192`.
42
-
43
- Reasoning off is now a standing rule from the user, not a workaround.
44
-
45
- ---
46
-
47
- ## 1. Rules that are not negotiable
48
-
49
- Unchanged from 09-01 and 09-02. Each has already cost a session.
50
-
51
- 1. **`widgets_values` is positional.** New widgets at the **bottom** of
52
- `optional` only. `tools/check_workflows.py` is the catcher.
53
- 2. **Never `rm` untracked things.** Move to `D:\ComfyUI\_disabled_custom_nodes\`.
54
- 3. **No `subprocess` in this pack.** `tools/check_all.py` may; `llm.py` /
55
- `store.py` must not.
56
- 4. **aiohttp handlers share the event loop.** Decode / disk / model I/O goes
57
- through `run_in_executor`.
58
- 5. **Never disable Smart App Control.**
59
- 6. **Do not pass `--token` to `comfy node publish`.**
60
- 7. PowerShell 5.1 — no `&&`.
61
- 8. `user/default/workflows/*.json` stay on legacy `H3RefChain` ids.
62
-
63
- New, and it shaped two changes this session:
64
-
65
- 9. **A change to `prompt_pack/` goes in BOTH `SYSTEM_PROMPT.md` and
66
- `AUTHORING_PROMPT.md`.** `tools/check_prompts.py` compares the numbered
67
- rules, the length table and the closing checklist; it does **not** compare
68
- the prose between them, so a rule added to one file only can still pass.
69
- `AUTHORING_PROMPT.md` is the copy users paste into LM Studio by hand, and
70
- the user has said explicitly that the manual paste is the *primary* route —
71
- the Write-plan button is the alternative. A rule that helps both belongs in
72
- the prompt; a rule about the rail belongs in `planner.build_user_turn`,
73
- where it cannot leak into the paste text.
74
-
75
- ---
76
-
77
- ## 2. Closed — do not redo
78
-
79
- ### The 09-02 live item: "writer looks at the rail"
80
-
81
- **Vision was working the whole time.** It never needed proving again after
82
- `495335d`. The rail's filenames actively lie (`cafe_floral_9x16.jpg` *is the
83
- face plate*, `podcast_host.jpg` is the full body), and the model described all
84
- three correctly. Nothing that reads filenames calls `cafe_floral_9x16.jpg` a
85
- close-up portrait.
86
-
87
- `VISION_SIDE = 1024` is fine. **Do not chase `--image-min-tokens`** — that was
88
- Grok's next lever and it was aimed at the wrong thing.
89
-
90
- ### Four faults, in the order they were hiding behind each other
91
-
92
- | commit | fault | how it was proved |
93
- |---|---|---|
94
- | `9cf1a64` | model renames rail tags (`@ref_1` -> `@girl_face`); `validate` matched on tag so it **never reached the file check**, and `_merge_register` unioned by tag so attempt 1's invented names poisoned every later attempt — the loop could not converge on a plan that was already correct | remap by filename; `check_planner.py` asserts first-attempt converge |
95
- | `bbade17` | `"subjects": {}` and omitted `desc` | **three byte-identical 1797-char replies** across three repair turns. Prose cannot outvote a grammar that permits the empty answer |
96
- | `369d3b1` | a truncated reply was returned as-is, `split_reply` found no JSON, and the loop spent every attempt blaming the model's formatting for an exhausted context | `finish_reason == "length"` now checked before the text is returned |
97
- | `4f756a7` | both plates of one person marked `fully_preserved` on every hop — including a full-body plate with a microphone and another room in it | prompt rule + two lints |
98
-
99
- ### The one fully successful run (worth not re-deriving)
100
-
101
- Context 32768, reasoning off, three stills, the user's deliberately bad brief.
102
- **ok, 2 attempts, no stub.**
103
-
104
- - `@ref_1` *"a close-up portrait of a young woman with long dark hair and red
105
- lipstick smiling at the camera"*
106
- - `@ref_2` *"a wide shot of a kitchen counter with a blue mug and a sink under
107
- a window"*
108
- - `@ref_3` *"a medium shot of a young woman in a white top and denim shorts
109
- sitting next to a microphone"*
110
- - subject 1 `context`: *"a white ribbed crop top and blue denim shorts, sitting
111
- on the counter"* — read off `@ref_3`, which is the user's "full body image is
112
- the wardrobe" instruction obeyed without it having been written well.
113
-
114
- The one thing it got wrong is what `4f756a7` addresses: `@ref_3` was
115
- `fully_preserved` on `[1, 2]`. It should be `partially_copy` on `[1]` with the
116
- colours in `context`, which is what an earlier run produced unprompted.
117
-
118
- ---
119
-
120
- ## 3. LIVE — take over here
121
-
122
- ### 3a. The GUI and the HTTP route were never exercised. Say so; do not imply otherwise.
123
-
124
- Everything above was driven by a standalone script that calls
125
- `planner.write_plan` **directly**:
126
-
127
- D:\downloads\drive_writer.py (NIMG=0 forces the text-only path)
128
- D:\downloads\measure_ctx.py (prompt-token measurements)
129
-
130
- Both were copied out of the session scratchpad, which is session-scoped and is
131
- gone by the time you read this. They need no running ComfyUI: `media.input_dir`
132
- falls back to `<ComfyUI>/input` when `folder_paths` is absent, and the pack is
133
- loaded the way `tools/check_planner.py` loads it, with `D:\ComfyUI` on
134
- `sys.path`. That also means they always run the code **on disk** rather than
135
- whatever a long-lived ComfyUI imported at boot — which is why they are the
136
- faster loop for anything that is not the panel itself.
137
-
138
- **Therefore still unverified:**
139
-
140
- - `routes.py::_plan` — `_pinned_refs`, the executor `vision_data_url` encode,
141
- the 400-on-images fallback. The script builds pinned rows and data-URLs by
142
- hand and bypasses the handler entirely.
143
- - The panel: Write plan -> the three boxes -> **Accept** -> widgets -> queue.
144
- - `js/` is unchanged this session, so `v1.5.3` is as it was, but the *round
145
- trip* of an accepted register onto the rail rows has not been seen since the
146
- tag remap landed. That is the one place the remap could still be wrong: the
147
- rail keys rows by tag, and the remap now guarantees the accepted plan uses
148
- the rail's tags, which is what Accept needs — but nobody has watched it.
149
- - **ComfyUI was never restarted this session.** The running instance has the
150
- *old* `planner.py` in `sys.modules`. Restart before any GUI check or you will
151
- be testing `495335d`.
152
-
153
- ### 3b. Your first job
154
-
155
- 1. LM Studio: context **32768**, reasoning **off**. Confirm both survived the
156
- load.
157
- 2. Restart ComfyUI. Hard-reload the tab. Console: `[HandTieClips] editor ui
158
- v1.5.3 loaded`.
159
- 3. Same rail as the session: `@ref_1 cafe_floral_9x16.jpg`,
160
- `@ref_2 h3_stress_kitchen_6.jpg`, `@ref_3 podcast_host.jpg`, all three left
161
- on the default `setting/prop` + `reference`, desc boxes empty. Brief: the
162
- user's own badly-written one (§5). Hops **2**, duration **7 s in the RUN
163
- panel**.
164
- 4. Expect: three correct `desc`, subject 1 with real `name`/`locked`/`context`,
165
- no *"filled from the photo descriptions"* warning.
166
- 5. **The open question is whether `4f756a7`'s prompt rule moves `@ref_3` to
167
- `partially_copy` on `shots: [1]`.** It is a model-behaviour question, not a
168
- code one. If it does not move, the lint fires and says so before Accept —
169
- that is the design, not a failure. Do not promote the lint to an error;
170
- `planner.validate` explains why (`errors` means the node would refuse the
171
- plan, and a retry rewrites the parts that were already right).
172
- 6. Then Accept, queue, and confirm `[HandTieClips] freed the writer`.
173
-
174
- ### 3c. The decision the user has not made
175
-
176
- `_stub_missing_subjects` fills `desc` with `"the reference photograph"` and a
177
- subject with `"the person"` / `"the same face and hair"`, then hands over a
178
- plan that *looks* filled. It is the only path in the writer that can put words
179
- the user never chose in front of the encoder.
180
-
181
- The user's stated principle: *"if it's bad, it's just a bad intent from the
182
- user — that's fine — but the guardrails must be as failsafe and protective of
183
- the intent as possible."* Silent filler is the opposite of that.
184
-
185
- `bbade17` should keep the stub from firing at all. The proposal on the table,
186
- **not yet approved**: mark stubbed fields visibly in the draft so an unread
187
- Accept cannot launder machine filler into a plan. Ask before building it.
188
-
189
- ---
190
-
191
- ## 4. Open, not the writer
192
-
193
- - **DEVLOG §31 is unwritten.** The three findings that belong in it: grammar
194
- beats prose (the byte-identical replies), the prompt-token breakdown, and
195
- the tag-remap cascade. Everything needed is in §2 and §5 of this file.
196
- - **`pin_renorm=band` A/B.** 3 hops, `control_after_generate=fixed`,
197
- `cache_hops=on`, `tools/texture_probe.py --box`. Baseline +4.2% mid-band per
198
- join. `sigma` is a no-op; `band` is the lever. Only pull `match_band` toward
199
- `tone.anchor_pull`'s 0.35 **if faces go waxy**.
200
- - **The system prompt is the biggest cost in the writer.** 5886 tokens, 74% of
201
- a 7929-token first turn, paid again on every repair turn. Nothing to do
202
- about it today — it is the shipped paste text and cutting it changes both
203
- routes at once — but it is the reason an 8192 context cannot work.
204
- - **First-frame crop box.** `MiniMaxH3AddGuide` centre-crops; a 9:16 still on a
205
- 16:9 canvas loses the sides. Noted, not planned.
206
- - **Push.** Nothing on `main` is on GitHub/HF except the old 0.4.5. If the push
207
- touches `docs/img/`: `git lfs push
208
- https://github.com/dntpi/ComfyUI-Hand-Tie-Clips.git main` **then**
209
- `git push origin main`. `origin` has two push URLs; LFS follows only the first.
210
- - **Version / publish.** Still 0.4.5. No `--token`.
211
-
212
- ---
213
-
214
- ## 5. Facts worth not re-deriving
215
-
216
- **Prompt token costs, measured on this machine against qwen3.8-27b:**
217
-
218
- | | tokens |
219
- |---|---|
220
- | `SYSTEM_PROMPT.md` | 5886 (74% of the first turn) |
221
- | 3 stills @ 1024 px | 1747 |
222
- | 3 stills @ 768 px | 1003 |
223
- | 3 stills @ 512 px | 451 |
224
- | 1 still @ 1024 px | 591 |
225
- | brief + rail rows | ~300 |
226
- | **first turn, 3 stills** | **7929** -> **8159** after `4f756a7` |
227
-
228
- Shrinking `VISION_SIDE` is **not** the lever it looks like. The stills are 22%
229
- of the turn; the system prompt is three and a half times their cost. At 512 px
230
- you save 1300 tokens and go back to guessing which picture is the face.
231
-
232
- - **A grammar outvotes prose.** Three byte-identical replies with the images
233
- and the errors in context. If a field is optional in the JSON schema, a
234
- repair turn asking for it will not produce it. `_tighten_schema` makes the
235
- missing fields structurally mandatory **for the repair turn only** — the
236
- shipped schema stays permissive on purpose, because a plan with no people in
237
- it is legitimate and `minProperties` on `subjects` would forbid it.
238
- `check_planner.py` pins the shipped schema's permissiveness so a later
239
- tightening of the wrong file fails there rather than in front of a user.
240
- - **The filename is the identity, not the tag.** A model that has looked at a
241
- picture names the row for what it saw. Accept writes back onto rail rows
242
- keyed by tag, so the remap is what makes the two survivable together.
243
- - **Text-only is a real diagnostic, not just a fallback.** Blind, the model
244
- confabulated `cafe_floral_9x16.jpg` into *"a wide view of a bright kitchen"*
245
- and promoted `podcast_host.jpg` to the likeness — a confident, well-formed,
246
- completely wrong register. Same schema, no eyes. `NIMG=0` reproduces it.
247
- - The user's test brief, verbatim: *"beautiful asian girl sitting on top of a
248
- kitchen counter talking about her day in earnest. 2 hops of 7 seconds. face
249
- image is the likeness of the subject. Full body image is the wardrobe and
250
- clothing of the subject"* — deliberately badly written, to test the
251
- guardrails rather than the prose.
252
- - **7 s is under the join canvas.** `CLAUDE.md` puts join validation at 2 x 8 s
253
- (`00028`); 5 s drops the airlock. Keep 7 s for writer testing, switch to 8 s
254
- before concluding anything about the join.
255
- - Pack media is **filenames under `<input>/h3_refs`**, not sockets.
256
- - `chain_salt` digests **tensors**. `voice_on` is an extra key field on this
257
- tree (why the 09-01 null check's keys differed and its pixels did not).
258
- - Hop meta `block` is stored as `block[:400]`. Do not reconstruct a beat from it.
259
- - ComfyUI execute is **async**. Never `asyncio.run` from `run()`; `llm._run_coro`
260
- is the side-thread escape.
261
- - 8-step turbo is the validation regime. Never report a 14-step result as one.
262
-
263
- ---
264
-
265
- ## 6. Also on disk (scratch, do not `rm`)
266
-
267
- - `D:\downloads\_nullcheck_A\` — A's lossless hops for the 09-01 null check
268
- - `D:\downloads\_nullcheck_A_prompt.json` — A's API graph
269
- - `D:\downloads\_queue_nullcheck_b*.py`, `_compare_*.py` — agent B scripts
270
- - `D:\downloads\drive_writer.py`, `measure_ctx.py` — this session's writer
271
- driver and the token measurements (§3a)
272
- - `D:\ComfyUI\output\HandTieClips\contact_sheet_00039_.png` — A's sheet
273
- - Six `.npy` scratch files in `D:\downloads\` from an older note; leave them
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
planner.py CHANGED
@@ -85,6 +85,48 @@ def beat_table():
85
  # words and measured 1.8 s of voiced audio.
86
  SPEECH_WPS = 2.5
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  # How much of a hop a speaking character should actually be speaking for. Below
89
  # this there are seconds of someone visibly mid-sentence with nothing assigned,
90
  # and the model fills them itself -- as fragments (chain_00059: 17.8% voiced,
@@ -322,17 +364,24 @@ def validate(shot_text, ref_text, *, hops=None, known_files=None, pinned=None,
322
  # twenty. This is the arithmetic, and it is worth stating outright
323
  # because the number the author needs is not the number of lines.
324
  secs = DURATION_FRAMES.get(label, 0) / float(FPS or 24)
 
325
  if spoken_words and secs:
326
- speech = spoken_words / SPEECH_WPS
 
 
 
 
 
 
 
327
  if speech < secs * SPEECH_MIN_SHARE:
328
  warnings.append(
329
- f"shot {i + 1}: {spoken_words} spoken words is about "
330
- f"{speech:.1f}s of speech in a {secs:.0f}s hop. A character "
331
- f"written as talking throughout needs roughly "
332
- f"{int(secs * SPEECH_WPS)}. The seconds left over are a "
333
- f"person visibly mid-sentence with nothing assigned, and "
334
- f"the model fills them itself. Give those seconds more "
335
- f"words, or name the sound they carry.")
336
  # Rule 3 inside a hop. A beat that opens on action and speaks later has
337
  # frames with a picture and no sound, and the model fills them with
338
  # dialogue nobody wrote -- the same failure as a silent hop, one
@@ -697,7 +746,11 @@ def build_user_turn(brief, hops, files, pinned=None, duration=None):
697
  f"roughly {int(secs * SPEECH_WPS)} words INSIDE the quotes -- "
698
  f"several sentences, not one. Count the spoken words too. "
699
  f"A hop where nobody speaks is fine, but say so: name the "
700
- f"sound the room makes instead.")
 
 
 
 
701
  lines.append(
702
  "If anything happens before the first spoken line -- walking "
703
  "in, sitting down, turning to the camera -- name the sound "
 
85
  # words and measured 1.8 s of voiced audio.
86
  SPEECH_WPS = 2.5
87
 
88
+ # Speech rate for scripts written in syllable blocks rather than space-delimited
89
+ # words -- Hangul, Han, Kana. Counting their whitespace tokens with SPEECH_WPS
90
+ # undercounts badly: a 27-syllable Korean line is 8 tokens, which the word rate
91
+ # calls 3.2 s against a real 4-5 s, and the "write roughly N words" target it
92
+ # derives would ask for about 85 syllables in a 10 s hop. ~5.5 syllables a
93
+ # second is the conversational figure across all three scripts; it is a
94
+ # reference figure, not something measured on this model.
95
+ SPEECH_SPS = 5.5
96
+
97
+ # Built from codepoints rather than written as an escape class so the source
98
+ # stays pure ASCII. Hangul jamo and syllables, kana, CJK ideographs, halfwidth
99
+ # kana.
100
+ _CJK_RANGES = ((0x1100, 0x11ff), (0x3040, 0x30ff), (0x3130, 0x318f),
101
+ (0x3400, 0x4dbf), (0x4e00, 0x9fff), (0xa960, 0xa97f),
102
+ (0xac00, 0xd7ff), (0xff66, 0xff9f))
103
+ _CJK = re.compile("[" + "".join(chr(a) + "-" + chr(b) for a, b in _CJK_RANGES) + "]")
104
+
105
+
106
+ def speech_seconds(text):
107
+ """Seconds of speech in `text`, counting each script in its own unit.
108
+
109
+ A plan may be written in any language -- the beats stay English prose, the
110
+ lines inside the quotes need not. Mixed text is counted both ways and
111
+ added, so an English beat with one Korean line is estimated correctly
112
+ rather than in whichever unit happens to dominate.
113
+ """
114
+ t = str(text or "")
115
+ cjk = len(_CJK.findall(t))
116
+ # Only tokens carrying a letter or digit. Stripping the CJK out of a
117
+ # Korean line leaves its punctuation stranded as free-standing tokens,
118
+ # and counting "!" and "." as two spoken words bought a 10 s hop most of
119
+ # a second it does not have.
120
+ rest = sum(1 for w in _CJK.sub(" ", t).split() if any(c.isalnum() for c in w))
121
+ return cjk / SPEECH_SPS + rest / SPEECH_WPS
122
+
123
+
124
+ def spoken_text(beat):
125
+ """Just the words inside the spoken spans, joined."""
126
+ t = str(beat or "")
127
+ return " ".join(t[a + 1:b] for a, b in spoken_spans(t))
128
+
129
+
130
  # How much of a hop a speaking character should actually be speaking for. Below
131
  # this there are seconds of someone visibly mid-sentence with nothing assigned,
132
  # and the model fills them itself -- as fragments (chain_00059: 17.8% voiced,
 
364
  # twenty. This is the arithmetic, and it is worth stating outright
365
  # because the number the author needs is not the number of lines.
366
  secs = DURATION_FRAMES.get(label, 0) / float(FPS or 24)
367
+ said = spoken_text((sh or {}).get("beat"))
368
  if spoken_words and secs:
369
+ speech = speech_seconds(said)
370
+ # Name the target in the unit the line is actually written in,
371
+ # because "roughly 25 words" of Korean is about 85 syllables and
372
+ # three times the hop.
373
+ if len(_CJK.findall(said)) > len(_CJK.sub(" ", said).split()):
374
+ target = f"{int(secs * SPEECH_SPS)} syllables"
375
+ else:
376
+ target = f"{int(secs * SPEECH_WPS)} words"
377
  if speech < secs * SPEECH_MIN_SHARE:
378
  warnings.append(
379
+ f"shot {i + 1}: the spoken lines run about {speech:.1f}s "
380
+ f"in a {secs:.0f}s hop. A character written as talking "
381
+ f"throughout needs roughly {target}. The seconds left over "
382
+ f"are a person visibly mid-sentence with nothing assigned, "
383
+ f"and the model fills them itself. Give those seconds more "
384
+ f"to say, or name the sound they carry.")
 
385
  # Rule 3 inside a hop. A beat that opens on action and speaks later has
386
  # frames with a picture and no sound, and the model fills them with
387
  # dialogue nobody wrote -- the same failure as a silent hop, one
 
746
  f"roughly {int(secs * SPEECH_WPS)} words INSIDE the quotes -- "
747
  f"several sentences, not one. Count the spoken words too. "
748
  f"A hop where nobody speaks is fine, but say so: name the "
749
+ f"sound the room makes instead. If the lines are in a script "
750
+ f"written in syllable blocks rather than spaced words -- "
751
+ f"Korean, Japanese, Chinese -- count syllables instead and "
752
+ f"aim for about "
753
+ f"{int(secs * SPEECH_SPS)}.")
754
  lines.append(
755
  "If anything happens before the first spoken line -- walking "
756
  "in, sitting down, turning to the camera -- name the sound "
tools/check_planner.py CHANGED
@@ -418,8 +418,49 @@ def main():
418
  ("twenty-five words is not", full, False)):
419
  _, ws = PL.validate(_talk(spoken), json.dumps(GOOD_REFS), hops=1,
420
  known_files=kf, duration="10 s")
421
- ck(label, any("spoken words is about" in w for w in ws) == want,
422
- "; ".join(w for w in ws if "spoken words" in w)[:110])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
423
 
424
  # Rule 3 applies INSIDE a hop. A beat that opens on action and speaks later
425
  # has frames with a picture and no sound, and the model fills them with
 
418
  ("twenty-five words is not", full, False)):
419
  _, ws = PL.validate(_talk(spoken), json.dumps(GOOD_REFS), hops=1,
420
  known_files=kf, duration="10 s")
421
+ ck(label, any("spoken lines run about" in w for w in ws) == want,
422
+ "; ".join(w for w in ws if "spoken lines run" in w)[:110])
423
+
424
+ # A plan may be written in any language; only the beats around the quotes
425
+ # have to be English. Counting a Korean line's whitespace tokens with the
426
+ # word rate called a real 4-5 s line 3.2 s, and the "roughly N words"
427
+ # target it derived asked for about 85 syllables in a 10 s hop. Live, on
428
+ # 2026-09-02: a 3-hop Korean vlog warned on every shot for being too thin
429
+ # when two of the three were fine.
430
+ KO = chr(0xd55c) + chr(0xad6d) + chr(0xc5d0) + chr(0xc11c) + chr(0xc758)
431
+ KO_LINE = KO + " " + chr(0xc2dc) + chr(0xac04) + chr(0xc740) # 8 syllables
432
+ ck("a CJK line is counted in syllables, not tokens",
433
+ abs(PL.speech_seconds(KO_LINE) - 8 / PL.SPEECH_SPS) < 0.01,
434
+ "%.2f" % PL.speech_seconds(KO_LINE))
435
+ ck("English still uses the word rate",
436
+ abs(PL.speech_seconds("one two three four five") - 5 / PL.SPEECH_WPS) < 0.01,
437
+ "%.2f" % PL.speech_seconds("one two three four five"))
438
+ ck("mixed text counts both scripts",
439
+ abs(PL.speech_seconds(KO_LINE + " and five more words here")
440
+ - (8 / PL.SPEECH_SPS + 5 / PL.SPEECH_WPS)) < 0.01,
441
+ "%.2f" % PL.speech_seconds(KO_LINE + " and five more words here"))
442
+ ck("stranded punctuation is not a spoken word",
443
+ PL.speech_seconds(KO_LINE + " ! . ?") == PL.speech_seconds(KO_LINE),
444
+ "%.2f vs %.2f" % (PL.speech_seconds(KO_LINE + " ! . ?"),
445
+ PL.speech_seconds(KO_LINE)))
446
+ ck("the word rate would have undercounted it",
447
+ len(KO_LINE.split()) / PL.SPEECH_WPS < PL.speech_seconds(KO_LINE),
448
+ "%.2f vs %.2f" % (len(KO_LINE.split()) / PL.SPEECH_WPS,
449
+ PL.speech_seconds(KO_LINE)))
450
+
451
+ # ...and the shortfall warning has to name the target in the unit the line
452
+ # is written in. "roughly 25 words" of Korean is three times the hop.
453
+ thin_ko = json.dumps({"shots": [
454
+ {"id": "s1", "beat": "@ref_1 walks and says, '" + KO
455
+ + "' She keeps going as the clip ends.",
456
+ "directives": {"tail": "hold"}}]})
457
+ _, ws = PL.validate(thin_ko, json.dumps(GOOD_REFS), hops=1,
458
+ known_files=kf, duration="10 s")
459
+ short = [w for w in ws if "spoken lines run about" in w]
460
+ ck("a thin CJK hop is still flagged", bool(short), "; ".join(ws)[:110])
461
+ ck("and its target is in syllables",
462
+ bool(short) and "syllables" in short[0],
463
+ short[0][:120] if short else "")
464
 
465
  # Rule 3 applies INSIDE a hop. A beat that opens on action and speaks later
466
  # has frames with a picture and no sound, and the model fills them with