Export pipeline
August 31, 2026 · View on GitHub
The MP4 export drives the same Rust + Direct3D 11 compositor that powers the
live preview (preview.md /
native-compositor.md), one segment at a time. The
Electron renderer turns the project document into the same SceneDescription
the live preview uses plus an ordered clip list, hands both to the napi addon
through the compositor IPC domain, and the addon drives openscreen-compositor's
Player + Compositor::compose_frame + AMF encoder + muxer to write one
.mp4. The renderer only watches progress; the actual rendering does not
leave the native process. Performance numbers, the bench, and the rejected
alternatives that drove the design live in
engineering/rendering-performance.md.
GIF is out of scope here: it has no native encoder yet, so it keeps its own
dedicated code path in src/lib/exporter/gifExporter.ts — the one format
the document adapter still renders in the renderer.
flowchart LR
DOC["AxcutDocument"]
TSD["src/native/sceneDescription.ts<br/>resolveVisibleClips / buildSceneDescription"]
RP["clips + SceneDescription JSON<br/>layout + effects + background + zoom + cursor + webcam"]
ADDON["compositor_view.node addon<br/>exportMulti (Electron IPC)"]
NATIVE["openscreen_compositor::live::Player<br/>Compositor::compose_frame<br/>h264_amf encoder + mux"]
FILE["output.mp4"]
DOC --> TSD --> RP --> ADDON --> NATIVE --> FILE
Segment loop
The renderer hands the addon exportMulti(clips, outPath, sceneJson, params)
through compositorViewService.exportMulti
(compositorViewService.ts:425).
The service resolves the same asset paths the renderer used (wallpaper
images, cursor theme sprite) and forwards the call to the addon
(compositorViewService.ts:437).
The addon loads openscreen-compositor's Player with the clip list and the shared
SceneDescription JSON, then walks the segments with one compositor
and one encoder + muxer pair:
-
Per segment: load metadata, hand the asset to the
Player(so screen and webcam decode land onopenscreen-compositor's sharedID3D11Device), drivecompose_frameat the segment's source time, and let the AMF encoder consume the rendered RT. The compositor is paused on the live preview for the duration of the export (set_playing(false)on every active preview view), which the addon does automatically — the only cost of running export against a live preview is GPU contention from the preview still composing; the bench measures that overhead at ~10 % of wall time in the measurement scenario, recovered by the auto-pause. -
Time projection. The encoder timestamp is contiguous output time, so junctions between segments are seamless.
compose_framereceives source time (source_t = frame / FPS), so zoom / annotation / cursor match the frame's content even when a speed region retimes the segment. An earlier draft proposed keying effects in virtual time throughout; that does not survive speed regions, and the two-clock split is why. -
Clips are contiguous — no gaps, no overlap. The renderer sums per-segment rounded frame counts into a single output frame counter; audio follows the same integer accumulation (
AudioConcatPlan). -
Audio and video junctions are seamless. Audio is decoded per clip (
audio.rs::decode_clip_audio), a libavfilteratempochain stretches each speed sub-segment to its output sample count, andassemble_concatenated_pcmconcatenates the per-segment PCM at the integer sample offsets the video loop just produced — neverround(cumulativeSec * sampleRate), because that compounds per-segment rounding error into audible A/V drift across a long multi-segment timeline. A short equal-power fade (coson the tail,sinon the head,cos² + sin² = 1) covers each internal boundary to suppress the click where two recordings meet butt-joined, without shifting timing. The in-tree WSOLA stretcher is still there, but only as the fallbackstretch_pcm_to_lengthtakes when the filter chain cannot be built, negotiates a format the drain does not read, or still comes up short of the target after the corrected pass (see Audio). -
The stretch is not overlapped with the encode. Decode and stretch run inside
walk_composited_timeline'son_clip_endcallback (pipeline.rs), which fires once per clip after that clip's frames have been composed and submitted to the encoder, on the same thread — so the stretch time is added to the export wall, not hidden behind it.progress()counts composed frames as they are handed to the encoder, and nothing calls it during the audio phase, so a long clip parks the export at whatever percentage its last frame reported. (The encoder's own flush comes later still, after the timeline walk.) That reporting gap is why theatempopath matters so much here: WSOLA is O(grain × radius) per rendered sample, which on a long clip meant minutes of an apparently frozen export. Reporting progress across the audio phase would fix the symptom rather than the cost, and is tracked separately. -
Output honours the timeline's selected aspect ratio (
resolveAspectRatioValueovergetEditorSettings(document).aspectRatio— the same typed façadebuildSceneDescriptionreads, so the dialog cannot drift from the compositor).ExportDialog'stierOutputDimsfeeds the crop-aware smallest clip on the timeline tocalculateMp4ExportSettings, which maps quality + source dims + aspect ratio to the encoder width / height / bitrate, and passeswidth/heighttoexportMulti. Only "Source" quality targets those source dims; 720p / 1080p target a fixed short side regardless.
Output formats and codecs
The native MP4 export takes width, height, frameRate, and codec as
parameters on exportMulti and writes H.264 (AMF) by default. The
user-facing codec choice crosses as the plain ExportVideoCodec string
("h264" / "h265" / "vp9") in those params; VP9
falls back to the same H.264 path on machines without a hardware VP9
encoder (software VP9 was measured too slow and removed — see
native-compositor.md). GIF is a
separate path through GifExporter and does not use the native addon.
Licensing
The app is MIT and stays MIT. Any bundled ffmpeg must be built without
--enable-gpl and without --enable-nonfree — those flags pull
x264/x265/xvid and fdk-aac, and licensing is all-or-nothing. The same
rule applies to the BtbN build the addon links against (see
native-compositor.md): LGPL-shared
*lgpl-shared, not GPL. scripts/fetch-ffmpeg.mjs vendors a pinned,
checksum-verified BtbN LGPL build and gates it on three independent
signals (-L says "Lesser General Public License"; no GPL flags or GPL
libs in -buildconf/-version; no libx264 / libx265 in
-encoders). It fails closed.
Note ffmpeg -version has no License: line — only configuration:.
The licence text is behind -L. An early gate looked for the former,
found nothing, and refused to vendor anything; failing closed is why that
was a bug and not an incident.
Traps this pipeline has actually fallen into
Each cost hours and each produced a confident, wrong conclusion.
app.getGPUFeatureStatus()from a windowless script reports everythingdisabled_software. Always probe with a real window.- Piping via
catunder Git Bash caps at ~70 MB/s — MSYS emulation, not Windows. new VideoFrame(canvas)is lazy. Timing the constructor measures nothing.- Isolated component benchmarks cannot price the cost of connecting
the component. A
node → ffmpegprobe measured 489–589 MB/s by materialising frames once, outside the timed loop — a true statement about the pipe that said nothing about the pipeline. -encoderslists what was compiled in, not what the machine can run. A portable build lists nvenc/qsv/amf everywhere; on this AMD laptop nvenc dies with "Cannot load nvcuda.dll". Only a one-frame smoke encode settles it — and the unit tests passed because the fixtures encoded the same wrong assumption as the code.- Electron cannot transfer an
ArrayBufferrenderer→main. The transfer list takesMessagePort[]; transferring a buffer silently drops the whole message (electron#34905) — it works renderer→renderer. Buffer.from(typedArray)copies. Wrapping (Buffer.from(buf.buffer, byteOffset, byteLength)) measured +31 %.- A stale
dist-electronbundle runs the previous main process against the new renderer. It read as "export IPC not registered" once and as "the bench flag does nothing" once. The bench now refuses to run against one. - A second instance of the same build quits silently. The lock keys
on the
userDatapath, so another dev build already running makes a launch exit 0 and report nothing. The installed app (openscreen.exe) resolves a differentuserDatapath and does not conflict.
A truncated project file is unopenable, not partially readable
listProjects skips a project whose JSON does not parse, so a truncated file
presents as a project that has vanished rather than as an error. Worth knowing
when a bench fixture disappears.
Two concurrent saves used to be able to produce exactly that. They no longer can:
DocumentService serialises saves through a per-project write queue and writes
atomically (unique temp file → fsync → rename, electron/ai-edition/document-service.ts:354).
One fixture from before the fix is still corrupt — see the Known gaps in
../engineering/rendering-performance.md.
The reason that one was never repaired is the useful part: its recoverable prefix
had speedRegions: 0, zoomRegions: 0 while the real timeline held two 3× speed
regions and a 1.80× zoom. Truncating to the valid prefix would have returned a
project that opened cleanly and was silently stripped of its effects. A partial
document that parses is more dangerous than one that does not — which is why the
loader rejects rather than salvages.