writer: count speech in the unit the line is written in
Browse filesFirst 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 +5 -0
- docs/DEVLOG.md +27 -0
- docs/HANDOVER_2026-09-01.md +0 -314
- docs/HANDOVER_2026-09-02.md +0 -186
- docs/HANDOVER_2026-09-02_2.md +0 -273
- planner.py +62 -9
- tools/check_planner.py +43 -2
|
@@ -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
|
|
@@ -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.
|
|
@@ -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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
if speech < secs * SPEECH_MIN_SHARE:
|
| 328 |
warnings.append(
|
| 329 |
-
f"shot {i + 1}:
|
| 330 |
-
f"
|
| 331 |
-
f"
|
| 332 |
-
f"
|
| 333 |
-
f"
|
| 334 |
-
f"
|
| 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 "
|
|
@@ -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
|
| 422 |
-
"; ".join(w for w in ws if "spoken
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|