Skip to content

refactor(rendering): drive the Media Foundation decoder from one request state machine - #2545

Merged
richiemcilroy merged 20 commits into
CapSoftware:mainfrom
cesarfrancots:perf/mf-decoder-state-machine
Oct 20, 2026
Merged

richiemcilroy merged 20 commits into
CapSoftware:mainfrom
cesarfrancots:perf/mf-decoder-state-machine

Conversation

@cesarfrancots

@cesarfrancots cesarfrancots commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

#2541 has merged, so this diff is now only this PR's own changes: fix(video-decode): read past Media Foundation samples that carry no frame and refactor(rendering): drive the Media Foundation decoder from one request state machine.

Summary

The Windows Media Foundation decoder thread used to be one 300-line loop. Batching, a mid-batch readahead slide, deferred requests and four separate fallback chains were all tangled together in it, which made its frame selection hard to reason about and impossible to test. This PR splits it into two parts:

  • FrameRequests holds the policy and never touches Media Foundation. It owns the cache, the waiting requests and the readahead, and it answers next_step() with Seek, Decode one sample, or Wait.
  • The thread takes in requests, carries out each step, and sends back the answers.

One answer rule. Once the decoder moves past a requested frame, the request gets the newest sample at or below that frame. VFR samples that round to the same frame resolve the same way no matter when the request arrives. Requests are served lowest first, and each gets its own seek when decoding forward cannot reach it. The cache keeps every sample since the last seek and evicts the oldest, so that newest sample is always in the cache.

Unit tests drive the policy with a fake sample stream. They cover:

  • VFR collisions and skipped frames
  • readahead sliding with every request
  • readahead never evicting the frame being shown
  • seeking back for a passed frame
  • long GOPs
  • a request landing inside a seek's pre-roll
  • several requests in flight
  • end of stream
  • read errors

Each new rule was mutation-checked: reverting it fails its test.

Behaviour changes

  • Several requests in flight each get their own frame. An export transition between two segments of the same recording sends both requests through one decoder. Previously the highest request was the target, and a lower request far from it got the target's frame.
  • A request behind the decoder with nothing cached at or below it seeks back. Previously, within half the cache size, it got the next decoded frame, which is a later one.
  • Every request slides the readahead, cache hits included. A sequential reader no longer drains the cache while the decoder sits idle.
  • Readahead never evicts the sample that answers the latest request. Frames too large for the 128 MiB cache budget used to get a later frame. The per-batch time budget is gone; readahead is bounded by its frame count.
  • A failed seek is no longer treated as a successful one. Waiting requests are answered from the cache instead of from wherever the old read position happens to be.
  • read_sample no longer returns Ok(None) for a sample that carries no frame. Stream ticks used to look like the end of the stream; it now reads past them.

Verification

Metric #2541 This PR Change
export-1080p60 wall (n=5) 7.65 s 7.52 s −1.8% [−6.2%, +13.5%] same
export-1080p60 CPU (n=5) 15.2 s 14.5 s −4.4% [−9.0%, +11.5%] same
decode random access avg (n=10) 66.7 ms 67.1 ms +0.6% [−3.3%, +2.1%] same
decode sequential (n=10) 305.6 fps 308.1 fps +0.8% [−0.5%, +2.0%] same
playback pipeline (n=5) 129.6 fps 137.3 fps +5.9% [−7.8%, +22.7%] same
playback scrub p50 (n=5) 46.5 ms 45.7 ms −1.7% [−6.1%, +10.8%] same

One 5-rep run flagged random access as +25% WORSE. A 10-rep, three-way rerun showed that was session noise: the #2541 build itself ranged from 68 to 86 ms across sessions, and the rerun shows +0.6% with a CI of [−3.3%, +2.1%]. Media Foundation returned no frameless samples on this fixture, so the reader change is not a factor here.

The win here is correctness and structure, not speed. The trends lean the right way but stay within noise.

Notes for reviewers

  • ffmpeg.rs keeps its own readahead and eviction helpers. They are deliberately not shared. The MF cache evicts strictly oldest first, because that is what keeps "newest sample at or below a frame" exact. The FFmpeg helper evicts by direction.
  • Today the MF zero-copy path is never taken, because the plane texture handles are always null. Cached MF frames are therefore CPU NV12 copies, so a 4096×2304 frame still leaves room for the readahead window plus the frame being shown.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; no new actionable issue was found.

Summary

The PR moves Media Foundation frame selection into FrameRequests and makes read_sample skip events without a frame.

  • Windows Media Foundation requests now receive their own matching frames.
  • Media Foundation reads past stream ticks with no frame.

Reviews (18) · Last reviewed commit: "Merge branch 'main' into perf/mf-decoder..." · Reviewed by Greptile

…rame

ReadSample hands back a null sample for stream ticks (gaps in the stream),
and read_sample reported those as Ok(None), the same as the end of the
stream. The decoder then stopped a batch early and answered with an older
frame. Read again until a sample, the end-of-stream flag or an error.
…est state machine

The decoder thread mixed batching, a mid-batch readahead slide, deferred
requests and four fallback chains in one loop. The policy now lives in
FrameRequests, which never touches Media Foundation: the thread takes in
requests, asks it for the next step (seek, decode one sample, or wait) and
does it. Tests drive it with a fake sample stream.

Once the decoder moves past a requested frame, the request gets the newest
sample at or below that frame. Requests are served lowest first, each with
its own seek when decoding forward cannot reach it, so the "first sample
after" fallback only applies to a frame before the stream's first sample.
The cache keeps every sample since the last seek and evicts the oldest, so
the newest sample at or below a frame is always cached.

Behaviour changes:
- Several requests in flight (export transitions between two segments of
  one recording decode both through the same decoder) each get their own
  frame. The highest used to be the target, and a lower one far from it got
  the target's frame.
- A request behind the decoder with nothing cached at or below it seeks back.
  Within half the cache size it used to get the next decoded, later frame.
- Every request slides the readahead, cache hits included, so a sequential
  reader no longer drains the cache while the decoder idles.
- Readahead stops at its frame count, or before the next sample would evict
  the one answering the latest request (frames too big for the cache budget
  used to get a later frame); the per-batch time budget is gone.
- Samples outside the old cache window are cached too, so a frame after a
  VFR gap past the window gets its own sample, not the one before the gap.
- A failed seek answers what waits from the cache instead of reading on from
  the old position as if it had landed.

Export output is byte-identical to the previous decoder on the reference
recording.
@vercel

vercel Bot commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

@cesarfrancots is attempting to deploy a commit to the Cap Software Inc Team on Vercel.

A member of the Team first needs to authorize it.

Comment thread crates/export/src/mp4.rs
@richiemcilroy

Copy link
Copy Markdown
Member

Thanks for the PR. Greptile currently has this at 4/5, and we only merge PRs once Greptile scores the current head 5/5. Please address the remaining Greptile feedback, then comment @greptileai to request a re-review, and repeat until it reaches 5/5.

@cesarfrancots

Copy link
Copy Markdown
Contributor Author

@greptileai

@cesarfrancots

Copy link
Copy Markdown
Contributor Author

@richiemcilroy Thanks. The Greptile feedback is addressed and all review threads are resolved. Greptile now scores the current head (9d0ef5a) 5/5. This PR is still stacked on #2541, so once that merges the diff shrinks to the two decoder commits.

@cesarfrancots

Copy link
Copy Markdown
Contributor Author

@greptileai

@cesarfrancots

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread scripts/capbench.py Outdated
@cesarfrancots

Copy link
Copy Markdown
Contributor Author

@greptileai

@cesarfrancots

Copy link
Copy Markdown
Contributor Author

@greptileai

@cesarfrancots

Copy link
Copy Markdown
Contributor Author

@greptileai

@cesarfrancots

Copy link
Copy Markdown
Contributor Author

@greptileai

@cesarfrancots

Copy link
Copy Markdown
Contributor Author

@greptileai

@cesarfrancots

Copy link
Copy Markdown
Contributor Author

@greptileai

…-machine

The request state machine now maps request times with request_frame, like main's other decoder paths.
@cesarfrancots

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread crates/rendering/src/frame_pipeline.rs
@cesarfrancots

Copy link
Copy Markdown
Contributor Author

@greptileai

@cesarfrancots

Copy link
Copy Markdown
Contributor Author

The macOS test finding is fixed here too (merged up from #2541 in 1e10ac5), and the branch includes the latest main.

@greptileai review

@cesarfrancots

Copy link
Copy Markdown
Contributor Author

@greptileai review

@cesarfrancots

Copy link
Copy Markdown
Contributor Author

@greptileai review

@cesarfrancots

Copy link
Copy Markdown
Contributor Author

@richiemcilroy Greptile is at 5/5 on the current head (8c4630d), and all review threads are resolved. Since the last round I merged the latest main (one conflict in crates/recording/src/cursor.rs, keeping main's cursor_id_for_image alongside this stack's cursor cache) and fixed Greptile's one new finding (capbench now rejects labels that aren't plain file names). #2541, #2545 and #2583 are all at 5/5 and mergeable. The only failing checks are the Vercel deploys, which need authorization for fork PRs.

@cesarfrancots

Copy link
Copy Markdown
Contributor Author

@greptileai review

@cesarfrancots

Copy link
Copy Markdown
Contributor Author

@greptileai review

@cesarfrancots

Copy link
Copy Markdown
Contributor Author

@greptileai review

@cesarfrancots

Copy link
Copy Markdown
Contributor Author

@greptileai review

@richiemcilroy
richiemcilroy merged commit ca1cec2 into CapSoftware:main Oct 20, 2026
45 of 47 checks passed
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.

2 participants