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
+27 -3
View File
@@ -307,9 +307,9 @@ Both halves were solved by OBS/libyuv long ago; do not re-derive:
OBS's answering machinery is the encoder queue (`libobs/obs-encoder.c`): the encoder thread
NEVER couples back into the video thread; overflow = dropped data, NEVER a frozen producer.
Fixed as: bounded `Channel<byte[]>` (cap 120) + a dedicated drain task owning stdin,
`SubmitFrameAsync` = copy-to-pool-array + `TryWrite` (drop-newest + count when full), pump
emits ONE fresh composite per iteration (no burst re-write), stop flushes the queue then EOF.
**Rule: verify with a clock-independent judge.** The WSL ticker that "proved" slice 9 has its
`SubmitFrameAsync` = copy-to-pool-array + `TryWrite` (drop-newest + count when full), stop
flushes the queue then EOF. **Rule: verify with a clock-independent judge.** The WSL ticker that
"proved" slice 9 has its
own Host-timer jitter under Windows load — so this slice burns a dot-matrix `_outputIndex`
into the bottom-right of every composite; decoding the recording reads the honest sequence
(+1/frame, jumps = counted drops) with no external clock involved. Take 17 must read
@@ -317,6 +317,30 @@ Both halves were solved by OBS/libyuv long ago; do not re-derive:
UNRELIABLE here: the scene is always animating — session elapsed timer + REC pulse — so
no two frames are ever byte-identical.)
**SUPERSEDED by slice 15 (2026-09-14):** the last clause "pump emits ONE fresh composite per
iteration (no burst re-write)" had it BACKWARDS for the deadline. Slice 10 chose a skip when
the render overruns — an overrun's slots vanish from the file — and that AUTHORS ACCELERATED
playback (my item-1 lesson already said it: "NEVER a skip … a late render shows as REPEATED
footage"). Device takes proved it: one fresh frame per 35ms stall → `ty-20260914-1726` = 163
video frames (2.72s) against 2.93s of audio, video ending 0.22s early ("audio speeds up then
cuts off"). OBS's answer (docs.obsproject.com/backend-design: "If the video frame queue is
full, it will duplicate the last frame"; `libobs/obs-output.c` counts "lagged frames due to
rendering lag/stalls" — never a time-hole) is to fill each missed slot by DUPLICATING the
newest frame; duration == wall, judder not fast-forward. That's what the pump's submit now
does: a catch-up loop over the missed slots emitting this iteration's composite again, clamped
to a `deadlineNow` captured once (bounded — the smear that justified slice 10 was the
BLOCKING pipe-write re-copying during a long freeze; the queue makes each emit nanoseconds,
so the burst is safe). The burned index moved inside the submit loop: EVERY emitted slot
carries its own +1 (this also fixed the old unconditional `_outputIndex++` that gapped the
sequence on non-submitting fast-render iterations). A/V sync must be re-clap-measured after
this fix — the +0.6s audio-late reading on 1726 was confounded by the 1.1x acceleration.
**Latest A/V sync numbers (2026-09-14, pre-slice-15 pacing):** keep the recipe below honest on
clap measures. ty-1723: 697 video frames (60fps) = 11.62s vs 11.84s audio. ty-1726 (clap): 163
frames = 2.72s vs 2.93s audio; clap audio env peak 1.655s vs video motion cluster 0.75–1.03s →
single-peak offset ≈ +0.64–0.89s, cross-correlation lag +0.667s (audio late) — BOTH confounded by
the acceleration; re-measure after slice 15.
**Take-4 follow-ups (2026-09-04) — the symptom needed a second pass, so cite again:**
render was still 58.9ms after slice 1. Slice 2 (buffer pool + opaque-row memcpy +