Fix the real A/V gap: a stream copy across mixed frame rates

The rebuild after 1457556 came out byte-identical to the broken file,
which proved the xfade fix never runs for this chapter. An all-cut chapter
goes down the concat demuxer with -c copy, which writes the output in the
FIRST input's time_base and reinterprets every later packet in it. 14 of
49 clips are 30/1 at 1/15360 against 35 at 25/1 at 1/12800, so those 14
play 15360/12800 = 1.2 too long with their audio untouched. collage_cmd
hardcoded -r 30 and yesterday's FPS sweep missed it.

collage_cmd now emits -r FPS, and assemble probes r_frame_rate across the
clips and routes mixed rates through the re-encoding tree. Rebuilt
chapter.mp4 is 364.120s video against 364.122s audio at 25/1, from
436.392 over 363.675.

Also settle the bbox coordinate space, measured over all 113 detections:
47 boxes have x2 past the 900px panel width, none has y2 past 1000 on
panels up to 2307px tall, and the range is exactly [0, 1000]. It is
gemma's normalized grid, not pixels, whatever the prompt asks for.
/vision converts before returning, which fixes identity's crop, the gated
face pairing that was comparing pixel face boxes against grid boxes, the
set-of-mark boxes and the review UI at once. Checked by eye on panel 7:
five of six boxes now land on their subject, including the foreground
character who had no identity.

The registry still holds boxes and embeddings enrolled from the wrong
space. vision and identity have to re-run, which is GPU work and was not
started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 12:16:22 +04:00
parent 1457556ce3
commit 8113bdfc8b
12 changed files with 329 additions and 285 deletions
+97 -258
View File
@@ -1,291 +1,130 @@
# HANDOFF, 2026-08-12
# HANDOFF, 2026-08-12 (second session of the day)
Live state is in `NEXT.md`. This file is only what this session did.
Live state is in `NEXT.md`. This file is only what this session did. The previous handoff's content is
now in `JOURNAL.md` and in `decisions/`.
## Asked
Get up to speed from the previous handoff. Then: the user watched `chapter.mp4` for the first time and
read out 19 timestamped defects. Mid-session, what about characters. Then, write the handoff.
Get up to speed from the previous handoff. Then: rebuild the chapter, and what about characters.
## Result
**Somebody finally watched the video.** That single act found more than four sessions of measuring did.
The recorded metrics said `script` 116/116 and "9 named speech lines". Both were true. Both measured the
wrong thing, because the 9 names are the *wrong* name.
Two things landed. The A/V gap is fixed and the rebuilt `chapter.mp4` is in the bucket. The `bbox`
coordinate space is settled with measurements instead of comments, and converted at the boundary.
Two hard numbers came out of it.
One correction to carry forward. **The first fix of the day named the wrong cause.** Commit `1457556`
claimed the xfade offset drift was the shipped 72.7s gap. The rebuild came out byte-identical to the
broken file, which disproved it. Both are real defects. Only the second one shipped.
1. **The chapter is 20% out of sync.** `chapter.mp4` is video 436.39s over audio 363.67s. The narration
ends 72.7 seconds before the picture. The gap accumulates, which is why everything after 2:54 goes
sideways. The 49 clips are clean: video and audio agree to 0.03s and sum to 363.6s. Assembly adds
72.7s of video and no audio.
2. **Identity binds names to the wrong people, and to people who have no name.** Walking panel 7 against
the art found zero correct bindings out of two, plus the one character who matters left unbound. See
`Panel 7, walked against the art`, which supersedes the earlier reading of this.
## The chapter, rebuilt
Nothing is committed. `worker_render.py` is edited in the working tree. The fix is **not** verified.
## The user's 19 notes, grouped by cause
| cause | timestamps | what is wrong |
| --- | --- | --- |
| one row absorbed every identity | 0:20, 1:07, 1:51, 1:59, end | anyone identified comes out `Choi Haeseon` |
| the MC has no name | 0:44, 0:50, 1:02, 1:23, 1:45 | falls back to "the worker", "someone", "she" |
| gender read off the wrongly bound row | 1:45 "she admits", end "as he waves" | `Choi` is `f`, so "he" means a nameless `m` row got the line |
| narration invents facts | 0:43 "results", 2:03, 2:05, 2:15 "long shift" | the verifier checks quotes and names, not invented claims |
| vision reads art-within-art as scene | 1:35 chibi on a monitor as "a man holding a drink", 1:59 "pointing towards the screen" | panel-in-panel and screen content taken as reality |
| a beat carries nothing | 0:35-0:37 | no content worth narrating |
| no parallax, so a still holds | 2:24-2:52, 28s static | `layers` wrote nothing (`caveats/audit-open.md#layers-writes-nothing`) |
| transition quality | 2:52-2:54 slide "too sharp and laggy" | `push` is `slideleft` at 0.4s. Retest after the sync fix |
| A/V drift | everything after 2:54 | the 72.7s gap above |
## Panel 7, walked against the art
This is the load-bearing finding of the session. The user pulled up the panel and checked every
detection by eye. **Read this before touching identity.** It contradicts what the earlier sessions
recorded, and it contradicts two theories I floated today before the user corrected them.
Panel `7c944dd4-e972-42c7-ba60-9f6939548e80_p007`, crop `s3://panels/.../panels/p006.png`, 900x1650.
A wide establishing shot of an office seen through a window. Vision emitted 6 characters.
| detection | vision said | the art shows | identity assigned |
| --- | --- | --- | --- |
| `person_5` | m, short black, **yellow sweater**, sitting | **Seonho**, foreground, yellow plaid, headphones, back to camera. The character who matters | **nothing** |
| `person_6` | f, short brown, **white shirt**, sitting | the **colleague**, green dress, ponytail. She has **no name** in the story | `Choi Haeseon` at **0.9** |
| `person_2` | m, short brown, green sweater, sitting | a background extra, seated beyond the next window pane | `Lim Seonho` at **0.9** |
| `person_1` | m, short black, suit, standing | **nobody. A window frame** | nothing |
| `person_3` | m, short black, blue sweater, standing | background extra | nothing |
| `person_4` | m, short black, grey sweater, standing | background extra | nothing |
**Zero of the two bindings are right, and the one character who matters got nothing.** Both wrong binds
carry confidence 0.9.
Three separate defects stack here.
**1. The stored bbox coordinate space is wrong.** Consumed as absolute pixels, all six boxes land in the
top third of a 1650px-tall panel, two of them inside the "YEAH!" speech balloon. Divided by 1000 against
the panel's own dimensions, `person_2`, `person_3`, `person_4` and `person_5` fit their subjects tightly.
So the numbers are not pixels. Two places assert that they are:
- `worker_vision.py:271` prompt text: `pixel bounding box [x1,y1,x2,y2] (top-left, bottom-right corners)`
- `worker_identity.py:91` comment: `vision emits [x1, y1, x2, y2] pixel corners (gemma4's native bbox convention)`
Everything reading `bbox` is therefore cropping the wrong region. `worker_identity.py:200` embeds
`_crop_bbox(img, ch["bbox"])`, so `Choi Haeseon` at 0.9 was matched on a crop of the speech balloon's
edge and `Lim Seonho` at 0.9 on a crop of empty window frame. Wrong crops are mostly blank white, which
embed alike, which is a plausible mechanism for one row absorbing 25 assignments.
**Do not treat rescaling as the fix.** I claimed that and the user disproved it in one screenshot.
After scaling, `person_1` still sits on a window frame with nobody in it, and `person_6` is offset,
clipping the woman and running onto the dark frame. `worker_vision.py:38` already calls the box
"coarse, imprecise". Scaling buys roughly-right boxes, not right ones.
**2. Vision has no concept of extra versus cast.** Four of the six detections are background extras or
nothing at all. They are handed to identity as candidates on equal footing with the two people who
carry the scene. That also means the "113 detected people" figure that framed the whole roadmap was
never the right denominator, so "26 of 113 carry an identity" measured nothing useful.
**3. Identity mints a name onto a person who has none.** The colleague has no name in the story. She was
labelled `Choi Haeseon` at 0.9. Across the chapter `Choi Haeseon` holds 25 of 26 assignments, so in
practice that row is the label this pipeline stamps on any unnamed woman. This is the direct cause of
the user's 0:20 note, "Choi Haeseon when there's no Choi in the frame, it's the colleague", and of the
gender flips, since gender is read off whichever row got bound.
This is a cousin of invariant 6 in `CLAUDE.md`, which forbids minting a character from an unparseable
model answer. The missing rule is the same shape: **never attach a name to a detection that carries no
name evidence.** An unnamed recurring person needs a stable anonymous identity so the narration can
call her "the colleague" every time, rather than being forced onto a named row.
Partly checked, not finished: `match()` in `worker_identity.py:69` does abstain, returning `None` below
threshold, so the 0.9 came from cosine clearing the threshold on a garbage crop. Whether the Tier-2
gemma resolver can answer "none of these" was not verified. Check that first.
### Descriptions are not trustworthy either
`person_6` is "white shirt" for a woman in a green dress. `person_5` is "yellow sweater" for yellow
plaid, which is close enough. Any downstream rule keyed on appearance text inherits this.
## Registry, measured
`characters` is keyed by `manga_id`, not chapter. Two manga share the table. `d7104032` has 34 rows with
9 named. This chapter's `ef105a86` has 19 rows with 3 named. The `Kei`, `Zen`, `Kanade`, `Rico` and `K3`
rows belong to the other manga, so they are not polluting this chapter.
This manga's 19 rows, all `status=confirmed`, all `first_seen_panel=NULL`:
- named: `Choi Haeseon` (f), `Seonho` (m, aliases `["Lim Seonho","Seonho"]`), `Lim Seonho` (m)
- 16 rows with `name=NULL` and an empty alias list
`Seonho` and `Lim Seonho` are the same person in two rows. `Seonho`'s alias list contains the other
row's name. That is why either spelling matches two rows and binds nothing.
Assignments across the whole table, for scale:
| character | name | assignments |
| --- | --- | --- |
| `character_afa7623b` | Choi Haeseon | 25 |
| `character_bb79cfb4` | Kanade | 25 |
| `character_cfd34340` | Rico | 19 |
| `character_6f491712` | Lim Seonho | 1 |
In this chapter only `Choi Haeseon` (25) and `Lim Seonho` (1) appear.
Read those 25 together with panel 7. `Choi Haeseon` is not a character who appears 25 times. It is the
row that absorbs any unnamed woman. The 16 nameless rows are not a backlog of people waiting for names.
Some of them are background extras that should never have become rows, and at least one of them, the
colleague, is a real recurring person who correctly has no name and needs to keep it.
An earlier draft of this file said "naming is the ceiling, 16 of 19 rows need names". **That was wrong**
and it is corrected here. The ceiling is that identity cannot say "person, no name" and cannot tell an
extra from cast.
## The A/V bug, located but not fixed
Reproduced on the 49 real clips with `repro.py` and `probe.py` (see Open). Per-round probe with
`ASSEMBLE_BATCH=8` and 6 `fade_black` boundaries spread across batches:
`s3://video/ef105a86-4b7e-4ac4-b45c-b7d83b8f5b5e/7c944dd4-e972-42c7-ba60-9f6939548e80/chapter.mp4`
```
r0 g0 n=8 XFADE in v= 49.44 a= 49.44 -> out v= 48.56 a= 48.64 lost_v=+0.88 lost_a=+0.80
r0 g1 n=8 XFADE in v= 47.04 a= 47.04 -> out v= 46.16 a= 46.23 lost_v=+0.88 lost_a=+0.81
r0 g2 n=8 XFADE in v= 48.00 a= 48.00 -> out v= 47.12 a= 47.19 lost_v=+0.88 lost_a=+0.81
r0 g3 n=8 XFADE in v=100.80 a=100.80 -> out v= 99.92 a= 99.96 lost_v=+0.88 lost_a=+0.84
r0 g4 n=8 concat in v= 64.18 a= 64.16 -> out v= 64.24 a= 64.26 lost_v=-0.06 lost_a=-0.10
r0 g5 n=8 XFADE in v= 44.00 a= 44.00 -> out v= 43.12 a= 43.17 lost_v=+0.88 lost_a=+0.83
r1 g0 n=7 XFADE in v=359.29 a=359.60 -> out v= 99.96 a=358.79 lost_v=+259.33 lost_a=+0.81
before v=436.392 a=363.675 nb_frames=9902 avg_frame_rate=22.69 gap +72.72
after v=364.120 a=364.122 nb_frames=9101 r_frame_rate=25/1 gap -0.002
```
Round 0 is correct. Each group loses only the xfade overlap. **Round 1 loses 259s of video against 0.8s
of audio.** Its output video is 99.96s at 2499 frames, almost exactly the frame count of input `n3` on
its own, the 99.92s intermediate. The final video appears to carry the frames of one input.
Cause: `assemble` sends an all-`cut` chapter down a `concat` demuxer with `-c copy`. That path writes
the output in the **first** input's `time_base` and reinterprets every later packet in it. 14 of the 49 clips
are `30/1` at `1/15360`, the other 35 are `25/1` at `1/12800`, so those 14 play `15360/12800 = 1.2` too
long with their audio untouched. `collage_cmd` hardcoded `-r 30`.
`decisions/chapter-assembly.md#mixed-rate-stream-copy`.
The round-1 filtergraph is arithmetically correct, so this is ffmpeg behaviour, not offset math:
Fixes: `collage_cmd` emits `-r FPS`. `assemble` probes `_fps_of` across the clips and routes mixed rates
through `_assemble_batched`, whose branches both normalize with `fps={FPS}`.
A second, latent defect on the transition path was fixed and committed separately. `_xfade_chain` took
offsets from `format=duration`, which is `max(video, audio)`. The accumulator crept past the end of its
input, and ffmpeg discarded whole clips at `rc 0` with nothing on stderr.
`decisions/chapter-assembly.md#offsets-from-min-stream`.
The single-item passthrough theory from the previous handoff is dead, recorded void at
`decisions/chapter-assembly.md#passthrough-innocent`. The one-path rewrite it recommended is not needed.
**Still open here.** The 14 clips in the bucket are still 30fps. Assembly normalizes them, so the chapter
is correct, but the fast stream-copy path stays off for this chapter until `render` re-runs. Nobody has
watched the rebuilt video yet. The 2:52 slide transition and the 28s static hold from 2:24 were both
supposed to be re-judged after the sync fix.
## Characters: the `bbox` space, settled
All 113 detections, straight from `/review/identity`:
| test | result |
| --- | --- |
| boxes with `x2` past the 900px panel width | **47 of 113** |
| boxes with `y2` past 1000, on panels 1257 to 2307px tall | **0 of 113** |
| boxes clamped at exactly 1000 | 21 in x, 5 in y |
| coordinate range over every box | `[0, 1000]` |
Gemma's native 0-1000 grid. Not pixels. `worker_vision.py` prompt text and the old
`worker_identity.py:91` comment both claimed pixels and both were wrong.
`/vision` now calls `_bbox_to_pixels(characters, w, h)` before returning. Four consumers are fixed at
once: `_crop_bbox` in identity, `_pair_faces_to_present`, the set-of-mark boxes, and the review UI's
client-side crop. The pairing one was comparing real pixel face boxes against 0-1000 character boxes,
which is the likely mechanism behind 7 `unknown` out of 7 `som_face` lines.
`decisions/identity-bbox.md#bbox-is-normalized`.
Checked by eye on panel 7, not just asserted. Five of six converted boxes land on their subject. That
includes `person_5`, who is Seonho in the foreground with headphones and carried no identity. `person_1`
still frames an empty window mullion, which is `caveats/speaker-attribution.md#extras-as-cast`.
Converted boxes for panel 7, for whoever redraws the overlay:
```
[n0][n1]xfade=transition=fade:duration=0.050:offset=48.510[v1]
[v1][n2]xfade=transition=fade:duration=0.050:offset=94.620[v2]
[v2][n3]xfade=transition=fadeblack:duration=0.600:offset=141.140[v3]
[v3][n4]xfade=transition=fade:duration=0.050:offset=241.010[v4]
[v4][n5]xfade=transition=fade:duration=0.050:offset=305.200[v5]
[v5][n6]xfade=transition=fade:duration=0.050:offset=348.270[v6]
person_1 [226, 414, 286, 553] window mullion, nobody
person_2 [ 34, 558, 106, 749] background extra, was assigned Lim Seonho
person_3 [428, 384, 494, 533] background extra
person_4 [498, 389, 561, 549] background extra
person_5 [460, 657, 631, 939] Seonho, foreground. was assigned nothing
person_6 [646, 591, 767, 794] the colleague, no name in the story. was assigned Choi Haeseon at 0.9
```
### The strongest clue, found last
**The registry is unchanged and still wrong.** Every stored box, embedding and `ref_image_uris` was
enrolled from the wrong space. `vision` and `identity` have to re-run before any of it means anything,
and that is GPU work nobody authorized. `caveats/speaker-attribution.md#bbox-wrong-space` is marked
resolved with the rerun pending.
Re-running that chain by hand over only the **6** round-0 intermediates gives a correct 348.24s at 8708
frames, `rc 0`, no warnings. Round 1 collapses with **7** inputs, not 6.
## Checks
The 7th input is the leftover 49th clip. With 49 clips and batch 8, round 0 makes 6 groups of 8 and one
group of 1, and `_assemble_batched` passes a lone group through un-encoded:
Every self-check runs from the repo root and passes:
```python
if len(group) == 1 and not final_round:
next_items.append(group[0])
```bash
.venv/bin/python worker_render.py # about 4 minutes, real ffmpeg
.venv/bin/python worker_vision.py
.venv/bin/python worker_identity.py
```
So the final xfade mixes 6 encoded intermediates with 1 raw clip. That passthrough is a **third** path
next to `concat` and `xfade`, and it is the prime suspect. Start here tomorrow. Confirm it by running
`probe.py` with 48 clips instead of 49, which removes the leftover entirely.
Three checks were added, because the existing ones passed all the way through both shipped defects:
### What was tried and what it cost
- `_fps_of(collage clip) == "25/1"`, on a real collage encode. This is the one that would have caught the
mixed-rate bug at the source.
- three clips whose audio outlasts their video by 0.4s, assembled through the xfade branch. Mutation
tested by restoring `_audio_dur`: fires with `video=1.80 audio=3.56 expected=3.56`.
- `_bbox_to_pixels` against panel 7's real `person_5` box, asserting the result covers the lower half of a
1650px panel, which a raw grid value cannot.
Two paths exist in `_assemble_once`. A `concat` branch handles cut-only batches. An `xfade` branch
handles batches with a real transition. They disagreed on frame rate. The concat branch forced `fps=30`
while the xfade branch normalized nothing, and clips are 25fps. `436.39 / 363.63 = 1.2001`, exactly
`30/25`, which is what sent me down this path.
`_check_assembled` now runs after every encode on both paths, because ffmpeg returns 0 while dropping
whole inputs.
Working-tree changes to `worker_render.py`, all uncommitted:
## Next command
- new `FPS = 25` constant. The three hardcoded `25`s and the one `30` now reference it
- the xfade branch normalizes every input with `setsar=1,fps={FPS}` into `[n{i}]` labels, matching what
the concat branch already did
- new `_stream_dur(path, kind)`. `_audio_dur` probes `format=duration`, which is `max(video, audio)`, so
it hides A/V drift by construction
- the `__main__` xfade self-check now assembles 4 clips through `_assemble_batched` with
`ASSEMBLE_BATCH=2` and asserts `abs(video - audio) < 0.25`. It previously asserted only
`getsize(out) > 0`, which is why this shipped
Watch the rebuilt chapter before anything else. That is what found every real defect so far.
**A dead end worth not repeating.** I also pinned `-r FPS` on both output encodes. That made it worse.
The chapter collapsed to exactly 100.00s at 2500 frames, because forcing CFR on irregular input
timestamps drops frames. The comment already sitting at the concat branch warns about this. Both `-r`
flags were removed again. The in-graph `fps=` filter is the right normalization. The output `-r` is not.
```bash
/usr/bin/ssh kami@192.168.1.104 'mc cat homesrv/video/ef105a86-4b7e-4ac4-b45c-b7d83b8f5b5e/7c944dd4-e972-42c7-ba60-9f6939548e80/chapter.mp4' > chapter.mp4
```
The fps inconsistency is real and worth keeping fixed. It is **not** proven to be the cause of the
shipped 72.7s gap. The scene graphs hold 356 `cut` against 6 `fade_black`, so round 2 of the real run
most likely stayed on the concat branch, where no mixing occurs. Treat the fps work as necessary and
insufficient.
Then, with a go-ahead, the vision and identity rerun in `NEXT.md` item 1.
### The user's own hypothesis, which is the recommended direction
## Traps confirmed again this session
Two guesses, both worth following:
1. the assembly is wrong in an ffmpeg sense
2. there are two different paths, and they get mixed when there should only be one
Guess 2 matches the code. `concat`, `xfade` and the single-item passthrough are three paths, and
`_assemble_batched` feeds the output of one into the input of another. **Collapse it to one path.**
Normalize every input, then xfade every boundary, with `cut` as a 0.05s fade. `acrossfade` shortens
audio by the same amount that xfade shortens video, so A/V stays locked. The chapter then comes out
about 2.4s shorter than the sum of the clips, with both streams agreeing. That removes the branch
interaction instead of tuning it.
## Measured
Job `778297bc-e7ce-439d-91b5-8a027060d17f`, chapter `7c944dd4-e972-42c7-ba60-9f6939548e80`.
- job `status=completed`, every stage at its unit count, finished `2026-08-11T20:08:16Z`
- it still carries `error: "partial: 112/116 completed"` (`caveats/audit-open.md#stale-job-error`)
- `chapter.mp4` 50MiB, video 436.392s, audio 363.675s, `r_frame_rate=25/1`,
`avg_frame_rate=63372800/2792909` which is 22.69, `nb_frames=9902`
- 49 clips, every one `25/1` exactly, sum video 363.63s, sum audio 363.60s. No clip has the two
differing by more than 0.05s
- scene-graph transitions: `cut` 356, `fade_black` 6
- `worker_render.py` `__main__` self-check passes on the current working tree
- workers up in tmux `manga-workers`, 9 windows
## Open
- **Finish the round-1 diagnosis.** The `_assemble_batched` tree turns 359s of video into 100s. This is
the worst defect found and it reproduces offline in about two minutes with no GPU. Suspect the 7th
passthrough input first.
```bash
.venv/bin/python <scratchpad>/probe.py # the per-round loss table above
.venv/bin/python <scratchpad>/repro.py # end-to-end verdict
```
The scratchpad is session-scoped and will be gone. **Re-download the clips first:**
```bash
/usr/bin/ssh kami@192.168.1.104 'P=homesrv/video/ef105a86-4b7e-4ac4-b45c-b7d83b8f5b5e/7c944dd4-e972-42c7-ba60-9f6939548e80; mc cp -q -r $P/clips/ /tmp/rclips/; cd /tmp/rclips && tar cf - .' | tar xf - -C clips/
```
Both scripts are worth committing next session. They are the only check that has ever caught this.
- **Do not trust `format=duration`.** It returns `max(video, audio)`, so every existing duration assert
in `worker_render.py` is blind to drift. `_stream_dur` exists now. The other asserts still use
`_audio_dur`.
- **Identity, in the order the panel 7 evidence implies.** First, settle the `bbox` coordinate space and
fix every consumer, since nothing else can be judged while crops are wrong. Second, let identity
abstain and hold a stable anonymous identity, so the colleague stays "the colleague". Third, separate
extra from cast so extras never reach identity. Only then merge `Seonho` into `Lim Seonho` and split
`character_afa7623b`, which still needs the reversible-merge design
(`caveats/audit-open.md#destructive-reconcile`).
- **Verify the bbox space before changing anything.** Two independent claims in the code say pixels, and
the art says otherwise. Confirm what the model was told and what it returns, rather than trusting
either comment. Then check whether the crop is the only consumer, or whether SoM marker placement and
the face-pairing in `worker_vision.py:57` read the same numbers.
- Re-derive the panel 7 overlay when needed. It took one `mc cat` of the crop plus a Pillow script, and
it found more than any query did:
```bash
/usr/bin/ssh kami@192.168.1.104 'mc cat homesrv/panels/ef105a86-4b7e-4ac4-b45c-b7d83b8f5b5e/7c944dd4-e972-42c7-ba60-9f6939548e80/panels/p006.png' > p007.png
```
Draw each `bbox` twice, once as pixels and once divided by 1000, then look at it.
- `mc` aliases on homesrv: use `homesrv` or `mio`, not `local`, which returns Access Denied. `rfs` is
the empty rustfs.
- `tmux` session `manga-workers` was gone and both systemd units were inactive. `./start_workers.sh`
starts 9 windows. The render worker is window 9 and must be restarted by hand to pick up an edit.
- Plain `ssh` is the kitty ssh kitten and refuses non-interactive stdin. Use `/usr/bin/ssh`.
- `cp` is aliased to `cp -i` in this shell and hangs on overwrite. Use `/usr/bin/cp -f`.
- The Bash tool's default timeout is 120s no matter what `timeout` the command itself carries. Pass the
tool's own timeout or background the run. Otherwise a restore step after a mutation test never runs,
which left a deliberately broken `worker_render.py` on disk once this session.
- `cd $dir && .venv/bin/python` fails, because the venv path is relative to the repo root. Use the
absolute interpreter path when the working directory is elsewhere.
- The whole assembly investigation ran offline on 49 downloaded clips with no GPU and no orchestrator.
Re-download with the command in `NEXT.md`.