Preview
August 20, 2026 · View on GitHub
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.
The compositor path
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.
Native D3D compositor overlay (live)
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 <video> for screen decode, 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 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.
Scene description
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.
Frame delivery
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. The renderer pulls on every other rAF tick (
PULL_LOOP_TICK_DIVISOR = 2,useNativeCompositorView.ts:70), so IPC + GPU readback +putImageDatarun at roughly 30 fps on 60/120 Hz displays without changing perceived smoothness.
The renderer-side wrapper mirrors this verbatim in the Electron main process
(compositorViewService.ts:339),
so when the addon is absent the IPC layer returns null too — the renderer
never has to special-case "addon missing".
Playback sync
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 re-anchor. During free-run the two clocks can drift; once the
additive error exceeds 100 ms (
Math.abs(sourceTimeSec - expectedSourceTimeSec) > 0.1,useNativePlaybackSync.ts:94) the hook re-issuessetNativeTime.useNativePlaybackSync:18calls this a known limitation acceptable for the ~6 s fixture it's measured on; a pause resets the drift by construction.
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:93):
when the playhead crosses into a clip whose assetId / webcamPath differs
from the previous one, NativeCompositorOverlay.tsx:175-203 pauses native across
the decoder swap, awaits setActiveClip, and re-reads the live transport now
(not from a captured isPlaying) before resuming — so a user pause that lands
in the middle of a clip transition is honoured, not silently undone.
When the decode clock fails
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.
Known gaps
- 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. - Long-recording scrub drift.
useNativePlaybackSync:18documents the accepted-at-fixture-time drift between the app's rAF playhead and the addon's free-run clock as a known limitation; a pause re-aligns them. A scrub further than 100 ms past expected position triggers an explicit re-anchor; below that the two clocks run independently until something forces a sync. Long recordings measured at the bench in engineering/rendering-performance.md stay below the threshold in practice, but no systematic measurement exists. - 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.
A single frame, end-to-end
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