Fix the 72.7s A/V gap: xfade offsets ran off the end of their input

_xfade_chain positioned every transition using _audio_dur, which probes
format=duration, which is max(video, audio). A clip's audio outlasts its
video by about a frame, so the offset accumulator crept ahead of the real
picture timeline. Once the creep exceeded the transition width, xfade
emitted the transition and silently discarded the second input and every
clip downstream, exiting 0 with nothing on stderr. That is the whole of
the shipped chapter's 436.39s of video over 363.67s of audio.

Offsets now come from min(video, audio). Every input is floored to a
whole frame count and trimmed on both streams, so the accumulator tracks
the real timeline instead of estimating it. _check_assembled verifies
each encode against the predicted length and against its own audio,
because both assembly branches drop stream time without failing.

Verified over the 49 real clips of chapter 7c944dd4: the round that
turned 359s of video into 100s now loses 0.85s, and the chapter comes out
358.76s video against 358.76s audio.

The single-item passthrough was not the cause. Two round-0 groups of 8
fresh clips collapse without one, recorded void in decisions/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 11:52:38 +04:00
parent a9d64fe80a
commit 1457556ce3
6 changed files with 206 additions and 20 deletions
+15 -12
View File
@@ -5,6 +5,7 @@ Updated 2026-08-12. What this session did is in `HANDOFF.md`.
## State
The chapter runs end to end and the output is **not watchable**. That is now measured, not guessed.
Assembly is fixed and verified offline. The shipped `chapter.mp4` has not been rebuilt yet.
Job `778297bc-e7ce-439d-91b5-8a027060d17f`, chapter `7c944dd4-e972-42c7-ba60-9f6939548e80`, 116 panels,
`status=completed`, finished 2026-08-11T20:08:16Z. `s3://video/` holds 49 clips and a 50MiB
@@ -12,23 +13,25 @@ Job `778297bc-e7ce-439d-91b5-8a027060d17f`, chapter `7c944dd4-e972-42c7-ba60-9f6
Two numbers set the agenda:
- `chapter.mp4` is video 436.39s over audio 363.67s. The narration finishes 72.7s before the picture.
- The shipped `chapter.mp4` is video 436.39s over audio 363.67s. Cause found and fixed, see below.
- Panel 7 checked against the art has **zero correct identity bindings** out of two, and the one
character who matters is unbound. `HANDOFF.md#panel-7-walked-against-the-art` has the table.
Uncommitted work sits in the tree: `worker_render.py` has an `FPS = 25` constant, fps normalization in
the xfade branch, a new `_stream_dur`, and a self-check that compares video against audio. The
self-check passes. It does **not** yet fix the chapter. Details and one dead end in `HANDOFF.md`.
## Next
1. **Fix chapter assembly.** `_assemble_batched` turns 359s of video into 100s while the audio survives.
It reproduces offline in two minutes, no GPU. Round 0 is correct and round 1 collapses. Round 1 is
the only round holding a raw clip that skipped encoding, so suspect the single-item passthrough
first. The recommended shape is one path, not three: normalize every input, then xfade every
boundary, treating `cut` as a 0.05s fade. `acrossfade` and `xfade` shorten audio and video equally,
so the streams stay locked. `HANDOFF.md` holds the per-round table, the filtergraph, and the repro
commands. Nothing downstream is worth judging until this lands.
1. **Rebuild `chapter.mp4`.** Assembly is fixed in `worker_render.py`. Verified over the 49 real clips of
this chapter: video 358.76s against audio 358.76s, agreeing to the frame. The cause was `_xfade_chain`
taking offsets from `format=duration`, which is `max(video, audio)`. The accumulator drifted past the
end of its input, and ffmpeg silently discarded whole clips at `rc 0`
(`decisions/chapter-assembly.md#offsets-from-min-stream`). The single-item passthrough was innocent
and the one-path rewrite is not needed (`decisions/chapter-assembly.md#passthrough-innocent`).
What is left is to clear the `assemble` stage and resume, then watch the result. That is CPU-only
ffmpeg, no GPU, but it needs the user's go-ahead.
Smaller follow-on: nine other `_audio_dur` calls in `worker_render.py` measure finished clips with
`format=duration`. So the durations reported to the orchestrator are blind to per-clip drift.
They position no filter, so invariant 9 does not cover them. Worth converting to `_stream_dur`.
2. **Fix identity, in this order.** Panel 7 is the worked example and
`HANDOFF.md#panel-7-walked-against-the-art` carries the evidence. Do not start at the registry.