fix: FramePump paces one frame per deadline slot — duplicate-on-lag, never skip (slice 15)

ty-1723/1726 device takes showed accelerated playback + audio tail cut-off:
a render overrun (~35ms vs the 16.6ms slot) SKIPPED the missed slots (slice 10's
freshness choice), so a 60fps-authoring pump wrote one frame per 35ms into a
60fps container — 1723: 697 frames/11.62s vs 11.84s audio; 1726: 163/2.72s vs
2.93s, video ending 0.21-0.24s early.

OBS never leaves a wall-time hole: the video thread emits one frame per tick and
a lagging producer DUPLICATES the newest frame ("lagged frames due to rendering
lag/stalls" — obs-output.c; "If the video frame queue is full, it will duplicate
the last frame" — docs.obsproject.com/backend-design). The pump's submit is now a
bounded catch-up over the missed slots (while now >= nextTick), fresh on the first,
repeated after — duration == wall, judder not fast-forward. Safe because Channel.
TryWrite never blocks (the take-9 smear was the blocking pipe-write; each emit is
nanoseconds). Burned frame index moved inside the loop: every emitted slot carries
its own +1 (also fixes the old unconditional pre-gate bump that gapped the judge
sequence on non-submitting fast-render iterations).

Good Dog test: Pump_Overrun_Renders_EmitsEverySlot_NotSkipped (60fps, 35ms render
cost, asserts >=0.65 of the wall slots emitted). 294/294 green, 0 warnings.

No push — web/A/V work is commit-local until greenlight.
This commit is contained in:
2026-09-14 17:40:59 -07:00
parent 394d86b94c
commit 76f51e6f4e
5 changed files with 176 additions and 95 deletions
+23
View File
@@ -996,6 +996,29 @@ Full suite 290/291 passing, the sole failure the pre-existing compositor pixel t
- Full suite **293/293 green, 0 warnings**. Audio untouched. **NOT YET VERIFIED ON DEVICE** — the
composition path needs a real 60fps-widget run (open item; see HANDOFF). Web work commits stay
LOCAL (no push) until the user greenlights.
- **Slice 15 — one frame per deadline SLOT: duplicate-on-lag, never skip (2026-09-14, device takes
ty-1723/1726):** slice 10's freshness choice — a render overrun VANISHES the missed slots from the
file — authored ACCELERATED playback: ~35ms render cost vs the 16.6ms slot, one fresh frame per
35ms (log wall-1726: `FramePump stall: iteration 33ms (> 2× the 17ms interval): worst render
33-46ms`), and a 60fps container muxed on arrival → 1723: 697 frames = 11.62s video vs 11.84s
audio; 1726: 163 frames = 2.72s vs 2.93s. The video also ended 0.21–0.24s before the audio ("audio
speeds up, cuts off at the end"). OBS's answer is duplicate-on-lag: `libobs/media-io/video-io.c`
emits one frame per tick and a lagger DUPLICATES the newest frame, counted as "lagged frames due
to rendering lag/stalls" (obs-output.c) — a wall-time hole never exists (docs.obsproject.com/
backend-design: "If the video frame queue is full, it will duplicate the last frame"). The pump's
submit is now a bounded catch-up: `while (now >= nextTick) { submit; nextTick += intervalTicks; }`
over a `deadlineNow` captured once per iteration — the LATEST composite, fresh on the first missed
slot, repeated (OBS's duplication) for the rest. Duration == wall under any render load, at the
cost of a short judder during a stall. The burst is safe because `Channel.TryWrite`
never blocks (slice 10's queue — the take-9 smear was the BLOCKING pipe-write re-copying a stale buffer
during a long freeze; here each emit is nanoseconds). The burned `_outputIndex` (slice 10 judge)
moved INSIDE the submit loop so every emitted slot carries its own +1 (and the old unconditional
pre-gate bump no longer gaps the sequence on non-submitting fast-render iterations). **Good Dog
test** `Pump_Overrun_Renders_EmitsEverySlot_NotSkipped`: 60fps, resolver sleeps 35ms, asserts ≥0.65
of the wall slots are emitted (the skip-pump wrote ~1/35ms ≈ 200 in 7s; the slot pump ~415). Full
suite **294/294 green, 0 warnings**. The +0.6s audio-late clap reading on 1726 was confounded by
the 1.1x acceleration — re-measure on device; if a real residual remains it is the audio pipeline.
No push (web/A/V work stays local).
- **Stop ordering matters:** `StopAsync` stops the encoder — since slice 10 it FLUSHES the pending
queue (`Channel.TryComplete` → drain writes the leftovers, closes stdin → EOF → ffmpeg finalizes+exits;
an accepted frame is never lost) — **before** awaiting the loop. The old reverse-order deadlock was