fix(rec): recordings played ~1.4x fast — CFR emission, drop -re

Root cause (measured, not guessed): the take-3 'rebase on overrun' reset
nextTick to wall-now, erasing every missed slot. Pump delivered 215-219/300
per 5s (~43fps) but rawvideo carries no timestamps — ffmpeg muxes by frame
count at -framerate 60, so every recording played fast with honest-looking
stats. A second pacer, ffmpeg -re, throttled the demux separately
('Resumed reading ... after a lag' 0.79s->4.82s).

Fix follows libobs video-io.c (https://github.com/obsproject/obs-studio)
- the video thread never resets its deadline; one frame per interval slot,
a late render repeats content (judder), never skips time:
1. FramePump: count-based emission, while(now>=nextTick){Submit; nextTick+=I}
2. FfmpegArgs: -re removed - the pump is the pacer

Muxed duration is now frame-count/fps == wall time by construction. Covers
game background + webcam (one shared pump). 0 warnings; suite 288/289 -
the one failure (Composite_FullScene_MasterPixels 1380,700) also fails with
this change stashed: pre-existing, untouched, recorded as follow-up.
This commit is contained in:
2026-09-10 08:35:30 -07:00
parent d212d5ae2c
commit bd396e488c
5 changed files with 117 additions and 62 deletions
+26 -2
View File
@@ -515,7 +515,9 @@ integration test fakes the whole subprocess (probe + encoder) with a Channel-bac
`Complete()` is EOF (`null`), never a `ChannelClosedException`.
**Decisions (locked):** args are pure (`FfmpegArgs.Build`, no string building in the encoder):
`-re -f rawvideo -pix_fmt bgra -video_size WxH -framerate FPS -i pipe:0` + a **real audio input — the
`-f rawvideo -pix_fmt bgra -video_size WxH -framerate FPS -i pipe:0` (**NO `-re`** — the FramePump
is the pacer since slice 9, 2026-09-10; `-re` added a second, fighting clock on the rawvideo demux)
+ a **real audio input — the
mixer writes IEEE-float stereo to a Windows named pipe** (`-f f32le -ar 48000 -ac 2
-i \\.\pipe\ytllive_audio`, name via `EncoderOptions.AudioPipeName`; replaced the old `-f lavfi -i
anullsrc` silence in the TASK 8 audio milestone) + explicit `-map 0:v -map 1:a` + `-c:v <enc> -b:v K
@@ -700,7 +702,8 @@ seam:** `Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<Compos
258.1ms, avg submit 1.5ms`. Two defects, both fixed: (1) the pump slept the FULL interval after each
render, so period = render + interval — OBS's `video_thread` (libobs/media-io/video-io.c) pattern
replaces it: absolute `nextTick += intervalTicks` deadline, sleep only the remainder, and on overrun
skip the wait AND the missed ticks (rebase, never burst stale frames). (2) the compositor did
skip the wait. (NOTE, slice 9: the original "skip the missed ticks (rebase)" half of this fix was
wrong — see the slice 9 correction below.) (2) the compositor did
per-pixel float sampling + `Math.Round` blending over all 2.07M master pixels (backdrop) and scanned
the whole destination per overlay (a 64px social-bar strip cost 2M iterations). `SceneCompositor` now
follows the libyuv pattern (BSD-3, chromium.googlesource.com/libyuv/libyuv — cited per the
@@ -810,6 +813,27 @@ seam:** `Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<Compos
reuses a canvas scratch + 8-deep output ring + a reused WriteableBitmap instead of minting two
fresh arrays + a new bitmap per 10Hz tick. Next suspect if gen2 stays hot: the WPF preview load
itself driving gen2 — recorded as follow-up, untouched this slice.
- **Slice 9 — the "rebase" WAS the recording time-lapse (2026-09-10, the recording playback-timing
take):** slice 6's "blown deadlines still rebase" was the recording-timing bug all along. In a real
take the pump emitted `215-219/300 per 5s` (~43fps) while `render+wait == period` still looked closed —
the rebase ERASED every missed slot (deadline = wall-now again), so the missed ticks never showed in
the stats. rawvideo has no per-frame timestamps: ffmpeg muxes by frame count at `-framerate 60`, so a
43fps reality was authored into a 60fps container and **every recording played ~1.4x fast** (takes
confirmed with a WSL ticker visible in the recording: file duration 11.44s vs ~15.5s wall). Two
defects, both fixed the OBS way (`libobs/media-io/video-io.c` — the video thread NEVER resets its
deadline; every interval tick outputs ONE frame, and a late render means repeated content — judder —
never a skipped timestamp):
(1) **count-based CFR emission:** `while (GetTimestamp() >= nextTick) { SubmitFrame(frame); nextTick += intervalTicks; statFrames++; }`
submits exactly one frame per crossed slot, re-writing the current composite when the render overruns
(duplicated footage = correct duration, not a time-lapse), and `nextTick` is NEVER reset to wall-now;
(2) **`-re` removed from `FfmpegArgs`** — it was a second, fighting pacer on the rawvideo demux
(its "Resumed reading … after a lag" grew 0.79s→4.82s across the take, leaving the pump behind its own
honest-stats count). One fix covers the game background AND the webcam — both flow through this one
pump. Muxed duration is now frame-count ÷ fps == wall time by construction. Test: the pump suite stays
green unchanged — the pacing test's `<interval` wait assertion still holds because the frame emits at
the next slot boundary, not immediately. (Unrelated pre-existing failure found the same day:
`Composite_FullScene_MasterPixels` pixel (1380,700) cyan-vs-magenta — reproduces with this fix stashed,
untouched by it, recorded as a follow-up.)
- **Stop ordering matters:** `StopAsync` stops the encoder (closes stdin → EOF → ffmpeg finalizes+exits)
**before** awaiting the loop, because closing stdin unblocks a write stuck on pipe backpressure — the
reverse order would deadlock. `ProcessFailed` self-stops the pump. `Failed` while live flips