Browser
MP4 or WebM export in the page.
exportProject renders the project frame by frame and encodes it with WebCodecs. It uses the same compositor as the preview, so the file matches what the player shows. It runs as fast as decode and encode allow, not in real time.
import { exportProject } from "@miraiclip/renderer";
const bytes = await exportProject(project, {
format: "mp4",
quality: "standard",
onProgress: ({ phase, framesDone, totalFrames }) => console.log(phase, framesDone, totalFrames),
});
const file = new Blob([bytes as BlobPart], { type: "video/mp4" });exportProject runs on the calling thread. To keep the page responsive, run the same export in a worker: Web worker. For a ready-made button with progress and cancel, see Export UI.
Options
| Option | Default | What it does |
|---|---|---|
format | required | "mp4" (H.264 + AAC) or "webm" (VP9 + Opus) |
quality | "standard" | "draft", "standard", "high", or { videoBitrate } in bits per second. Changes bitrate only, not size or frame rate |
fps | project frame rate | A number (29.97 is read as 30000/1001) or { num, den } |
width, height | project size | Output size in pixels |
range | 0 to the end of the last clip | { startUs, endUs }. Output timestamps start at startUs |
target | none | A WritableStream to stream the file into. See Stream to disk |
audioChunkSeconds | 60 | Audio is mixed in chunks of this length. Use whole seconds |
signal | none | AbortSignal. The promise rejects with ExportAbortedError |
onProgress | none | Called with an ExportProgress (below) |
factories | built-in kinds | Scene-node factories for custom clip kinds. Pass the same ones as the player |
openDemuxer, createDecoder, openAudio | mediabunny + WebCodecs | Media adapters |
Video decode is capped at 2× the output's longest side. For uncapped decoding, pass createDecoder: createWebCodecsDecoder (exported by @miraiclip/renderer).
An empty project (no clips) throws nothing to export: the composition is empty.
Progress
| Field | When | Meaning |
|---|---|---|
phase | always | "audio", "video" or "finalizing" |
framesDone, totalFrames | always | Video frames encoded so far, out of the total |
audioMixedUs, audioTotalUs | "audio" phase | Timeline µs mixed so far, out of the range |
import { exportProject } from "@miraiclip/renderer";
const bytes = await exportProject(project, {
format: "webm",
quality: "high",
fps: 30,
width: 1280,
height: 720,
range: { startUs: 5_000_000, endUs: 20_000_000 },
onProgress: ({ phase, framesDone, totalFrames, audioMixedUs, audioTotalUs }) => {
if (phase === "audio") console.log(`audio ${audioMixedUs}/${audioTotalUs} µs`);
else console.log(`${phase} ${framesDone}/${totalFrames}`);
},
});Cancel
import { ExportAbortedError, exportProject } from "@miraiclip/renderer";
const controller = new AbortController();
// cancelButton.onclick = () => controller.abort();
try {
const bytes = await exportProject(project, { format: "mp4", signal: controller.signal });
} catch (error) {
if (!(error instanceof ExportAbortedError)) throw error;
// cancelled: encoders are released
}Stream to disk
Without a target, the whole file is held in memory until the promise resolves. For long exports, stream it into a file instead. Each chunk is { type: "write", data, position }, which FileSystemWritableFileStream.write accepts as-is.
import { exportProject } from "@miraiclip/renderer";
// The File System Access API (Chromium). Not in TypeScript's DOM types yet.
declare function showSaveFilePicker(options?: { suggestedName?: string }): Promise<FileSystemFileHandle>;
const handle = await showSaveFilePicker({ suggestedName: "export.webm" });
const writable = await handle.createWritable();
const bytes = await exportProject(project, { format: "webm", target: writable });
bytes.length; // 0: the file went to disk| Behavior | Detail |
|---|---|
| Return value | An empty Uint8Array |
| Write positions | May go backwards: the container patches its header at the end |
| Backpressure | A slow stream slows the encoders, so memory stays bounded |
| Closing | exportProject closes the stream when it finishes. Don't call writable.close() after it |
| Cancelling | The stream is closed too, so the file may hold partial output |
Worker exports are different
With exportProjectInWorker / exportViaWorker, the stream is left open. Close it yourself. See Web worker.
Codec support
Before the first frame, exportProject checks that the browser can encode the format. If it can't, it rejects with UnsupportedMediaError. Its codec names what's missing: "avc" or "aac" for MP4, "vp9" or "opus" for WebM. Some Chromium builds have no H.264 encoder. Fall back to WebM:
import { UnsupportedMediaError, exportProject, type ExportFormat } from "@miraiclip/renderer";
async function exportWithFallback(): Promise<{ bytes: Uint8Array; format: ExportFormat }> {
try {
return { bytes: await exportProject(project, { format: "mp4" }), format: "mp4" };
} catch (error) {
if (!(error instanceof UnsupportedMediaError)) throw error;
return { bytes: await exportProject(project, { format: "webm" }), format: "webm" };
}
}| Helper | Returns |
|---|---|
isWebCodecsSupported() | true when the browser has the WebCodecs decode pipeline |
hasHardwareVideoEncoder(format, width, height) | true when the format's video codec has a hardware encoder at that size. false doesn't mean export fails: a software encoder may still work |
Memory
| Source | Bounded by |
|---|---|
| Encoded file | target. Without one, the whole file is in memory |
| Audio mix | audioChunkSeconds. About 23 MB of PCM per minute at 48 kHz stereo |
| Frames in flight | A small fixed window between render and encode |
| Decoded video | The 2× decode cap above |
For hour-long exports, set a target. To render on a server instead, see Server (Node).