Skip to content

fix(win): pace the capture loop to a deadline instead of sleeping a full period - #331

Merged
EtienneLescot merged 2 commits into
mainfrom
fix/win-capture-frame-pacing
Aug 10, 2026
Merged

fix(win): pace the capture loop to a deadline instead of sleeping a full period#331
EtienneLescot merged 2 commits into
mainfrom
fix/win-capture-frame-pacing

Conversation

@EtienneLescot

@EtienneLescot EtienneLescot commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

The Windows capture helper delivers about 75% of the requested frame rate.

writeVideoFrames ended each iteration with sleep_for(frameDuration), after the frame had been captured, converted and submitted to the sink writer. So the real period was work + 1/fps, never 1/fps. At 1080p the work is ~11 ms: 33.3 + 11 = 44.4 ms, i.e. 22.5 fps for a 30 fps recording.

The fix keeps a nextFrameDue deadline and sleep_untils it. If a frame ran 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

Same instrumented build, only the pacing differing. Ryzen 5 7520U / Radeon iGPU, Windows 11 26200, 15 s display capture at 1080p, 30 fps requested:

frames elapsed real fps
before 317 14.672 s 21.6
after 440 14.675 s 30.0

Why the frame count comes from stderr

nb_frames / duration on the recording cannot answer this question. The sink writer re-times its output to nominal CFR, so ffprobe read exactly 30.000 fps on every run here — including a run with a 300 ms stall injected through OPENSCREEN_WGC_TEST_STALL_READBACK_MS, where the loop was managing about 3 iterations a second. The new [pacing] frames=N elapsed_ms=M line on stop is the ground truth; it sits next to the existing [stop-timing] instrumentation and is captured by the diagnostic tool's stderr collection:

node scripts/diagnostic-tool/diagnostic.mjs --duration 15

Other platforms

Neither shares the pattern:

  • macOS sets configuration.minimumFrameInterval and writes from the ScreenCaptureKit delegate callback — the OS paces it, there is no sleep loop.
  • Linux derives the output frame index from the wall clock (elapsed * fps / 1e9 in capture.rs), so it is deadline-based by construction.

The two cursor samplers (cursor-sampler.cpp, the macOS cursor helper) do sleep a fixed interval after their work, but that work is a GetCursorInfo plus a stdout line, so the drift is a couple of percent of sample density rather than 25% of the frame rate. Left alone.

Notes

Pre-existing on main — not introduced by #305 or #306, and independent of the #252 stop-hang work. The native helpers have no PR CI, so build.yml needs a manual dispatch on this branch to get a compiled artifact.

Summary by CodeRabbit

  • Bug Fixes
    • Improved video-frame pacing for more consistent capture timing.
    • Added recovery when processing exceeds the target frame interval.
    • Added final pacing diagnostics to help identify frame-rate issues.

…ull 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.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

No new commits to review since the last review.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f540e15a-fd0c-42ad-bcfd-913973c55ea6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The video writer now uses a steady-clock deadline schedule for frame pacing. It resynchronizes after processing overruns and logs final frame-count and elapsed-time diagnostics.

Changes

Video Frame Pacing

Layer / File(s) Summary
Deadline scheduling and diagnostics
electron/native/wgc-capture/src/main.cpp
The writer initializes the next-frame deadline from the steady clock. It replaces fixed-duration sleeps with deadline-based pacing, resynchronizes after overruns, and logs final frame-count and elapsed-time diagnostics.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

Suggested reviewers: seb1900

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main Windows capture pacing change.
Description check ✅ Passed The description provides a detailed summary, measurements, rationale, platform context, and testing command, but omits several template metadata sections.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/win-capture-frame-pacing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@EtienneLescot

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Already reviewed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@EtienneLescot
EtienneLescot merged commit c9ff08a into main Aug 10, 2026
15 of 16 checks passed
@EtienneLescot
EtienneLescot deleted the fix/win-capture-frame-pacing branch August 10, 2026 19:15

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@electron/native/wgc-capture/src/main.cpp`:
- Around line 908-912: Update the pacing metric around the writer loop and
stopVideoWriter() so teardown work is excluded from elapsed_ms. Record the
stop-request timestamp before WGC, audio, and webcam shutdown, or establish a
writer-local timing boundary, and use that boundary instead of
control.recordingStartedAt while preserving the existing frame logging.
- Around line 898-906: Update the capture loop’s paused-to-running transition
around control.paused and nextFrameDue so resuming resets the deadline to now
plus frameDuration before submitting a frame. Track the prior paused state as
needed, keeping the existing slow-frame resynchronization for normal running
frames and ensuring the first post-resume frame is separated by one frame
interval.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d97da350-5504-40e8-b1ec-1dff71530836

📥 Commits

Reviewing files that changed from the base of the PR and between 09bc7d2 and da3d588.

📒 Files selected for processing (1)
  • electron/native/wgc-capture/src/main.cpp

Comment on lines +898 to +906
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reset the deadline when a pause ends.

When control.paused is true, nextFrameDue becomes stale. After a long pause, Line 904 sets it to now, and Line 906 does not wait. Because latestFrameTexture is still set, the loop submits two frames without one frameDuration interval after resume. Reset the deadline on the paused-to-running transition separately from slow-frame resynchronization.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/native/wgc-capture/src/main.cpp` around lines 898 - 906, Update the
capture loop’s paused-to-running transition around control.paused and
nextFrameDue so resuming resets the deadline to now plus frameDuration before
submitting a frame. Track the prior paused state as needed, keeping the existing
slow-frame resynchronization for normal running frames and ensuring the first
post-resume frame is separated by one frame interval.

Comment on lines +908 to +912
std::cerr << "[pacing] frames=" << frameIndex << " elapsed_ms="
<< std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - control.recordingStartedAt)
.count()
<< std::endl;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Exclude shutdown time from the pacing metric.

The writer logs this interval after control.recordingStartedAt, while the main thread can perform WGC, audio, and webcam shutdown before stopVideoWriter() joins it. If the writer is still running during that work, teardown time is included in elapsed_ms, which under-reports loop pacing. Record the stop-request timestamp before teardown or use a writer-local measurement boundary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/native/wgc-capture/src/main.cpp` around lines 908 - 912, Update the
pacing metric around the writer loop and stopVideoWriter() so teardown work is
excluded from elapsed_ms. Record the stop-request timestamp before WGC, audio,
and webcam shutdown, or establish a writer-local timing boundary, and use that
boundary instead of control.recordingStartedAt while preserving the existing
frame logging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant