Skip to content

perf(recording): decode each Windows MJPEG camera frame once for preview and encoder - #2865

Draft
richiemcilroy wants to merge 1 commit into
mainfrom
perf/recording-windows-mjpeg-single-decode
Draft

richiemcilroy wants to merge 1 commit into
mainfrom
perf/recording-windows-mjpeg-single-decode

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

On Windows, an MJPEG camera frame was decoded twice while recording with the preview open. The capture callback decoded it for the preview (frame.as_ffmpeg(), feeds/camera.rs), and the camera encoder thread decoded the same bytes again (camera_frame_to_ffmpeg, output_pipeline/win.rs). Frames the pacer repeated were decoded once more. #2757 only reused the decoder. With this PR, each frame is decoded once and the encoder shares the decoded frame.

Changes

  • Capture callback (Windows only): when the camera is MJPEG and both a native (encoder) sender and an ffmpeg (preview) sender are attached, the callback decodes the frame first. It then attaches an av_frame_ref of the result to the NativeCameraFrame it sends to the encoder, and hands the same decoded frame to the preview as before. This is a refcount bump, so no pixels are copied.
  • NativeCameraFrame::decoded: Option<Arc<SharedDecodedFrame>>: camera_frame_to_ffmpeg returns a reference to the shared frame instead of decoding. This covers all three Windows camera muxers (WindowsCameraMuxer, WindowsFragmentedM4SCameraMuxer, win_segmented_camera) and pacer repeats.
  • Fallbacks (the encoder decodes exactly as on main):
    • The preview is closed, so the callback does not decode.
    • The frame format is not MJPEG.
    • av_frame_ref fails.
    • The callback's decode failed.
    • More than 8 shared decoded frames are already in flight (SharedDecodeBudget). The budget means a stalled encoder queues compressed MJPEG instead of decoded 4 MB+ frames.
  • Unchanged: macOS and Linux. Everything new is cfg(windows), apart from a decoded: None in the Windows-only verify test helper.

Why sharing is safe and the output is identical

  • On Windows the GPUI preview takes FFmpegVideoFrames through AddSender. Neither the preview nor the encoder writes into the decoded planes:
    • H264Encoder::queue_frame only sets pts on its own AVFrame, converts with swscale into its own frame, then calls send_frame.
    • The preview scales or reads the planes.
  • The shared reference is taken before the preview's set_pts, so the encoder sees the decoder's frame metadata unchanged.
  • The callback's thread-local MJPEG decoder and the encoder's MjpegDecoder are both a default Context::new_with_codec(MJPEG) decoder fed the same bytes. The decoded frames are therefore byte-identical, and the A/B below confirms it.
  • Preview timing: the callback does the same work before the preview frame is sent (MF buffer copy and decode, now in the other order), plus one av_frame_ref.

Merges cleanly with #2837 (git merge-tree clean). It does not touch the fan-out code or the feeds/camera.rs import line that #2837 changes.

Measurements

Scratch A/B on Blacksmith windows-2025 (8 vCPU AMD EPYC 4565P), run 39681975821, branch ci/windows-mjpeg-single-decode-ab. Earlier run 39679067414 gave matching results.

The runner has no camera, so the bench uses a synthetic MJPEG source:

  • 30 distinct YUVJ422P frames (gradient plus noise) are encoded with the FFmpeg MJPEG encoder at q=3. That is 289 KB/frame at 720p and 639 KB/frame at 1080p, which is webcam-like.
  • Each frame goes through the same path as the camera callback:
    • Media Foundation memory buffer, NativeCameraFrame, and the preview decode.
    • Then the real camera_frame_to_ffmpeg, optionally followed by H264Encoder (libx264) into an mp4.
  • Base is d16fd199b (main). Head is this branch with only crates/recording swapped in. Both run the same release-built bench test.
  • Each run is 600 measured frames after 30 warmup frames. There are 10 rounds without encode and 4 with encode, and the variant order alternates each round. The tables show medians.
  • CPU is process user+sys time per frame. "Preview ready" is the time from callback start until the preview frame is ready to send. Decodes are counted per frame: the encoder input counts as not decoded when it points at the preview's pixel buffer.

Preview open while recording (the case this fixes)

res metric main this PR change
1920x1080 MJPEG decodes per frame 2.00 1.00 -50%
1920x1080 CPU per frame (decode + convert) 20.98 ms 9.31 ms -56%
1920x1080 CPU per frame (incl. libx264 encode) 31.41 ms 21.63 ms -31%
1920x1080 encoder-side convert p50 10149 us 2.6 us -100%
1920x1080 encoder-side convert p95 12938 us 4.0 us -100%
1920x1080 preview ready p50 (with encode) 9934 us 9899 us -0%
1920x1080 preview ready p95 (with encode) 13923 us 13899 us -0%
1280x720 MJPEG decodes per frame 2.00 1.00 -50%
1280x720 CPU per frame (decode + convert) 8.68 ms 4.44 ms -49%
1280x720 CPU per frame (incl. libx264 encode) 14.44 ms 9.45 ms -35%
1280x720 encoder-side convert p50 4306 us 2.3 us -100%
1280x720 preview ready p50 4326 us 4325 us -0%
1280x720 preview ready p95 5069 us 5285 us +4%

Without encode, preview-ready at 1080p was 10437 us on main and 9156 us with this PR (p50). Across all cases it stays within run-to-run noise (-15% to +4%). The callback does the same decode either way.

At 30 fps, 1080p, that is about 350 ms of CPU saved per second of recording, roughly a third of a core.

Preview closed (fallback path, unchanged behaviour): 1 decode per frame on both. CPU per frame is 9.86 vs 9.78 ms at 1080p and 4.21 vs 4.40 ms at 720p, both within noise.

Output identity, main vs this PR (300 measured + 30 warmup frames per case, preview on and off, 720p and 1080p):

  • Encoder input frames (format, size, colour range/space, and every visible plane byte), digested per frame: identical in all 4 cases.
  • Decoded frames of the recorded mp4 (330 per case): identical in all 4 cases.
  • The mp4 files are byte-identical (md5 1fc61897... at 1080p, a627162d... at 720p).

Unit tests (Windows, run in the A/B job, since main CI does not run cap-recording tests on Windows):

  • shared_mjpeg_frame_matches_encoder_decode_without_decoding_again: the shared and repeated encoder frames point at the preview's pixels, match the fallback decode plane for plane, and give the whole budget back.
  • exhausted_decode_budget_leaves_decoding_to_the_encoder: when the budget is exhausted the encoder decodes itself with identical output, and clones of a frame do not take extra budget.

@blacksmith-sh

blacksmith-sh Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Found 1 test failure on Blacksmith runners:

Failure

Test View Logs
cap-verify::crash/crash_safety View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

This branch was successfully deployed

2 active deployments
Preview – cap-web — 577f23e8 Deployed Oct 21, 2026 by vercel[bot]
Preview – cap-status — 577f23e8 Deployed Oct 21, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant