Skip to content

Video Export ​

boundsvg ships no video codec. H.264 carries patents and ffmpeg is LGPL/GPL, and neither belongs inside an MIT-licensed engine. What boundsvg provides is the deterministic part — the PNG frame sequence — and two ways to hand it to an encoder that is already on the machine.

WhereEncoderPackage
BrowserThe browser's own WebCodecs VideoEncoder@boundsvg/video (opt-in)
Node / CLIAn ffmpeg you installed@boundsvg/cli, --format mp4

Cloudflare Workers has no WebCodecs, so browser MP4 export does not run there.

Browser ​

bash
npm install @boundsvg/video
ts
import { renderToMp4 } from "@boundsvg/video";

const mp4: Uint8Array = await renderToMp4(engine, scene, {
  durationMs: 2000,
  frameRate: 30,
});

renderToMp4 samples the animation through the same deterministic frame sampler renderFrames uses, encodes each frame with the browser's H.264 encoder, and writes the container with a small bundled muxer wasm. The muxer is the only binary that ships; the codec is the browser's.

To encode a frame sequence you already have — from a worker, or from a previous run — use encodePngFramesToMp4 instead:

ts
import { encodePngFramesToMp4 } from "@boundsvg/video";

const mp4 = await encodePngFramesToMp4(frames, { frameRate: 30 });

Requirements ​

WebCodecs VideoEncoder with H.264 support. Feature-detect before offering the option, rather than failing on click:

ts
if (typeof VideoEncoder !== "undefined") {
  // MP4 export is possible; a browser can still refuse the codec itself,
  // which surfaces as VIDEO_ENCODER_UNSUPPORTED.
}

Node and the CLI ​

Install ffmpeg yourself — boundsvg neither bundles nor downloads it.

bash
# macOS
brew install ffmpeg
# Ubuntu
sudo apt install ffmpeg
# Windows
winget install Gyan.FFmpeg
bash
boundsvg export --input scene.svg --output out.mp4 --format mp4 \
  --default-font NotoSansJP --font NotoSansJP:400:normal:./NotoSansJP.ttf \
  --duration-ms 2000

ffmpeg comes from FFMPEG_PATH when that is set, and otherwise from PATH. It is not a fallback chain: an FFMPEG_PATH that does not run is an error rather than a reason to try PATH. boundsvg doctor reports the command it tried; a missing ffmpeg is reported there as information, not as a failed check, since only this one format needs it.

29.97fps and the other NTSC rates ​

The NTSC rates are not decimals. 29.97fps is exactly 30000/1001, and no decimal expresses it — an export that rounds drifts against its own timeline.

--fps therefore accepts a whole number, one of the NTSC aliases, or an explicit rational:

bash
boundsvg export ... --format mp4 --duration-ms 2000 --fps 30000/1001
boundsvg export ... --format mp4 --duration-ms 2000 --fps 29.97      # same thing

Any other decimal is refused rather than approximated. The browser exporter takes the same three forms — frameRate: 29.97 or frameRate: { numerator: 30000, denominator: 1001 }.

The browser exporter writes the container itself, so it can be exact by construction: timescale = numerator, every sample lasting denominator ticks. A 3600-frame 30000/1001 clip lands on 3600 × 1001 ticks with no residue.

The CLI hands ffmpeg the rational rate as -framerate 30000/1001 and ffmpeg owns the container, including which timebase it writes — a 30fps export comes out with timescale 15360 and a delta of 512, not 30 and 1. The rate is still exact; the timescale is simply ffmpeg's to choose.

Determinism ​

Encoded video is outside the byte determinism contract. The bytes depend on the encoder that produced them — a browser version, a hardware encoder, an ffmpeg build. Two runs on different machines will not match.

What stays deterministic is the input: the PNG frames the sampler produces. Decoding, compositing and encoding them are the platform's work. See the Determinism Contract.

Limits ​

  • H.264 only. No VP9, AV1, or WebM.
  • Video only — no audio track.
  • H.264 in yuv420 needs even dimensions and carries no alpha, so frames are padded to even sizes on the right and bottom and composited over an opaque background. The browser exporter takes a background option, default #ffffff, and rejects a translucent one rather than compositing it against black; the CLI always pads with white.
  • 3600 frames per export on both paths. A durationMs too short to fill two frames rounds up to two rather than being refused, on both paths as well; the browser exporter refuses a shorter schedule only when you supply the frames or the times yourself.
  • 120fps ceiling on both paths.
  • No variable frame rate, and no fragmented MP4.
  • The browser exporter caps one export at 256 MiB of encoded video.
  • The CLI cannot write MP4 to stdout: the faststart pass has to seek.

In the browser exporter the default codec string is avc1.640028 — H.264 High profile, level 4.0, which covers 1080p30. Larger or faster exports need a higher level, or the browser rejects the configuration with VIDEO_ENCODER_UNSUPPORTED. The CLI has no equivalent flag; libx264 picks its own level.

ts
await renderToMp4(engine, scene, {
  durationMs: 2000,
  frameRate: 60,
  codec: "avc1.640033", // level 5.1 — 1080p60, 4K
});

The browser path additionally requires one encoded sample per frame, in order. A platform that emits B-frames or drops frames fails the export with VIDEO_SAMPLE_ORDER_INVALID or VIDEO_SAMPLE_COUNT_MISMATCH rather than producing a file that plays wrong.

Structured Video diagnostics ​

Encoder, frame preparation and muxer failures use the existing FatalError from @boundsvg/core. These diagnostics have a fixed code, message and stage, with domain: "video", category and operation in context. Only validated native muxer failures include context.reason. Absent context fields are omitted. External exception messages, URLs and sample bytes are not copied into diagnostics.

CodeCategoryStageMessage
VIDEO_ENCODER_UNSUPPORTEDencoderUnsupportedemitVideo encoding is unavailable for this configuration
VIDEO_ENCODER_FAILEDencoderFailureemitVideo encoding failed
VIDEO_MUXER_LOAD_FAILEDloadwasmMP4 muxer could not be initialized
VIDEO_MUXER_ABI_MISMATCHprotocolwasmMP4 muxer schema does not match this package
VIDEO_MUXER_INVALID_INPUTinvalidInputvalidateMP4 muxer input is invalid
VIDEO_MUXER_MISSING_INPUTmissingInputemitMP4 muxer requires an input that was not supplied
VIDEO_MUXER_INVALID_STATEinvalidStateemitMP4 muxer operation is invalid in its current state
VIDEO_MUXER_RESOURCE_LIMITresourceemitMP4 output exceeds the supported resource limit
VIDEO_MUXER_ALLOCATION_FAILEDresourceemitMP4 muxer could not allocate output storage
VIDEO_MUXER_WRITE_FAILEDcontaineremitMP4 container assembly failed
VIDEO_MUXER_PROTOCOL_ERRORprotocolwasmMP4 muxer returned an invalid failure
VIDEO_SAMPLE_ORDER_INVALIDsampleOrderemitEncoded samples are not in presentation order
VIDEO_SAMPLE_COUNT_MISMATCHsampleCountemitEncoded sample count does not match submitted frames
VIDEO_FRAME_PREPARATION_FAILEDframePreparationemitVideo frame could not be prepared
ts
import { FatalError } from "@boundsvg/core";
import { initVideoWasm } from "@boundsvg/video";

try {
  await initVideoWasm();
} catch (error) {
  if (error instanceof FatalError && error.code === "VIDEO_MUXER_LOAD_FAILED") {
    console.error(error.code, error.context?.operation);
  } else {
    throw error;
  }
}

Direct initVideoWasm() calls now reject with typed load or ABI diagnostics, so replace raw exception/message matching with FatalError.code checks. Failed initialization can be retried with a later input; concurrent calls share initialization, and the first successful input remains authoritative. The bundled MP4 glue and binary must use the same independent schema revision. An ABI mismatch is checked again on retry; if the generated glue already retains an incompatible instance, load a fresh module realm with the matching binary. Retrying alone does not replace that instance.

VIDEO_ENCODER_UNSUPPORTED now means missing runtime capability or rejected encoder configuration. Encoding execution failures use VIDEO_ENCODER_FAILED; missing input, invalid input, container assembly, resource limits and sample order/count each have their own codes. Capability checks occur when used, preserving bitmap → canvas → encoder ordering. A failed or finished container writer cannot be reused. Cleanup does not replace a primary failure. Frame-producer, progress and warning callback failures propagate unchanged, so not every failure has a VIDEO_ code.