@fr0/renderer-node
v0.1.0
Published
Render a fr0 Timeline to MP4/WebM/GIF via @napi-rs/canvas + ffmpeg.
Downloads
135
Readme
@fr0/renderer-node
Render a fr0 Timeline to an H.264 MP4 file from Node.js via headless Chromium.
Status
✅ Phase γ (MVP) — done. ✅ Phase δ-6 (canvas-direct via renderer-canvas) — done.
Runs the same renderTimelineToVideo orchestrator from @fr0/renderer-browser inside Playwright-driven Chromium. Phase δ-5 rewired that orchestrator to use @fr0/renderer-canvas directly (no html2canvas, no React DOM in the encoder path), so the bundle this package ships to the browser is now significantly smaller and the per-frame work is significantly faster.
Purpose
Same MP4 as the Vite web demo produces, but callable from a Node script / CLI / CI pipeline. Useful for:
- Automated regression tests that verify the full render pipeline end-to-end
- Batch processing (render many templates in a loop)
- Server-side rendering without pushing work to a user's browser
The output is byte-identical to what the Vite Export button would download, so a regression in the preview-time renderer cannot silently diverge from the Node render path.
Quick start
import { renderTimelineToFile } from '@fr0/renderer-node';
import { buildSample } from '@fr0/templates';
const timeline = buildSample('titleCard', { title: 'Hello' });
const result = await renderTimelineToFile(timeline, {
outputPath: 'title-card.mp4',
onComplete: (bytes) => console.log(`wrote ${bytes} bytes`),
});
console.log(result);
// {
// outputPath: 'title-card.mp4',
// bytes: 351630,
// frames: 120,
// width: 1920,
// height: 1080,
// fps: 30,
// }See packages/examples/cli-render for a CLI wrapper.
How it works
renderTimelineToFile(timeline, { outputPath })
├─ esbuild bundles src/headless/entry.ts in-memory (cached per process).
│ The bundle includes renderer-browser (encoder + DOM components for
│ embed, kept around as the "second renderer"), renderer-canvas
│ (Phase δ-5 — the actual draw path), and mp4-muxer. The headless
│ entry exposes `window.__fr0Render` on load.
│ React / react-dom are still bundled because the renderer-browser
│ package re-exports DOM components even though the encoder path no
│ longer touches them.
│
├─ Writes the bundle into a minimal HTML wrapper, saves it to a temp
│ file, and points Playwright at the file:// URL. (file:// is a secure
│ context in Chrome, so WebCodecs VideoEncoder is available — about:blank
│ / page.setContent is not.)
│
├─ Launches a headless Playwright Chromium with --use-gl=swiftshader
│ and no-GPU software rendering args, suitable for CI / containers.
│
├─ page.goto(file://…/host.html) waits for the bundle to run.
│
├─ page.evaluate((tl) => window.__fr0Render(tl), timeline) triggers
│ the renderTimelineToVideo orchestrator inside Chrome (Phase δ-5):
│ offscreen <canvas> → frame loop → drawTimeline (renderer-canvas)
│ → new VideoFrame(canvas) → WebCodecs VideoEncoder (H.264)
│ → mp4-muxer
│ Returns the MP4 bytes as a plain number[] (Playwright cannot
│ serialize ArrayBuffer through evaluate).
│
├─ Reassembles the number[] into a Node Buffer and fs.writeFile() it
│ to outputPath.
│
└─ Closes the browser + cleans the temp directory.Public API
| Export | Purpose |
|---|---|
| renderTimelineToFile(timeline, options) | Top-level entry point. Returns { outputPath, bytes, frames, width, height, fps }. |
| RenderTimelineToFileOptions | outputPath (required), bitrate?, onComplete?, customizeLaunch? hook for injecting your own Playwright launch options. |
| buildHeadlessBundle() | Low-level: return the bundled browser entry as a string. Cached per process. Useful if you want to host the bundle from your own dev server. |
| clearBundleCache() | Reset the bundle cache. Mainly for tests that mock esbuild. |
Requirements
- Node.js 20+
- Playwright Chromium. Downloaded on
pnpm install(~200 MB binary). Re-runpnpm exec playwright install chromiumif the download was skipped. - WebCodecs
VideoEncoder— available in the full Chromium channel Playwright ships. The strippedchrome-headless-shellvariant does NOT exposeVideoEncoder, which is whyrenderTimelineToFileforces afile://URL (secure context) and uses the fullchromiumbinary.
Known limitations / trade-offs
- First render per process is slow because esbuild has to bundle the encoder + its dependencies. Subsequent calls reuse the cached bundle.
- Software H.264 encoder. Headless Chromium ships the software encoder only; hardware acceleration is advisory. For batch workloads, consider pooling browsers.
- Profile selection. Only Constrained Baseline is used (
avc1.42E0..) because the software encoder in headless Chromium does not reliably accept High Profile. Baseline handles up to 4K fine; interop with old players is a bonus. - Custom layer registry empty. Phase δ-5 dropped the React
COMPONENTSregistry from the encoder path. Sample timelines that use custom-layer components (Counter / ProgressBar / GradientText) render those layers as no-ops in the canvas pipeline. Phase δ-9 promotes the existing components to first-class IR primitives so the escape hatch goes away entirely. - No audio layer yet. When audio support lands in
core, the encoder path and this wrapper will need an AudioEncoder branch. - Video layer in Node is browser-mediated. Because the encoder runs inside Playwright Chromium, video layers Just Work via
<video>+currentTimeseek. There is no separate Node-side video decoder.
Testing
pnpm --filter @fr0/renderer-node testSix tests across two files:
bundle.test.ts(4): real esbuild call, cache identity,clearBundleCache, ensuresrenderTimelineToVideosymbol ends up in the bundlerender-to-file.smoke.test.ts(2): renders a tiny 320×240 / 5-frame Timeline through real Playwright Chromium, verifies file size + MP4ftypmagic bytes +onComplete. Skipped automatically whenchromium.executablePath()does not point at an installed binary.
The full sample-template integration check lives under packages/examples/cli-render:
pnpm --filter @fr0/example-cli-render render --sample titleCard
pnpm --filter @fr0/example-cli-render render --sample kineticTypography
pnpm --filter @fr0/example-cli-render render --sample shapeShowcaseOutputs land under packages/examples/cli-render/out/.
What this package is NOT
- Not a general-purpose video processing library (no trimming, concat, transcoding)
- Not FFmpeg-based — we reuse the browser encoder instead
- Not tied to any cloud provider (runs anywhere Node + Chromium run)
