Timeline Preview Frame Source — Design
Status: phases 2–5 implemented behind timelinePreviewDecoder, with the
deviations in Implementation status. Phase 6 is open.
Code: web/src/components/timeline/preview/, web/src/components/timeline/render/.
Research: Timeline Preview Decoding: What Other Web Editors Do.
Problem
The timeline preview gets its video pictures from <video> elements. The
compositor uploads whatever frame an element shows when a tick runs. It cannot
ask for the frame that belongs to the clock time. Four defects follow from
this, and the drift fix in playbackSync.ts cannot remove them:
- The picture is not frame-exact. Even with zero drift, the uploaded frame can be one frame off the clock. The export decodes a different frame for the same time.
- Cuts start late. A seek in Chrome costs 90–140 ms (measured with CDP on “Sommernacht am See – Space Song (Rough Cut)”). An incoming clip starts about 60 ms late even when a cold slot prepared it.
- Remap, reverse, and shuttle are scrubs.
videoRateModereturnsscrubfor these cases, and the element is paused and seeked on every tick. That is the slowest path the browser has. - Preview and export disagree. Export already decodes with mediabunny
(
render/SequentialVideoSource.ts). Preview does not.
Every editing engine with published source that has frame-exact preview gets it
from WebCodecs, not from <video> (Remotion @remotion/media, Diffusion
Studio Core v4, WebAV). See the comparison.
Goal
The preview draws, on each tick, the decoded frame whose timestamp is the last one at or before the clip’s source time. It uses the same frame selection rule as the export. A prepared cut draws the incoming clip on its first frame.
Out of scope: audio. AudioGraph.ts already schedules all clip audio on the
AudioContext, and video elements are muted.
Current state
| Part | File | What it does |
|---|---|---|
| Master clock | preview/PlaybackClock.ts |
Position from AudioContext.currentTime while the context runs, wall clock otherwise |
| Element pool | preview/PreviewCompositor.tsx, preview/videoSlotPool.ts |
8 hot slots (MAX_VIDEO_LAYERS) and 4 cold slots. Cold slots load clips that start within 30 s. promotePreparedVideoSlot swaps a cold element into a hot slot at the cut |
| Sync | preview/playbackSync.ts |
Rate nudge up to ±25% below 0.25 s of drift. Hard seek above it, with a measured seek lead |
| Frame readiness | preview/gpu/videoFrameVersion.ts |
Tracks a per-element frame generation so the GPU skips uploads while a seek decodes |
| Upload | preview/gpu/compositor.ts |
copyExternalImageToTexture from the element. Holds the last texture while the element seeks |
| Source time | packages/timeline/src/render/sceneModel.ts |
clipSourceTimeSec(clip, timeMs) resolves trim, speed, and remap |
| Export decode | render/SequentialVideoSource.ts, render/OffscreenVideoPool.ts |
mediabunny VideoSampleSink for monotonic times. Seeked elements for rotation, non-square pixels, alpha, HDR, unsupported codecs, and retiming |
| Proxies | packages/websocket/src/lib/video-proxy.ts |
All-intra H.264 MP4 (GOP of 1), long side scaled down, same timestamps as the source. Local mode only |
The all-intra proxy matters for this design. In a proxy, every frame is a keyframe, so any frame decodes alone. Random access, reverse, and remap cost one decode per frame on a proxy.
Design
D1. One frame source per clip asset, with two implementations
interface ClipFrameSource {
/** Last decoded frame with timestamp <= sourceSec, or null. Never waits. */
frameAt(sourceSec: number): VideoFrame | HTMLVideoElement | null;
/** Move the decode position. `exact` waits for the frame at sourceSec. */
seek(sourceSec: number, mode: "play" | "exact"): Promise<void>;
/** Keep frames decoded ahead of sourceSec at `rate`. */
advance(sourceSec: number, rate: number): void;
dispose(): void;
}
DecodedFrameSource(new) decodes with mediabunny and WebCodecs.ElementFrameSourceholds one pooled<video>element and the current drift loop. It is the fallback. Its behavior does not change.
The compositor asks the source for a frame and stops managing elements
directly. The pool, decideVideoDrift, and SeekLatencyTracker move behind
ElementFrameSource.
D2. The fallback rules match the export
DecodedFrameSource.open(url) returns null, and the clip uses
ElementFrameSource, when any of these is true:
VideoDecoderis missing, orisConfigSupportedrejects the track config.- The track has rotation, a non-square pixel aspect, alpha, or HDR.
SequentialVideoSource.openrejects the same four today. - A decode error occurs before the first output frame, and a retry with
hardwareAcceleration: "prefer-software"also fails. WebAV uses this retry.
A clip never switches implementation during playback. A failure after the
first frame holds the last frame and reports through onFailure, the same as
an element error today.
D3. The AudioContext stays the master clock
PlaybackClock does not change. The research shows two clock models: audio
master (Diffusion Studio, the mediabunny player) and wall-clock frame counter
(Remotion, WebAV). NodeTool plays continuous audio through AudioGraph, so the
audio clock stays master and video follows it.
D4. Late frames: draw the best frame while playing, wait when paused
- Playing. On each tick the compositor calls
frameAt(target). If the queue has no frame at or beforetarget, it draws the newest frame it has. The existing hold-last-frame rule ingpu/source.tscovers a layer with no frame. The clock never stops for video. Remotion stops the clock and buffers, which is correct for a viewer but makes an editor feel stalled. - Paused, scrub, frame step. The compositor calls
seek(target, "exact")and redraws when it resolves. The latest request wins: a newer seek supersedes an older one, and a superseded seek still paints its frame if it lands first. Remotion handles paused scrubs the same way. - Play start. Playback waits until every active source has its first frame,
up to 300 ms. Then the audio starts. This replaces the
isClockAdvancinggate for decoded sources.
D5. Decode ahead with a bounded queue
DecodedFrameSource owns one VideoSampleSink iterator. advance keeps
frames decoded up to 250 ms or 8 frames ahead of the target, whichever is
smaller. Each frame is converted with sample.toVideoFrame() and the sample is
closed at once.
frameAt drops and closes every queued frame older than the one it returns.
A source holds at most 10 open VideoFrames at any time.
The iterator restarts with sink.samples(t) when the target moves backward or
jumps forward by more than 1 s (MAX_SEQUENTIAL_GAP_SEC in the export).
mediabunny starts the decode at the preceding keyframe. A smaller forward jump
decodes through and drops frames.
D6. Prepare clips before the cut
The cold-slot lookahead becomes a decoder lookahead. Within 2 s of a clip’s
start (scaled by the shuttle rate), the compositor opens its
DecodedFrameSource and calls seek(inPointSec, "exact"). At the cut, the
first frame is already in the queue.
Open decoders are capped at 12, which matches today’s 8 hot plus 4 cold
elements. When a new clip needs a decoder and the cap is reached, the clip
furthest from the playhead loses its decoder. A matted clip uses two sources,
one for the picture and one for the matte, as videoSlotKey does today.
D7. Remap, reverse, and shuttle use random access
A remapped, reversed, or out-of-range-rate clip calls seek(t, "play") on every
tick with its own curve time, instead of advance.
- Proxy or all-intra source. Each request decodes one frame. Requests are coalesced, so at most one decode per source is in flight. The newest target wins.
- Long-GOP source, reverse or backward remap. The source decodes the GOP
that contains
tinto a cache ofImageBitmaps and serves frames from it backward. The cache holds one GOP, capped at 2 s and at 64 MB of decoded pixels. A GOP that exceeds the cap falls back toElementFrameSourcefor that clip.
Shuttle rates above 2× decode every frame and draw some of them. The decode rate limits shuttle smoothness on long-GOP sources. The proxy removes that limit.
D8. The GPU uploads VideoFrame directly
CompositeSource in gpu/types.ts gains VideoFrame. Both backends accept it:
copyExternalImageToTexture in gpu/compositor.ts and drawImage in
canvas2dCompositor.ts. The upload key is the frame’s timestamp plus the
source id, so a frame uploads once. No ImageBitmap copy is made on the
playback path.
Frame dimensions come from displayWidth/displayHeight. Rotated and
non-square tracks stay on the element path (D2), so the frame is already in
display orientation.
D9. Preview and export share the decode core
The frame selection rule, the timestamp epsilon, the restart rule, and the
fallback rule move into one module, render/clipDecoder.ts. Both
DecodedFrameSource and the export use it. The export path keeps
await-ing the exact frame. The preview path does not wait. A test feeds the
same sample list to both and asserts the same timestamp for each target time.
D10. Main thread first
mediabunny and the decoders run on the main thread in the first release, as in Remotion and Diffusion Studio. Decode itself runs in the GPU process. Demux and the iterator cost main-thread time, and React competes for it.
The ClipFrameSource interface hides where decoding runs. If the measurement in
the acceptance criteria shows main-thread tasks over 8 ms from decoding, the
sources move into one dedicated worker that transfers VideoFrames to the main
thread. Kapwing moved its decoding into workers for this reason.
D11. A setting selects the path until it is proven
timeline.previewDecoder is "element" (default) or "webcodecs". The
WebCodecs path ships behind it. The default flips after the acceptance criteria
pass. The element path stays as the fallback in D2 and is not deleted.
A clip whose asset resolves to a ready proxy uses DecodedFrameSource under
both settings. A proxy is all-intra H.264 in MP4 at a reduced size, so it is
the input with the least decode risk, and it gets frame-exact preview first.
The D2 fallback still applies to a proxy that fails to open.
D12. Alpha bakes stay on the element path
A transparent model3d bake (VP9 yuva420p in WebM) uses
ElementFrameSource, with the probe in bakeDecoding.ts unchanged. mediabunny
can decode the alpha plane, but the compositor would then need a second upload
path for the alpha texture. That move is a follow-up after phase 6.
Implementation status
| Part | Where | State |
|---|---|---|
| Setting (D11) | stores/SettingsStore.ts timelinePreviewDecoder, switch in Settings → Execution |
Done. Proxy clips decode under both values |
| Decoded source (D4, D5) | preview/DecodedFrameSource.ts |
Done |
| Decoder lookahead and cap (D6) | preview/decodedFrameSourcePool.ts |
Done |
| Random access (D7) | DecodedFrameSource.seek(t, "play") |
Coalesced single-frame decodes. A short forward step decodes through |
VideoFrame upload (D8) |
gpu/types.ts, gpu/source.ts, gpu/compositor.ts |
Done |
| Shared core (D9) | render/clipDecoder.ts, used by SequentialVideoSource |
Done |
Deviations from the design:
- D1. The compositor branches per slot between the element pool and the
decoder pool.
ClipFrameSourceandElementFrameSourcewere not added, because one implementation behind an interface added nothing. The element pool code did not change. - D4. Play start does not wait 300 ms for first frames. The decoder lookahead opens each clip before its cut, so no wait was needed in the measurement.
- D7. The long-GOP reverse cache is not built. Reverse on a long-GOP source decodes from the keyframe for each frame, with only the newest request in flight.
Measured on 2026-10-11 in Chrome 154 over CDP on “Sommernacht am See – Space Song (Rough Cut)” (20 fps sources, 15 s of playback):
- Criterion 1: 699 of 702 ticks drew 0 frames of error, and no tick was late. The other 3 came from one render per source with a stale React time. That render draws the frame already on screen.
- Criterion 2: each clip opened its decoder 2.4–3.0 s before its cut and drew its in-point frame on its first tick.
- Criterion 4:
frames-openreported 0 after the timeline closed. - Paused frame steps drew the frame that mediabunny
getSamplereturns for the same time (8 of 8). - Criteria 3, 5, and 6 are not measured. They need a reverse run on a proxy and a long-GOP 4K clip.
Data flow
PlaybackClock (AudioContext) ──▶ liveMs
│
compositor tick ─▶ for each active video layer:
t = clipSourceTimeSec(clip, liveMs)
source = frameSources.get(clipId, assetUrl)
playing ? source.advance(t, rate) : source.seek(t, "exact")
frame = source.frameAt(t) // VideoFrame | <video> | null
─▶ CompositeLayer { source: frame }
─▶ GPU upload (once per frame timestamp) ─▶ composite ─▶ present
Diagnostics
The window.__nodetoolTimelinePerf sink gains these events:
| Event | Fields | Use |
|---|---|---|
frame-presented |
sourceId, targetSec, frameSec |
Frame error per tick |
frame-late |
sourceId, targetSec, newestSec |
A tick that drew an older frame |
decoder-open |
sourceId, implementation, reason |
Which path a clip took, and why it fell back |
decoder-restart |
sourceId, fromSec, toSec |
Seeks and jumps |
frames-open |
count |
Leak detection |
The CDP probe from the drift fix reads these events. It reports frame error, cut latency, and the peak open frame count.
Acceptance criteria
Measure in Chrome over CDP on “Sommernacht am See – Space Song (Rough Cut)” and on one long-GOP 4K phone clip without a proxy:
- Frame error is 0 frames for 95% of ticks and at most 1 frame for all ticks, over 15 s of playback.
- A cut to a clip that was prepared (D6) draws the incoming clip on the cut tick.
- Reverse playback at 1× on a proxy runs without
frame-lateevents for 95% of ticks. frames-openreturns to 0 within 1 s after playback stops and after the timeline closes.- No main-thread task over 50 ms comes from decoding during playback.
- A scrub across 10 s of the long-GOP clip shows the exact frame at each point where the pointer stops.
Phases
- Interface and element extraction. Add
ClipFrameSource. Move the pool, the drift loop, and the seek tracker behindElementFrameSource. Behavior does not change. The existing playbackSync and compositor tests pass unchanged. - Forward playback. Add
DecodedFrameSourcewith D3–D5 and D8 behind the setting, and enable it for proxy clips under both settings (D11). Scope: forward play at shuttle rates up to 2×, paused scrub, and frame step. - Cuts. Add the decoder lookahead and the decoder cap (D6).
- Shared core. Extract
render/clipDecoder.tsand moveSequentialVideoSourceonto it (D9). - Random access. Add remap, reverse, and fast shuttle (D7).
- Default flip. Run the acceptance criteria and flip the setting. Decide on the worker move (D10) with the task-length measurement.
Each phase is one PR and passes the mandatory checks.
Tests
DecodedFrameSourceagainst a fake sink that yields timestamped samples: frame selection, the restart rule, queue bounds, and that every frame and sample is closed afterdispose.- The shared selection rule, fed the same samples through the preview and export paths (D9).
- Decoder cap and eviction order with more than 12 upcoming clips.
- Fallback selection for each D2 condition, with the export rejection list as the source of the cases.
- Late-frame policy: a playing tick with an empty queue draws the newest frame, and a paused seek waits.
Risks
| ID | Risk | Mitigation |
|---|---|---|
| R1 | A hardware decoder stalls when the app holds too many of its output frames | The 10-frame cap per source (D5). Measure the cap on Apple Silicon and on one Windows GPU before the default flip |
| R2 | The GPU limits the count of concurrent hardware decoders. Eight 4K layers can exceed it | The decoder cap (D6). The software retry (D2). Proxies reduce the frame size |
| R3 | A VideoFrame leak exhausts GPU memory within seconds |
The frames-open event, the dispose tests, and acceptance criterion 4 |
| R4 | Main-thread demux causes jank on large projects | Acceptance criterion 5 and the worker move (D10) |
| R5 | A long-GOP reverse cache uses too much memory | The 2 s and 64 MB caps, with the element fallback (D7) |
| R6 | Cloud deployments have no proxies, so long-GOP random access is slow there | The element fallback for reverse on long-GOP sources (D7). A proxy worker for cloud is a separate project |