---
title: Capture and export
description: Read finished frames as pixels, encode stills, and write a video file from a mounted composition.
---

Capture asks a mounted composition for one frame, waits until the frame is ready, and reads
its pixels while nothing else may paint. Export captures every frame of a range that way and
feeds the pixels to a video encoder. Both run on the same page a viewer previews, so the file
shows exactly what the preview shows.

## Render a file

`renderHeadless` from `@motionactor/engine/node` is the whole pipeline in one call. It builds
the composition into a page, opens that page in headless Chromium, captures each frame and
writes the video:

```ts twoslash title="render.ts"
import { resolve } from "node:path";
import { exportFormats } from "@motionactor/engine/export";
import { renderHeadless } from "@motionactor/engine/node";

const result = await renderHeadless({
  entry: {
    entry: "./src/title-card.tsx",
    export: "titleCard",
    props: { title: "Hello" },
  },
  format: exportFormats.mp4H264,
  output: resolve("out/title-card.mp4"),
  poster: { frame: 45, output: resolve("out/title-card.jpg") },
  onProgress: ({ done, total }) => console.log(`${done}/${total}`),
});

result.frames; // 60
result.durationSeconds; // 2
```

- It needs two optional peers: `playwright-core`, with a browser installed by
  `playwright-core install chromium --only-shell`, and `@mediabunny/server` to encode in Node.
- The video is written beside `output` under a temporary name and renamed when it is
  complete. On a failure or an abort through `signal`, the browser is closed and neither the
  video nor the poster is left behind.
- Each frame has `frameTimeoutMs` (5000 by default) to render, settle and be read. A frame
  that misses it fails the render with "Frame 12 did not settle within 5000ms".
- `gl: "angle"`, the default, draws WebGL on the GPU where there is one and in software where
  there is not. `gl: "swangle"` always draws in software, which gives the same pixels on
  every machine. Software GL compiles each shader on first use, so a heavy WebGL scene may
  need a longer `frameTimeoutMs`.
- Exported video has no sound yet. Audio from `Audio` and `Video` actors plays in previews
  but is not mixed into the file.

## Stills and several exports

`openHeadlessSession` keeps one browser and page open on a composition. A still reads only
its frame, and further exports reuse the page without building it again. `renderHeadless`
is a session, a still for the poster, an export and `close`.

```ts twoslash title="session.ts"
import { resolve } from "node:path";
import { exportFormats } from "@motionactor/engine/export";
import { openHeadlessSession } from "@motionactor/engine/node";

const session = await openHeadlessSession({
  entry: { entry: "./src/title-card.tsx", export: "titleCard" },
});
try {
  const jpeg = await session.still(45); // reads frame 45 and nothing else
  jpeg.byteLength;
  await session.export({
    format: exportFormats.mp4H264,
    output: resolve("out/opening.mp4"),
    range: { start: 0, end: 30 },
  });
} finally {
  await session.close();
}
```

- Stills and exports run one at a time, in call order: the page holds one frame at a time.
- `signal` on `openHeadlessSession` cancels the build, the launch and the page load. On `still`
  or `export` it cancels that call only, and the session stays open.
- An open that fails or is aborted closes the browser before it rejects. `close` waits for the
  running call, then releases the browser, the server and the build. Calling it again does nothing.

## Formats

| `exportFormats.` | File | Codec | Alpha |
| --- | --- | --- | --- |
| `mp4H264` | `.mp4` | H.264 | Discarded |
| `prores4444` | `.mov` | ProRes 4444 | Kept |
| `webmVp9` | `.webm` | VP9 | Discarded |
| `webmVp9Alpha` | `.webm` | VP9 | Kept |

Each format has a `label` ("ProRes 4444 MOV") and an `extension`, so a save dialog can list
them directly. A format that keeps alpha captures over a transparent page, so only what the
scene paints is opaque. Those captures are PNG screenshots, which are slower than the JPEG
ones an opaque export reads.

## Capture

Under `renderHeadless` are two pieces you can use on their own. A capture host reads pixels.
A capture controller reserves a frame so that no seek, prop update or other capture can paint
over it while the host reads. `mountComposition` returns the controller as `preview.capture`.

```ts twoslash title="poster.ts"
// @jsx: react-jsx
// @filename: title-card.tsx
import { createScene, input, LayoutActor, signal } from "@motionactor/core";
import { text } from "@motionactor/schema";
import {
  createRenderRuntime,
  defineComposition,
} from "@motionactor/renderers/react";
import { dom } from "@motionactor/dom/react";
class Title extends LayoutActor { text = signal(""); }
const titleScene = createScene(function* (ctx, { title }) {
  const heading = ctx.spawn(Title, { text: title, appearance: { opacity: 0 } });
  yield* heading.appearance.opacity.tween(1, 0.5);
  yield* ctx.wait(1.5);
  return { heading };
}, { inputs: { title: input.of(text, "Launch") } });
export const titleCard = defineComposition({
  id: "TitleCard", width: 1920, height: 1080, fps: 30, defaultProps: { title: "Launch" },
  runtime: createRenderRuntime({ backends: [dom] }),
  scene: titleScene,
});
// @filename: poster.ts
import { mountComposition } from "@motionactor/engine/react";
import { titleCard } from "./title-card";
const preview = await mountComposition({
  composition: titleCard,
  element: document.getElementById("stage")!,
  props: titleCard.defaultProps,
});
// ---cut---
import {
  captureStill,
  createCompositionCapture,
} from "@motionactor/engine/capture";

// Whatever can read this page's pixels.
declare function readStage(signal: AbortSignal): Promise<ImageBitmap>;

const capture = createCompositionCapture({
  controller: preview.capture,
  viewport: preview.viewport,
  host: {
    frameSize: { width: 1920, height: 1080 },
    read: async (signal) => ({
      kind: "image",
      image: await readStage(signal),
    }),
    dispose() {},
  },
});

const poster = captureStill(capture, 45);
const jpeg = await poster.jpeg(0.85);
const png = await poster.png(); // the same capture, encoded again
```

`read` is the one part that depends on where the page runs. It returns either an image the
browser can draw (`ImageBitmap`, `OffscreenCanvas`, a canvas element) or raw RGBA or BGRA
bytes. A host that returns bytes also supplies `encode` for stills.

`@motionactor/engine/capture/cdp` has the host `renderHeadless` uses. `createCdpCaptureHost`
takes a Chrome DevTools Protocol session, which Electron's `webContents.debugger` already is
and a Playwright `CDPSession` becomes through a small adapter, and reads each frame as a
`Page.captureScreenshot`. Call `emulateCapturePage` first so that frames come out in
composition pixels on a high-density display.

`createCompositionCapture` throws when the host's frame size differs from the composition's.
A capture that runs out of time throws a `CaptureError` with code `timeout` and the labels of
the holds that never released in `error.holds`, so the message names the font, video or
shader that kept the frame waiting.

## Export

`exportComposition` from `@motionactor/engine/export` captures a half-open range of frames
and encodes them into a [Mediabunny](https://mediabunny.dev) target. In a browser the encoder
is WebCodecs; in Node, call `registerMediabunnyServer()` from `@mediabunny/server` first.

```ts twoslash title="export.ts"
// @jsx: react-jsx
// @filename: title-card.tsx
import { createScene, input, LayoutActor, signal } from "@motionactor/core";
import { text } from "@motionactor/schema";
import {
  createRenderRuntime,
  defineComposition,
} from "@motionactor/renderers/react";
import { dom } from "@motionactor/dom/react";
class Title extends LayoutActor { text = signal(""); }
const titleScene = createScene(function* (ctx, { title }) {
  const heading = ctx.spawn(Title, { text: title, appearance: { opacity: 0 } });
  yield* heading.appearance.opacity.tween(1, 0.5);
  yield* ctx.wait(1.5);
  return { heading };
}, { inputs: { title: input.of(text, "Launch") } });
export const titleCard = defineComposition({
  id: "TitleCard", width: 1920, height: 1080, fps: 30, defaultProps: { title: "Launch" },
  runtime: createRenderRuntime({ backends: [dom] }),
  scene: titleScene,
});
// @filename: export.ts
import { mountComposition } from "@motionactor/engine/react";
import { createCompositionCapture } from "@motionactor/engine/capture";
import { titleCard } from "./title-card";
const preview = await mountComposition({
  composition: titleCard,
  element: document.getElementById("stage")!,
  props: titleCard.defaultProps,
});
declare function readStage(signal: AbortSignal): Promise<ImageBitmap>;
const capture = createCompositionCapture({
  controller: preview.capture,
  viewport: preview.viewport,
  host: {
    frameSize: { width: 1920, height: 1080 },
    read: async (signal) => ({ kind: "image", image: await readStage(signal) }),
    dispose() {},
  },
});
// ---cut---
import { BufferTarget } from "mediabunny";
import { frameStateKey } from "@motionactor/core";
import {
  exportComposition,
  exportFormats,
} from "@motionactor/engine/export";

const target = new BufferTarget();

const result = await exportComposition({
  captures: [
    {
      ...capture,
      stateKey: async (frame) =>
        frameStateKey(preview.compiled.at(frame)),
    },
  ],
  frames: { start: 0, end: preview.viewport.durationInFrames },
  fps: preview.viewport.fps,
  target,
  format: exportFormats.webmVp9,
});

result.repeatedFrames; // 44: the title is still from frame 15 on
const file = new Blob([target.buffer!], { type: "video/webm" });
```

- `frames` is `[start, end)`. An empty range throws an `ExportError` with code `empty-range`,
  and a missing encoder throws one with code `no-encoder` before any frame is captured.
- Pass several captures of the same page, mounted separately, to capture several frames at
  once. Each takes the lowest frame nobody has taken, and finished frames wait in order for
  the encoder. One capture still reads the next frame while the encoder works.
- `stateKey` lets export skip frames that draw nothing new. `frameStateKey` from
  `@motionactor/core` turns a resolved frame into a string that is equal only when the
  actors, layers and camera are. When a frame's key matches the one before it, the encoder
  writes the previous picture again.
- `signal` aborts. On an abort or any failure every capture is stopped, the output is
  cancelled, and the reason is thrown. Removing a partly written file is the caller's job.

A renderer that draws from the frame number rather than from actor state, such as a noise
shader keyed to time, makes two frames with equal state look different. Its class declares
`static frameDriven = true`, which puts the frame number into the key.

## Browser requirements

DOM effects of the `glShader` and `canvasPaint` kinds draw HTML into a canvas, which needs a
Chromium feature that is off by default. `captureBlinkFeatures` lists it. A browser that
captures has to be launched with it:

```ts twoslash
import { captureBlinkFeatures } from "@motionactor/engine/capture";

const flag = `--enable-blink-features=${captureBlinkFeatures.join(",")}`;
// "--enable-blink-features=CanvasDrawElement"
```

`renderHeadless` launches Chromium with it already, along with an sRGB colour profile so
captured colours equal the CSS colours on any display.
