The preview is the editor's frame-at-the-playhead surface. It is composited by the same
Rust + Direct3D 11 native crate that drives MP4 export — crates/compositor/ reached via the
compositor_view.node napi-rs addon — and pulled into the renderer as an RGBA8
bitmap that paints onto an HTML <canvas>. The DOM around the canvas hosts the
interactive-only layers (zoom gimbal, annotation selection, PiP webcam drag) that
need real hitboxes. Everything visible in the preview comes out of the same
compositor that writes the export; parity is the property of one renderer, not a
discipline across two.
This document describes the live composition path. The export pipe (architecture/export-pipeline.md) shares the same compositor and scene contract; the GPU-resident path it builds on is documented in architecture/native-compositor.md. Performance numbers (preview fluidity, bench methodology, the trade-offs that drove this design) live in engineering/rendering-performance.md.
One compositor exists in the source tree, and it is the live one. A Pixi/WebGL single-canvas screen compositor was tried alongside it and removed once it was established that nothing mounted it.
Mounted as the first child of .previewFrame by
NativeCompositorOverlay.tsx.
It owns the <canvas>, drives a useNativeCompositorView hook that allocates an
offscreen compositor_view view sized to the canvas's device-pixel rect
(useNativeCompositorView.ts:73),
and pushes a SceneDescription JSON every time the document or the editor
settings change. Wallpaper, screen video, webcam, cursor, zoom regions, annotations
— every visible pixel comes from this view. The DOM neighbours it (the .screenStage
wrapper, the screen <video>, the WebcamOverlay <video>, the AnnotationLayer, the
ZoomFocusOverlay, the webcam drag hitbox) are interactive overlays: their pixels are
hidden in CSS, only their pointer-event geometry counts.
The two <video> elements decode no picture either. The screen <video> is the playback
clock, and an <audio> on the same file plays its sound; both deselect their video track
once their metadata is in (dropVideoTrack, under the AudioVideoTracks Blink feature the
editor window enables). Left alone, each decoded the whole recording a second and a third
time beside the compositor: ~6.7 % of an RTX 4070 Ti's decode engine apiece on a 1080p60
take, measured, and zero once the track is dropped, with the clock, playbackRate and seeks
unchanged. A recording without sound drops its picture too, but with neither track selected
the element has nothing left to keep time with and jumps to its end on play, so it keeps
time by the wall clock instead (keepTimeByWallClock, wallClock.ts): it never plays for
real, and answers play, pause, currentTime and playbackRate from a clock that runs
playbackRate times the wall clock. Before that, a soundless take was decoded twice by
Chromium at the speed region's rate, against the compositor's own decode: at 4K and 3× the
view fell 3.5 s of playback behind the playhead (#994). The WebcamOverlay
<video> is never played at all — it is read for the camera's size (loadedmetadata),
which shapes the PiP box in the scene.
The path is enabled by the presence of the native addon — there is no flag,
no capability probe, no per-document switch. The compositing service loads
compositor_view.node at startup via
compositorViewService.ts
(ensureAddon); when the binary is missing the service logs once
([compositor-view] native addon not present; running as no-op,
compositorViewService.ts:288)
and returns synthetic negative view ids whose readFrame always returns null, so
the whole overlay stays inert. In that mode the renderer's own <video> element
keeps playing (decode is the responsibility of the DOM, not the compositor), but
nothing composites a frame — the canvas stays empty. The editor ships like this
on platforms where the addon isn't built; the dev build brings the addon in.
The renderer→addon IPC goes through native-bridge:invoke with a single
compositor domain
(compositorViewClient.ts:24). The
service in the main process loads the addon from
electron/native/bin/<platform>-<arch>/compositor_view.node (packaged) or
electron/native/compositor-view/build/compositor_view.node (dev), with an
OPENSCREEN_COMPOSITOR_VIEW_NODE env override for the standalone builds. The
ffmpeg shared-DLL directory is prepended to PATH before the require so the
addon's LoadLibrary("avcodec-NN.dll") resolves against the same pinned build the
crate links against
(compositorViewService.ts:206).
There is no fallback to a CPU/Canvas2D legacy compositor: before the native view ships a frame, only the wallpaper painted by CSS is visible, and the cursor / zoom DOM layers depend on the layout math (not the compositor) to know where to land. That is a product gap, not a fallback path. The native preview is the only compositor in service.
document → SceneDescription → JSON is the contract the app hands the native
compositor so it can compute the composed frame itself; the renderer does no
per-frame math.
buildSceneDescription
(src/native/sceneDescription.ts) is a pure data mapping from an AxcutDocument plus the
current editor settings to a SceneDescription JSON string. It resolves:
- Visible clips.
resolveVisibleClipsis the single shared clip list:resolvePlaybackSegments(document.timeline.clips, document.timeline.trimRanges)(trim-narrowed, so word-level cuts from the transcript editor actually reach the compositor instead of only affecting the transcript panel's own strikethrough) sorted bytimelineStartSecand filtered to clips whose asset has a resolvableoriginalPath. The same call backsbuildSceneDescription, the native-export clip list inExportDialog, and the active-clip lookup inNativeCompositorOverlay— three previously-divergent sites that are now the same expression. - Scene regions. Zoom, Full Camera, speed, annotations, and captions are stored
in RAW document time (trims still occupy their place) but applied at each
frame's source time (the compositor matches each decoded frame's PTS, not a
timeline counter).
projectRegionsToSourcebridges the two reference frames by resolving each region against every visible segment's own raw extent and emitting one entry per source-time span it covers, tagged with the segment'sclipIndex. Captions piggyback on annotations (captionCuesToTextRegionsproduces text annotations at the same zIndex layer) so the compositor draws them with the same path; deliberately no separate caption layer in the native code. - Layout. The webcam rect is resolved by the same
computeCompositeLayoutcall the legacyframeRendererruns and shipped to the addon aslayout.webcamRect/layout.screenRectin fractions of the output frame. The native side consumes those verbatim and applies the padding-slider and reactive-zoom adjustments on top, so preview/export never disagree on placement. Per-clip screen resolutions and crops mean a multi-clip document that mixes recording shapes (e.g. a 16:9 screen crop clipped to 9:16 beside another native 9:16 clip) lays out exactly like a single recorded ratio;layout.layoutByClipis index-aligned withclipsandcropByClipand the Rustfor_clip_windowselects the entry for the clip being composed. - Lengths as fractions. Every length that crosses the contract is a fraction
of its own reference box —
roundnessFracof the output frame's short side,screenRadiusFracof the screen box's short side, annotationx/y/w/hof the screen rect,padding0..1. There are no render-target pixels in the payload: the native compositor rasterises the preview into a small contain-fitted frame and the export at full output size, so a pixel meant two different things on the two sides of the boundary; a fraction has no unit to get wrong. The slider itself stays in pixels for the user — the division happens once, here.
The descriptor mirrors the Rust struct in
crates/compositor/src/scene.rs; field rename is camelCase
on both sides. The Rust consumer (compositor.rs::compose_frame,
crates/compositor/src/compositor.rs:1421) reads
the JSON per frame, derives the per-clip and per-frame values it needs (zoom
state from regions.rs::zoom_state_at, camera-fullscreen progress from
regions.rs::camera_fullscreen_progress_at, screen crop from
SceneCrop::belongs), and only then issues GPU draw calls. Region visibility is
expressed as [startSec, endSec) intervals and matched against t = source_time;
a region straddling a clip boundary emits one entry per covered clip via
projectRegionsToSource.
The native compositor runs off-screen (no OS window, no HWND ever crosses IPC), and the renderer pulls composed frames out of it. The protocol is the load-bearing contract between the two:
readFrame(id: number, sinceGen: number): { gen, width, height, data } | null(addons.d.ts declares it as CompositorViewAddon.readFrame,
electron/native/compositor-view/addon.d.ts:93;
the renderer-facing type is CompositorFramePacket,
contracts.ts:120.)
The contract, with the invariant on the consumer side:
- Self-describing packet. A returned object carries its own
width/height/data. The drawing buffer is resized to those values beforeputImageDataruns (useNativeCompositorView.ts:195), so pixels and canvas can never drift apart: there is no separate source of truth for "how big the bitmap is right now" — every packet carries it. - Generation-gated.
genis a monotonic per-frame generation (≥ 1). The renderer holds the last one it painted and passes it back assinceGen; the addon returnsnullwhenevergen <= sinceGen. An idle preview pays nothing: no buffer clone, no IPC crossing, no canvas copy, noputImageData. The null return is the dominant case while the preview sits still (paused editing). One in-flightreadFrameat a time (useNativeCompositorView.ts:155) so two responses can't land out of order and rewindlastGen. datais RGBA8,width * height * 4bytes. The renderer wraps it vianew Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength)—ImageDatarejectsSharedArrayBuffer-backed arrays, and the IPC binary is never shared, so the cast is safe.createImageBitmapdecodes off the main thread (UI stays at 60/120 Hz) and snapshots the view, thenputImageDatais the synchronous fallback if bitmap creation is unavailable.- Verify before trusting.
data.byteLength !== width * height * 4 || width === 0 || height === 0is a hard bail (useNativeCompositorView.ts:190): a mismatch would corrupt the image silently. The consumer never assumes a size — every draw is preceded by a resize of the canvas's drawing buffer to the packet's declaredwidth/height. - Pull loop cadence. Read-back frames are pulled on every other rAF tick
(
PULL_LOOP_TICK_DIVISOR = 2), shared-texture frames (below) on every tick. Each read-back frame is a GPU readback, a structured clone across IPC and a canvas upload, and the tick counter does not advance while a read is in flight: an 8 MB frame whose round trip passes 16.7 ms is pulled one tick in three, about 20 fps.
The renderer-side wrapper mirrors this verbatim in the Electron main process
(compositorViewService.ts),
so when the addon is absent the IPC layer returns null too — the renderer
never has to special-case "addon missing".
On Windows' hardware backend the pixels never leave the GPU. Copying them through RAM was the preview's bottleneck, not the compositor: at the same ~57 composed frames per second, read-back reached the canvas at 20-26 fps and kept 54-57 % of the renderer's main thread busy (measurements in engineering/rendering-performance.md).
- Native side. The render thread copies each composed frame into one of four shared
D3D11 textures (
shared_frames.rs, NT handles, no keyed mutex), waits for the GPU to finish the copy, and publishes{ gen, slot, handle }.SlotBooktracks which slot holds the ready frame and which ones Chromium still holds; a frame nobody took gives its slot back. A held slot is never rewritten, since Chromium may still read it: when every slot is held and one has waited 1 s for its release, the view falls back to read-back. - Main process.
readFrametakes the frame (readSharedFrame), imports it withsharedTexture.importSharedTexture, sends it withsharedTexture.sendSharedTextureto the frame that asked, drops its own reference and answers with a receipt ({ …meta, shared: true }, no pixels).allReferencesReleasedreturns the slot (releaseSharedFrame(id, slot, gen)) once Chromium is done with it in every process. - Renderer. The preload's
setSharedTextureReceiverhands the frame toelectronAPI.onCompositorFrame, ahead of the receipt; the hook draws theVideoFramewithdrawImageand closes it. The pixels are byte-identical to read-back. - Fallbacks, all to read-back. Not Windows, the software backend, Chromium without GPU
compositing, or
OPENSCREEN_PREVIEW_READBACK=1never enable it. A failed import or send turns it off for the view, and so does a first frame that lands transparent (a texture Chromium could not open, e.g. on another adapter): the composed frame is cleared opaque, so a transparent pixel can only be that. The render thread republishes the current frame on every switch, so the canvas never waits for something to move.
The native view runs its own clock while playing, so the renderer only pushes a
seek when the user actually moves the playhead (scrub, step, or wrap-around to
a new clip). The mapping sits in
useNativePlaybackSync.ts.
- Play / pause → free-run.
setNativePlayingtoggles the addon's free-run decoder. While playing,currentTimeSecticks every rAF in the renderer; pushing that per tick would force a rewind+seek seek each frame and fight the addon's free-run (the render thread prioritises app-requested frames over free-run). SouseNativePlaybackSynconly pushessetNativeTimewhile the transport is paused. - RAW → native source. The playhead
currentTimeSecis in RAW virtual time (trims still occupy their space on the ruler — same reference the V4 timeline and the webcam overlay use), but the native stream plays compressed segments (resolveVisibleClips, trims removed).resolveNativePositionbridges the two: it maps the RAW playhead viadocument.timeline.clips(the raw layout) to find the segment that contains it, then returns the segment'sclipIndex+ source time. Without this bridge a RAW playhead against a compressed clip list pointed at the wrong clip after a trim — wrong camera, misaligned screen. - Drift, measured rather than guessed. Every frame carries where the view was when it
composed it (
clipIndex,sourceTimeSec, both transports), andNativeCompositorOverlaycompares that with the playhead on the one timeline both share: programme time, the trim-compressed one, where a cut is no jump (nativeSync.ts). The gap is counted in seconds of playback, so a speed region divides it by its speed, and the last frame is aged by the time since it arrived, at most 250 ms, past which the view counts as stalled. A gap over 150 ms that holds for 100 ms re-anchors the view withsetActiveClip, at most every 500 ms. That covers a stall of the render thread and a jump by the user while playing. It replaced a guess from the wall clock at 1× speed, which inside a 2× speed region re-seeked the view ten times a second;useNativePlaybackSyncstill makes that guess for an addon that reports no position. - Free-run decodes every source frame. Inside a speed region the render thread adopts
speed ×the source's frame rate, so the decoder sets the ceiling: 4K at 2× needs 60 decoded frames a second, at 4× 120. A clip without a camera used to decode its screen file a second time for a stand-in webcam decoder, halving that budget; the stand-in is now never stepped or sought, and compose takes the screen frame in its place (Player::webcam_frameincrates/compositor/src/live.rs). On an M1, 4K at 2× went from 1.2 s of source behind on average in the editor to no lag the counter could measure, and from 8 to 57 frames published a second in the bench.cargo run --release --example live_free_run_bench_macos -- <file> <speed>replays the loop and prints the lag.
The overlay's rect is kept aligned with the DOM via the same primitives used elsewhere in the renderer:
computeDeviceRect(src/native/nativeViewRect.ts) turnsgetBoundingClientRect()into a device-pixelCompositorViewRect, rounding every axis to dodge the truncated / off-by-one windows the addon produces on non-integer inputs.rectsEqualskips thesetRectpush when nothing has changed (nativeViewRect.ts:33) — the the ResizeObserver (useNativeCompositorView.ts:259) and a coalesced rAF schedule steady-state scrolling and resizes without re-pushing the rect.- The overlay reuses the canvas's CSS
width: 100%; height: 100%for its display box (NativeCompositorOverlay.tsx:217); the addon'srect.x/rect.yare vestigial (ignored native-side peraddon.d.ts:18and kept on the wire for source compatibility).
Clip changes across the playhead boundary are atomic at the
setActiveClip(viewId, screenPath, webcamPath, webcamOffsetSec, clipIndex, sourceTimeSec)
RPC
(compositorViewClient.ts), and who sends it
depends on the transport:
- Paused (scrub, step):
NativeCompositorOverlaysends it whenever the playhead enters another clip. - Playing: the render thread crosses into the next clip by itself, preloading it ahead
of the cut. The overlay used to send the clip again at every cut, which made the view
seek back to a place it had just left, or drop the clip it had preloaded: a hitch at
every cut of an edited take. It now only sends it when the view is elsewhere — a jump
(a click on the timeline, followed at once) or a gap the drift watch above catches.
On a pause it also brings back a view left on another clip, before
setNativeTimeseeks in it. - An addon that reports no position is driven as before: native is paused across the
decoder swap, and the live transport is re-read now (not from a captured
isPlaying) before resuming, so a user pause that lands mid-transition is honoured.
The hidden <video> is a clock and an audio source, not the picture — the
pixels come from the native canvas. So its failing is not the preview failing,
and the invariant since #395
is: a media error can never render EditorEmptyState. That component now
answers exactly one question — is there anything in this project to show? — and
Preview.tsx's render condition (hasProject && hasAsset && previewSources.length > 0)
takes no failure input at all.
What replaced it, in two layers:
- Classify, then reload
(
mediaError.ts, applied inVirtualPreview'sonError).MEDIA_ERR_ABORTEDis ignored outright and never counted: the<video>is keyed onactiveSource.id, so every cross-asset clip boundary remounts it mid-load and Chromium reports exactly that.MEDIA_ERR_SRC_NOT_SUPPORTEDgets one retry (a recording the capture process is still writing reports as unsupported); everything else — including anerrorevent carrying noMediaError— ridesRETRY_DELAYS_MS. The reload is a plainload(): re-assigningsrcfirst would start a second load that aborts the first and emit a spurious abort. The budget is re-armed by playing past the position the failure happened at (tracked in the rAF tick). Both obvious alternatives are wrong: never re-arming makes the third transient failure of a long session terminal — #395 with a longer fuse — while re-arming when the reload completes is worse still, becauseloadedmetadata/canplayprove the header parsed and the decoder is willing, not that the bytes that killed us are readable. A truncated recording re-fires both on every reload, so that version loops at 400 ms forever and never surfaces the card at all. - Resume through the existing path. The reload queues
pendingSeekRefand letsonLoadedMetadatarestore position and playback — the same path a cross-asset seek uses, so the component has one resume path rather than two. The position is resolved at reload time, not when the error fired, because the user can scrub during the backoff: it comes fromlocateVirtualPositionagainst the live playhead, guarded by anassetIdcheck (that function answers for whatever clip the playhead is on, which after a boundary advance can belong to a different asset), and falls back to a source time sampled while the decoder was known good.video.currentTimeis not usable here — it reads 0 afterload().
While a reload is in flight the rAF tick returns early (recoveringRef),
publishing the clock but taking no decision. This cannot ride on the v.paused
gate the rest of the tick uses: an error does not fire pause, so a failure
during playback leaves paused false, and the tick would go on making
trim/boundary decisions against a frozen clock and clobber the queued resume.
Only once the budget is spent does Preview hear about it, and it renders
PreviewErrorCard over the still-mounted canvas — the last composed frame
stays visible, which is the evidence the project is intact. The card carries the
MediaError code, because a user's report is otherwise unactionable: the reason
#395 could only ever be described as "the preview disappeared" is that the code
was thrown away.
- Live preview is video-only.
crates/compositor/src/live.rs(1628 lines) handles only the video packet stream; audio is decoded and mixed inaudio.rs::decode_clip_audiofor the export path and not for the live view. Editing playback is therefore silent against the exported file; users hear audio only when the export runs. There is no flag in this branch that re-routes live audio. - Speeds the decoder cannot follow fall behind. VideoToolbox decodes 4K at about 97
frames a second on an M1, short of the 120 a 30 fps source needs at 4×. The render thread
caps a tick at
max_stepsframes and counts at most 0.1 s of clock per tick, so the view loses time and the drift watch re-anchors it every 500 ms; skipping ahead to a keyframe instead is not implemented. - Drift under 150 ms is left alone. The view and the app's clock run independently inside the drift watch's tolerance, and a correction is a seek, not a change of pace: a view drifting slowly is re-anchored with a visible step rather than eased back.
- Shared textures are Windows-only. macOS (an
IOSurface-backed Metal texture) and Linux (a dmabuf exported from Vulkan) still read back:sharedTextureimports both, the native halves are not written. - Add-on absent = blank frame. When
compositor_view.nodeis missing (development with the addon not yet built, or a packaged build for an unsupported architecture) the overlay renders no pixels: only the DOM/CSS wallpaper and the interactive layers show. There is no CPU/Canvas2D fallback path; the live preview is gated on the addon being present.
The labelled arrows are the IPC + state edges; everything inside one box is in-process.
sequenceDiagram
participant Doc as AxcutDocument
participant TSD as src/native/sceneDescription.ts
participant Overlay as NativeCompositorOverlay.tsx
participant Hook as useNativeCompositorView.ts
participant IPC as native-bridge:invoke<br/>(compositor domain)
participant Svc as compositorViewService.ts
participant Addon as compositor_view.node
participant Canvas as renderer <canvas>
Doc->>TSD: buildSceneDescription(doc, settings)
TSD-->>Overlay: SceneDescription JSON
Overlay->>IPC: setScene(viewId, json)
IPC->>Svc: dispatch
Svc->>Addon: setScene(id, json)
Overlay->>Hook: ResizeObserver on canvas
Hook->>IPC: setRect(viewId, deviceRect)
IPC->>Svc: dispatch
Svc->>Addon: setRect(id, rect)
Addon-->>Svc: ok
Note over Addon,Canvas: compositing thread runs off-screen<br/>(D3D11VA decode → compositor → RGBA8 staging)
loop every other rAF tick (~30 fps)
Hook->>IPC: readFrame(viewId, lastGen)
IPC->>Svc: dispatch
Svc->>Addon: readFrame(id, sinceGen)
alt same generation or no frame
Addon-->>Svc: null
Svc-->>Hook: null
Hook-->>Canvas: (canvas untouched, idle path costs nothing)
else new generation
Addon-->>Svc: { gen, width, height, data }
Svc-->>Hook: { gen, width, height, data }
Hook->>Hook: validate byteLength === width*height*4
Hook->>Canvas: canvas.width = width; canvas.height = height<br/>createImageBitmap → drawImage
Hook->>Hook: lastGen = gen
end
end
Overlay->>IPC: setActiveClip(viewId, screenPath, webcamPath,<br/>webcamOffsetSec, clipIndex, sourceTimeSec)
IPC->>Svc: dispatch
Svc->>Addon: setActiveClip(...)
Overlay->>IPC: setPlaying(viewId, playing) (per play/pause)
IPC->>Svc: dispatch