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
+34 -26
View File
@@ -376,42 +376,50 @@ public sealed class FramePump : IDisposable
IFfmpegEncoder? encoder;
lock (_gate) encoder = _encoder;
if (encoder == null) break;
// Count-based CFR emission (libobs video-io.c — the frame interval
// is a DEADLINE and the output stream holds its declared rate):
// one frame per interval slot, whatever the render cost. When the
// renderer falls behind, the SAME fresh composite is written again
// for every slot that ticked past, so a slow render expresses as
// duplicated footage (judder) — never as a skipped timestamp. The
// deadline counter is NEVER reset to wall-now: the old rebase
// erased every missed slot, so a 43fps reality was authored into a
// 60fps container and every recording played ~1.4x fast (rawvideo
// carries no timestamps — muxed duration is pure frame count).
submitSw.Restart();
await encoder.SubmitFrameAsync(frame, ct);
while (!ct.IsCancellationRequested
&& System.Diagnostics.Stopwatch.GetTimestamp() >= nextTick)
{
await encoder.SubmitFrameAsync(frame, ct);
statFrames++;
nextTick += intervalTicks;
}
submitSw.Stop();
submitTicks += submitSw.ElapsedTicks;
// Submit copied the bytes — everything from this tick is recyclable.
// Release AFTER submit, and the free-list Contains guard makes the
// Cut path (BlendFrame returns toFrame itself, aliasing scratch) safe.
// SubmitFrameAsync copied the bytes on every write above — the
// tick's buffers are recyclable once each due slot consumed them.
// The free-list Contains guard keeps the Cut path (BlendFrame
// returns toFrame itself, aliasing scratch) safe.
ReleaseScratch(frame.BgraPixels);
ReleaseScratch(scratch);
statFrames++;
ReportStats();
// Advance the deadline; cost already spent is not slept again.
// Blew the frame budget: skip the wait AND the missed ticks —
// rebase rather than bursting a catch-up pile (OBS rewinds its
// tick; a burst only queues stale frames). Otherwise sleep the
// BULK and SPIN the 2ms tail — never hand a sub-tick remainder
// to the sleep quantum (see the timeBeginPeriod note).
nextTick += intervalTicks;
// Sleep the BULK of the remainder, SPIN the 2ms tail — never hand
// a sub-tick remainder to the sleep quantum (the timeBeginPeriod
// note). If the renderer already ate the budget there is nothing
// left to sleep and the loop renders the next frame straight away.
waitSw.Restart();
var ahead = nextTick - System.Diagnostics.Stopwatch.GetTimestamp();
if (ahead <= 0)
{
nextTick = System.Diagnostics.Stopwatch.GetTimestamp() + intervalTicks;
}
else
{
if (ahead > SpinTailTicks)
await _pacingDelay(TimeSpan.FromSeconds(
(ahead - SpinTailTicks) / (double)System.Diagnostics.Stopwatch.Frequency), ct);
while (!ct.IsCancellationRequested
&& System.Diagnostics.Stopwatch.GetTimestamp() < nextTick)
Thread.SpinWait(400);
}
if (ahead > SpinTailTicks)
await _pacingDelay(TimeSpan.FromSeconds(
(ahead - SpinTailTicks) / (double)System.Diagnostics.Stopwatch.Frequency), ct);
while (!ct.IsCancellationRequested
&& System.Diagnostics.Stopwatch.GetTimestamp() < nextTick)
Thread.SpinWait(400);
waitSw.Stop();
waitTicks += waitSw.ElapsedTicks;
ReportStats();
}
}
}