Skip to content

fix(rendering): stale frames in same-clip cross-fades and seeks with the FFmpeg decoder - #2661

Merged
richiemcilroy merged 3 commits into
mainfrom
fix/same-clip-transition-decoder
Oct 17, 2026
Merged

richiemcilroy merged 3 commits into
mainfrom
fix/same-clip-transition-decoder

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Exports and editor playback that use the FFmpeg decoder (Linux, and Windows without a D3D11 video device) could show frames up to 89 source frames old. This happened in a cross-fade between two parts of the same clip, after a seek back to the start, and in seek storms.

Causes

  1. Two transition sides read one reader. A cross-fade between two parts of one clip decodes the outgoing and incoming sides through the clip's single decoder. The FFmpeg reader holds one position, so the two sides took each other's frames (editing/export/same-clip-transition/forward/ffmpeg: up to 55 frames stale). Media Foundation holds one position too, and seeks for every interleaved request instead.
  2. The decoder mistook its last sent frame for the reader's position. A cache hit, or the first-frame shortcut, sets the last sent frame without moving the reader. A following request for an uncached frame the reader had already passed then looked like a short step forward. The reader read on past it, and the answer fell back to an older cached frame. After seek 6.1 s, seek 0, seek 2.0 s and play, 31 frames showed frame 0 (seek/seek-gop/home-then-play30/ffmpeg).
  3. First-frame shortcut. Any uncached request for frames 0 to 2 was answered with the stream's first frame, even when frames 1 and 2 exist.

Fix

flowchart LR
    T[transition frame] -->|incoming side| P[clip decoders]
    T -->|outgoing side, same clip| S["transition_side(): second readers, opened on first use"]
    T -->|outgoing side, other clip| O[that clip's decoders]
Loading
  • Second readers (crates/rendering/src/lib.rs). RecordingSegmentDecoders::transition_side() opens a second screen and camera reader on first use and keeps it. The outgoing side of a same-clip transition reads from it: export, editor playback prefetch, the scrub preview and the export preview. The AVAssetReader pool already opens a reader per position, so on macOS it is shared and nothing extra opens. If the second readers fail to open, both sides fall back to the one reader, as before.
  • Reader position (crates/rendering/src/decoder/ffmpeg.rs). reader_at tracks the newest frame the reader has vended since its last reset, including a frame pulled for a request that was then dropped. A cache miss at or behind it resets the reader. Known VFR holds are still answered first, so a reader parked in a long hold does not seek every frame.
  • First frame. The stream's first frame answers only requests at or before it, for any uncached request rather than only backward ones.

The same changes apply to the software-fallback loop.

Measured

Engine verify cases on Apple silicon, FFmpeg decoder:

Case main This PR
editing/export/same-clip-transition/forward 8 frames up to 55 stale pass
editing/export/basic/seed-5, -10, -23, -37 1 to 6 frames, up to 70 stale pass
editing/export/multi-segment/seed-7 22 frames up to 29 stale pass
seek/seek-gop/home-then-play30 31 frames up to 89 stale pass
audio/audio-mix-30s-a2/long-crossfade/mp4-* (Linux, Windows) 18 frames up to 49 stale pass
seek/*/storm*, fast tier up to 11 seeks stale pass
seek/*/storm*, full tier (3000 seeks) 62 to 78 seeks, up to 83 stale 1 to 2 seeks, at most 2 stale
  • The remaining storm seeks come from the 2-frame backward lookback tolerance (MAX_FRAME_LOOKBACK_TOLERANCE), now the only mechanism in those entries, which are marked flaky with tight bounds.
  • The decoder reference model (decoder::model): 27 FFmpeg stale and overread groups pass, 14 entries are removed and 13 narrowed. Eleven seeds move to the existing grid-rounding and future classes. Where main answered them with a stale frame, the decoder now seeks, and the seek path's known rounding (request time to frame, and to the stream tick) can land one frame off.
  • AVAssetReader, the macOS default, is unchanged: the full export, seek, playback and editing suites pass with the same results.
  • One new Linux-only entry: editing/export/basic/seed-23 output frame 8 shows incoming camera frame 84 for 76 (main: output frames 7 to 12 up to 70 stale). A traced run (38997099068) shows the decoder answering request 76 with frame 76, the same as on macOS, so the wrong frame comes from the software renderer's shared camera layer, which draws both transition sides. That is a separate follow-up.

Performance

scripts/verify/perf-ab.py (#2660) against the merge base on Apple silicon, 3 interleaved pairs, median CPU time: export-basic +0.1%, export-jitter-camera-40s -0.1%, seek storms +0.1% (9.62 s to 9.63 s), playback-basic -0.2%. The second readers open once per clip, and only when a same-clip transition is rendered.

RetriggerView in GreptileConfidence Score: 5/5

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

Summary

The PR separates the two readers used during same-clip transitions and fixes FFmpeg seeks that previously depended on the last returned frame.

  • Same-clip transitions give the outgoing side its own reader.
  • FFmpeg seeks back when an uncached request is behind its reader.

Review used source inspection. No runtime tests were run.

Reviews (1) · Last reviewed commit: "fix(rendering): seek the FFmpeg decoder ..." · Reviewed by Greptile

@vercel

vercel Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
cap-status Ready Ready Preview Oct 17, 2026 3:05am UTC

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Capy found no issues.

View 1 other finding in Capy.

Open in Capy

@blacksmith-sh

This comment has been minimized.

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Capy found no issues.

Open in Capy

@richiemcilroy
richiemcilroy merged commit 24b6f6b into main Oct 17, 2026
63 of 64 checks passed

This branch was successfully deployed

2 active deployments
Preview – cap-web — 559ed901 Deployed Oct 17, 2026 by vercel[bot]
Preview – cap-status — 559ed901 Deployed Oct 17, 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