From 53bf16a208a7d4682c73acf770256ba6273815ea Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 10 Aug 2026 18:51:23 +0200 Subject: [PATCH] fix(win): pace the capture loop to a deadline instead of sleeping a full period The video writer ended each iteration with `sleep_for(1/fps)`, after the frame had already been captured, converted and submitted to the sink writer. The real period was therefore `work + 1/fps`, never `1/fps`: at 1080p the work is ~11 ms, so a 30 fps recording ran at 22. Keep a `nextFrameDue` deadline and `sleep_until` it. When a frame runs long the deadline is resynced to now rather than carried forward, so a stall costs the frames it costs instead of being repaid as a burst of catch-up frames -- the same rule the webcam cadence a few lines above already follows. Measured on a Ryzen 5 7520U / Radeon iGPU, 15 s display capture at 1080p with 30 fps requested: before 317 frames / 14.672 s = 21.6 fps after 440 frames / 14.675 s = 30.0 fps The frame count comes from the new `[pacing]` line on stderr because the recording cannot answer this question: the sink writer re-times its output to nominal CFR, so `nb_frames / duration` reads exactly 30.000 whatever the loop actually did -- it stayed at 30 even with a 300 ms stall injected through OPENSCREEN_WGC_TEST_STALL_READBACK_MS. The line sits next to the existing [stop-timing] instrumentation and is picked up by the diagnostic tool's stderr capture. macOS and Linux do not share the pattern: ScreenCaptureKit paces the callbacks itself via `minimumFrameInterval`, and the pipewire helper derives its output frame index from the wall clock. --- electron/native/wgc-capture/src/main.cpp | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index 63cbcbba..14223957 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -706,6 +706,7 @@ int main(int argc, char* argv[]) { int64_t nextWebcamWriteDueHns = 0; const int64_t nominalWebcamIntervalHns = static_cast(10'000'000ULL / std::max(1, webcamCapture.fps())); + auto nextFrameDue = std::chrono::steady_clock::now(); while (!control.stopRequested && !encodeFailed) { Microsoft::WRL::ComPtr videoSample; @@ -844,8 +845,25 @@ int main(int argc, char* argv[]) { } frameIndex += 1; - std::this_thread::sleep_for(frameDuration); + // Pace to a deadline, not `sleep_for(frameDuration)` after the work: + // capturing, converting and encoding a 1080p frame costs ~11 ms, so + // sleeping a whole period on top of it made the real period + // `work + 1/fps` -- 30 fps requested delivered 22.5 measured. + nextFrameDue += frameDuration; + const auto now = std::chrono::steady_clock::now(); + if (nextFrameDue < now) { + // Fell behind (slow frame, or waiting on the first one). Resync + // to now rather than firing a burst of catch-up frames, same as + // the webcam cadence above. + nextFrameDue = now; + } + std::this_thread::sleep_until(nextFrameDue); } + std::cerr << "[pacing] frames=" << frameIndex << " elapsed_ms=" + << std::chrono::duration_cast( + std::chrono::steady_clock::now() - control.recordingStartedAt) + .count() + << std::endl; }; std::thread videoWriterThread;