Overview
Recast has one compositor. The browser renders every output frame through the
same RenderCore the live preview uses and WebCodecs-encodes them to a
video-only temp mp4; Rust/FFmpeg then only muxes the processed audio in
with -c:v copy. Because a single renderer produces both preview and export,
the two can't visually diverge.
The legacy Rust/FFmpeg filter_complex compositor still exists and runs as an
automatic fallback: never a user-facing choice. It is selected when the
browser path is disabled, blocked, incapable, or fails mid-render.
Two independent serial queues cooperate:
- App-scoped render queue (
exportActivity.svelte.ts): composites browser jobs one at a time in this window, so a render survives closing its editor and two encoders never contend for the GPU. - Durable Rust export queue (
commands::export_queue): a SQLite row + a payload file + a single serial worker thread. It owns every export's lifecycle, survives an app restart, and drives both the mux tail (browser path) and the full Rust composite (fallback path).
Engine selection (chooseExportEngine) is a pure resolver behind the
browserExportBeta experimental flag; while that flag is off, everything is
Rust.
Diagram
flowchart TD
trigger["Editor: handleExport()"] --> decide{chooseExportEngine}
decide -->|"!masterEnabled / forceLegacy /<br/>blockedReason / !capability"| rustPath
decide -->|browser| buildJob["buildExportJob (main thread)<br/>rasterize DOM assets → ExportJob"]
subgraph browserPath["Browser engine"]
buildJob --> renderQ["exportActivity render queue<br/>(serial, N=1)"]
renderQ --> render["run-export-job → RenderCore<br/>→ MediaBunny CanvasSource"]
render --> tempmp4["video-only temp mp4"]
tempmp4 --> save["saveBrowserExportVideo → temp path"]
end
save --> enqueue["enqueueExport(browserVideoPath)"]
rustPath["enqueueExport (no browserVideoPath)"] --> queue
subgraph rustQueue["Durable Rust queue"]
enqueue --> queue["SQLite row + payload file"]
queue --> worker["serial worker"]
worker --> branch{browser_video_path?}
branch -->|yes| mux["run_mux_job<br/>-c:v copy + audio atempo"]
branch -->|no| composite["run_export_job<br/>full FFmpeg composite"]
end
mux --> out["output mp4 / gif"]
composite --> outsequenceDiagram
participant Ed as Editor page
participant EA as exportActivity (render queue)
participant RC as RenderCore + MediaBunny
participant IPC as Tauri commands
participant WK as Rust export worker
Ed->>EA: enqueueBrowserExport({ id, job, params })
Note over EA: optimistic queued item (hasRenderPhase)
EA->>RC: renderJobToBytes(job) [serial, N=1]
RC-->>EA: mp4 bytes (progress 0..RENDER_MAX=95)
EA->>IPC: saveBrowserExportVideo(bytes) → temp path
EA->>IPC: enqueueExport({ ...params, browserVideoPath })
IPC->>IPC: validate + repair, persist row + payload
IPC-->>WK: notify export_wake
WK->>WK: run_mux_job (-c:v copy + audio)
WK-->>Ed: export-state progress (mapped to RENDER_MAX..100)
WK-->>Ed: export-state success(path) + export-jobs-changed
Note over EA: on render failure → enqueueExport() WITHOUT<br/>browserVideoPath → Rust composites from scratchKey components
| Component | File | Role |
|---|---|---|
chooseExportEngine | packages/editor/src/lib/export/choose-export-engine.ts | Pure resolver: browser vs rust, first-match precedence + telemetry reason |
browserExportBlockedReason / resolveExportFps | packages/editor/src/lib/export/browser-export-eligibility.ts / | Throughput gate (SAFE_EXPORT_THROUGHPUT) + effective export fps |
probeBrowserExportCapability | packages/editor/src/lib/export/export-capability.ts | Cached WebCodecs H.264-encode probe |
buildExportJob | packages/editor/src/lib/export/build-export-job.ts | Producer (main thread): snapshot scene, rasterize DOM assets → serializable ExportJob |
ExportJob + bitmap helpers | packages/editor/src/lib/export/export-job.ts | Handoff contract; collectTransferables / closeJobBitmaps |
runExportJob | packages/editor/src/lib/export/run-export-job.ts | Consumer (DOM-free): rebuild per-frame callbacks, drive the renderer |
renderTimelineToVideo | packages/editor/src/lib/export/offscreen-export.ts | Offline RenderCore + WebCodecs loop → mp4 bytes |
videoEncodingConfigFor | packages/editor/src/lib/export/browser-export-plan.ts | Quality-tier → MediaBunny VideoEncodingConfig |
runBrowserExport / renderToBytes / renderJobToBytes | packages/editor/src/lib/export/browser-export.ts / / | Orchestrator + worker-vs-main-thread render + worker→main fallback |
exportActivity store | apps/desktop/src/lib/stores/exportActivity.svelte.ts | App-scoped serial render queue + read-model over the Rust queue |
run_mux_job / mux_browser_gif | apps/desktop/src-tauri/src/commands/editor.rs / | -c:v copy + audio mux; 2-pass GIF palette on the browser video |
export_queue commands + worker | apps/desktop/src-tauri/src/commands/export_queue.rs | Durable SQLite queue, serial worker, save_browser_export_video, reconcile/sweep |
| Rust composite fallback | apps/desktop/src-tauri/src/commands/export/*.rs | run_export_job full FFmpeg compositor (cuts/speed, captions, camera, blur, codec) |
Control / data flow
Browser export (the default path when eligible)
- Decide:
handleExport(editor+page.svelte) readsbrowserExportBeta, probes capability only if the flag is on, then callschooseExportEngine({ masterEnabled, blockedReason, capabilitySupported }). First matching guard wins: disabled →forceLegacy→ feature-blocked → capability, elsebrowser(choose-export-engine.ts). - Build render state:
buildExportRenderState(store, { skipVisualRaster: engine === "browser" })(+page.svelte); the browser engine composites visuals itself, so the Rust-side text→PNG / cursor pre-render is skipped. - Build the job:
buildExportJob(build-export-job.ts) snapshots the scene and rasterizes every DOM-bound asset (background bitmap, cursor SVG sprites, annotation images, caption webfont) to transferableImageBitmaps, then de-proxies each store-sourced field withtoStatic($state.snapshot). The result is plain data + bitmaps, zero closures. - Enqueue render:
exportActivity.enqueueBrowserExportpushes an optimisticqueueditem (hasRenderPhase: true) and the job onto the app-scopedrenderQueue, thenpumpRenderQueue(exportActivity). - Render:
pumpRenderQueueruns one job at a time viarenderJobToBytes(browser-export.ts): worker when supported, else main thread; a worker failure retries the same job main-thread.renderTimelineToVideo(offscreen-export.ts) composites each output frame throughRenderCoreinto a MediaBunnyCanvasSourceand WebCodecs-encodes to mp4. Render progress maps to0..RENDER_MAX(95). - Persist:
saveBrowserExportVideo(exact)(exportActivity→export_queue.rs) writes the mp4 bytes to a temp file and returns its path. - Enqueue mux:
enqueueExport({ ...params, browserVideoPath, exportId })(exportActivity) hands off to the durable Rust queue. - Mux: the worker sees
browser_video_pathand callsrun_mux_job(export_queue.rs→editor.rs): input 0 is the browser video (-c:v copy,editor.rs); audio inputs (source/system/mic/music) are built, warped to the output timeline withatempo/cuts, AAC-encoded, and muxed.+faststart. The browser temp video is deleted on success (editor.rs). GIF instead runsmux_browser_gif, a 2-pass palette (palettegen→paletteuse) on the already-composited browser video, no audio. - Report: the worker emits
export-state(progress mapped onto theRENDER_MAX..100tail,exportActivity) andexport-jobs-changed;finishFeedbackfires the success toast + telemetry once.
Rust export (fallback)
Chosen when chooseExportEngine returns rust, or when a browser render
throws (GPU context loss on a long/heavy source): pumpRenderQueue's catch
clears hasRenderPhase and calls enqueueExport({ ...params, exportId })
without browserVideoPath (exportActivity).
enqueue_export(export_queue.rs) probes source metadata, auto-repairs the render state (clamps staletrim_end), runsvalidate_render_state, then atomically writes the payload file + inserts aqueuedrow and notifiesexport_wake.- The serial worker (
spawn_export_worker, own thread + current-thread runtime) claims the oldest queued row (claim_next_queued) and, seeing nobrowser_video_path, callsrun_export_job, the full FFmpegfilter_complexcompositor undercommands/export/*.rs(cuts/speed, burned captions, camera burn-in, blur, codec selection). - Success writes the output path +
success; failure keeps the payload for retry; a "cancel"-containing error recordscancelled.
Invariants & gotchas
- Producer/consumer split is load-bearing.
build-export-job.tsis the ONE place that touches the store/DOM;run-export-job.tsis intentionally DOM-free so it can move verbatim into a render worker. Don't reach into the store from the consumer. structuredClonehazards. Everything inExportJobmust be structured-cloneable or a transferable bitmap. Two specific traps:- Svelte
$stateproxies throwDataCloneErroronpostMessage, so every store-sourced field is run throughtoStatic($state.snapshot) in the producer (build-export-job.ts).staticAnnotationsnapshots around the bitmaps so it doesn't clone them. - MediaBunny's
Qualityis a branded object that doesn't survivepostMessage. Only the plainExportQualitytier rides in the job; the consumer rebuilds the encoder config withvideoEncodingConfigFor(job.quality)(run-export-job.ts).
- Svelte
- Context-loss handling. A lost GL context turns uploads/draws into silent
no-ops (a black-from-here mp4) and can strand
source.addforever.offscreen-export.tsguards three ways: anisContextLost()check per frame , awebglcontextlostlistener that rejects alostPromiseraced against the encoder awaits, and a one-timeunhandledrejectionguard swallowing MediaBunny's benign "closed codec" double-close. A layer-draw throw is caught per-layer so one bad annotation/caption frame doesn't abort (and silently fall back): it logs once and keeps rendering. - Decoder efficiency.
sink.getSample(t)builds a freshVideoDecoderper call; the loop usessamplesAtTimestampsso each packet decodes at most once (offscreen-export.ts). Retaining aVideoFramesilently starves the decoder, everytoVideoFrame()isclose()d in afinally. - Throughput gate routes heavy sources to Rust.
width*height*fps > SAFE_EXPORT_THROUGHPUT(1920*1080*60) →blockedReason→ Rust (browser-export-eligibility.ts). 1080p60 is the verified ceiling; 1080p120 and 4K land on the reliable Rust compositor. - Browser-fail → Rust fallback is automatic and lossless to the user. On a
render throw (non-abort),
exportActivityre-enqueues the same params withoutbrowserVideoPath; the Rust compositor rebuilds from scratch (exportActivity). The worker-vs-main-thread layer also self-heals: a worker failure rebuilds a fresh job (bitmaps were transferred away) and retries main-thread (browser-export.ts). - Queue durability. The heavy
ExportRequestpayload is a file underexport_queue/<id>.json; the SQLite row holds only metadata + that path. Enqueue is atomic (write_atomic). A job survives closing its editor (the render queue is app-scoped, the mux queue is backend-owned) and an app restart,reconcile_on_loadflips orphanedrunningrows tointerrupted(export_queue.rs);sweep_stale_jobsGCs terminal rows + orphan payloads . The render queue's own items are local-only until handoff, sorefreshListpreserves them across reconciles (exportActivity). -c:v copy⇒ the browser must render at source-composition resolution. The mux never re-scales video, so the browser renders at the canvas/comp resolution the output needs; only the audio graph is (re)built server-side. The browser video is already warped to the output timeline, sorun_mux_jobapplies cuts/speed to audio only (editor.rs).- Unified progress bar. Browser render owns
0..RENDER_MAX(95); the backend mux is the fastRENDER_MAX..100tail.hasRenderPhaseis a local-only field carried acrossrefreshListso the mapping and total-time telemetry stay correct (exportActivity). renderingInBrowserfreezes the preview (it shares this GPU + decoder) so it stops fighting the export (exportActivity;store.isPlaying = falseat+page.svelte).
Related
- 03-preview-and-rendercore.md: the shared
RenderCore/WebGL2Backendthat composites both preview and export frames. - 04-media-decode-and-workers.md: MediaBunny
decode,
samplesAtTimestamps, and the render-worker ownership pattern. - 07-ipc-and-tauri-boundary.md: the
export-state/export-jobs-changedevent streams,AppErrorboundary, and the raw-bytessave_browser_export_videoinvoke.