Skip to content

Server side

@miraiclip/server-export runs exportProject on a server: pass a project document as JSON from Node, and the package launches headless Chrome, hydrates the project in a harness page, and runs the exact same export pipeline the browser uses. That identity is the design — the server cannot render differently from the preview, because it runs the preview’s code.

import { exportProjectFile } from "@miraiclip/server-export";

const { filePath, bytesWritten } = await exportProjectFile(doc, {
  format: "mp4",
  quality: "high",
  out: "render/final.mp4", // streams to disk as it encodes
  assets: { media: "./media/interview.mp4" }, // asset id → local file
  onProgress: (p) => console.log(p.phase, p.framesDone, "/", p.totalFrames),
});

Or from the command line:

miraiclip-export project.json --out final.mp4 --quality high \
  --asset media=./media/interview.mp4

Try it

The repo ships a runnable example — a ready-made project.json (the e2e fixture video plus a text overlay), the CLI invocation, and a Node script: examples/server-export.

The browser

The package depends on lightweight playwright-core and does not download a browser at install. It launches, in order: browser.executablePath → the MIRAICLIP_BROWSER env var → the machine’s installed Google Chrome. Real Chrome is the recommended runtime because it ships the proprietary H.264/AAC encoders MP4 export needs — free Chromium builds (including Playwright’s default download and most Docker images) export WebM only; MP4 fails their up-front codec probe with a clear error. On a bare server, npx playwright install chrome installs branded Chrome; any Chrome/Chromium path works via executablePath. On Linux the page renders WebGL on SwiftShader (software GL) by default — GPU-less servers stall on Chrome’s default ANGLE.

Assets

The document’s asset src values must be reachable from the server. Three ways, checked in order per asset: an explicit assets: { "<asset id>": "<local path>" } entry; an http(s)/data: src, fetched as-is; otherwise the src is treated as a local path resolved against assetsDir (the CLI defaults it to the project file’s directory). Local files are served to the page over a loopback-only static server that answers Range requests — media decode seeks by byte range, so a naive server would re-download the file per seek. A missing file fails fast, naming the asset, before any browser launches.

Progress, cancellation, output

onProgress receives the same phases as the browser export (audio with audioMixedUs/audioTotalUs, video with framesDone/totalFrames, finalizing), forwarded live across the process boundary. An AbortSignal cancels cleanly mid-export — the in-page export aborts, encoders are released, and the call rejects with ServerExportAbortedError (the CLI wires this to Ctrl-C).

With out, the encoded chunks stream from the browser into the file as they are produced (directories created; a failed export removes the partial file): peak memory stays flat however long the output is, and the result is { filePath, bytesWritten } — no bytes in memory. Streamed progress events also carry bytesWritten (bytes on disk so far — live file size and MB/s; note total output size is the encoder’s decision, so framesDone / totalFrames is the true completion fraction, and a remaining-bytes estimate is bytesWritten × (totalFrames − framesDone) / framesDone; output flushes in ~16 MiB chunks, so short exports may read 0 until finalize). Want the bytes in memory too? Read them back: await readFile(result.filePath). Without out, the whole file comes back as { bytes }; fine for short outputs, but the in-page → Node hop transiently costs about twice the file size, so give long exports an out path. format, quality, fps, width/height, range, and audioChunkSeconds pass straight through to exportProject’s options; audio always mixes in bounded chunks, so timeline length does not grow memory either.

How it stays honest

The harness bundle is built from the workspace at publish time@miraiclip/core, @miraiclip/renderer, and their dependencies are baked in, so the package is version-locked to the renderer it shipped with and consumers install nothing browser-side. The integration suite drives a real headless Chromium end to end and verifies the output container with an independent parser: duration, dimensions, exact range lengths, mid-export aborts, and the CLI itself — on every CI run.