@fr0/renderer-browser
v0.1.0
Published
Browser DOM renderer for fr0
Readme
@fr0/renderer-browser
Browser-side React DOM renderer for fr0 + canvas-direct MP4 export pipeline.
Status
✅ Phase α-2 (preview, React DOM) — done
✅ Phase β-1 (MP4 export) — superseded by δ-5
✅ Phase δ-5 (canvas-direct encoder, html2canvas撤廃) — done
🛑 Embed-only henceforth — the React DOM components are kept as a second renderer for IR validation; new visual features land in @fr0/renderer-canvas first.
Purpose (post-δ-5)
This package now plays two roles:
- React DOM preview / embed.
<TimelineRenderer>and the layer components (<TextLayer>,<ShapeLayer>, …) render a Timeline as positioned React DOM. Useful for live preview UIs and for embedding fr0 output inside a React app where DOM (a11y, SEO, click handlers) matters. - Browser-side MP4 export entry point.
renderTimelineToVideodrives an offscreen<canvas>via@fr0/renderer-canvasand pipes the bitmaps intoWebCodecs VideoEncoder+mp4-muxer. The encoder no longer touches the React DOM tree.
The two paths are intentionally independent: a feature added to the IR must be implemented in both renderers (canvas and DOM) before it ships, and any visual divergence between them is treated as an IR-ambiguity bug. This is how the project earns the right to call its IR "renderer-agnostic".
For server-side rendering see @fr0/renderer-node (which runs the same canvas pipeline inside a Playwright Chromium). For playback UI see @fr0/player.
Quick start — preview (React DOM)
import { TimelineRenderer } from '@fr0/renderer-browser';
import type { Timeline } from '@fr0/core';
function Preview({ timeline, frame }: { timeline: Timeline; frame: number }) {
return <TimelineRenderer timeline={timeline} frame={frame} />;
}Stateless — frame is driven by the caller. Playback loops, scrubbing, and controls belong to @fr0/player.
Quick start — MP4 export
import { renderTimelineToVideo } from '@fr0/renderer-browser';
const { blob, frames, width, height, fps } = await renderTimelineToVideo(timeline, {
onProgress: (frame, total) => console.log(`${frame}/${total}`),
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'timeline.mp4';
a.click();
URL.revokeObjectURL(url);Browser-only. Requires WebCodecs VideoEncoder (Chromium-based browsers, Safari 16.4+, Firefox 130+).
For canvas-side custom components (type: 'custom' layers), pass a CanvasComponentRegistry from @fr0/renderer-canvas via the registry option. React COMPONENTS from @fr0/components no longer apply to the export path — they only power the DOM preview.
Public API
Preview (React DOM)
| Export | Purpose |
|---|---|
| <TimelineRenderer> | Top-level preview component. Calls resolve() once per (timeline, frame) via useMemo and renders every layer in z-order inside a canvas-sized root <div>. |
| <LayerRouter> | Dispatches a single ResolvedLayer to the correct concrete component based on layer.type. Returns null for invisible layers. |
| <TextLayer> / <ShapeLayer> / <ImageLayer> / <VideoLayer> / <GroupLayer> / <CustomLayer> | Concrete layer components. Importable directly for custom hosts. |
| applyTransform(resolvedTransform) | Pure helper mapping a ResolvedTransform to the CSSProperties used on every layer element. |
| CustomComponentRegistry / CustomComponentProps | Types for the React-side custom layer registry. |
Encoder (canvas-direct)
| Export | Purpose |
|---|---|
| renderTimelineToVideo(timeline, options) | High-level one-shot: offscreen <canvas> + image / video preload + font wait + frame loop (drawTimeline → VideoFrame → encoder.encode) + mux. Returns { blob, frames, width, height, fps }. |
| preloadImages(timeline) | Walks image layers (including nested group children) and decodes every unique src as HTMLImageElement before the render loop. |
| preloadVideos(timeline) | Walks video layers and creates an HTMLVideoElement per unique src, awaiting loadedmetadata. |
| createWebCodecsEncoder(options) | Low-level WebCodecs VideoEncoder wrapper (H.264, hardware-preferred, 60-frame keyframe cadence). |
| createMp4Muxer(options) | Low-level mp4-muxer wrapper using ArrayBufferTarget with fastStart: 'in-memory'. |
Design notes — preview (React DOM)
- Frame distribution is pure props, not React Context. The renderer is a one-shot data flow from timeline + frame → DOM.
- Visibility is conditional rendering, not CSS. Layers outside their
[from, from + duration)window returnnull. - Transform-origin carries the anchor.
left/topare the raw x/y from core; rotation and scale pivot viatransform-origin: {anchorX*100}% {anchorY*100}%. - The layer router is a closed switch, not a registry. The one exception is the
customlayer type, which uses an explicitMapregistry passed as a prop. - Fill
kind: 'solid'only. The DOM renderer flattensFillValueto a CSS color string and ignores gradient kinds; gradients render correctly through the canvas renderer instead.
Design notes — encoder (Phase δ-5)
- Canvas-direct, no DOM intermediary. The encoder allocates a single offscreen
HTMLCanvasElementoftimeline.width × timeline.heightand callsdrawTimeline(ctx, timeline, frame)from@fr0/renderer-canvas. There is no React tree, no html2canvas, no DOM ↔ bitmap step. - WebCodecs VideoEncoder with codec
'avc1.42001e'(H.264 Baseline 3.0). Widest MP4 playback compatibility. Hardware acceleration is preferred. Keyframes every 60 frames. - mp4-muxer
ArrayBufferTarget+fastStart: 'in-memory'— everything in RAM until finalization, the resulting Blob is playable directly. - Frame-by-frame imperative driving.
renderTimelineToVideoadvances the counter as fast as the canvas + encoder pipeline can absorb — not real-time playback. - Image / video preloading.
preloadImagesandpreloadVideosrun before the first frame so fonts and decoded bitmaps are stable. The video preload also mounts the temporary<video>elements in an offscreen host because some browsers refuse to seek detached video nodes. - Per-frame video seek. Inside the loop,
seekVideosForFramewalks the resolved state, setscurrentTime = startTime + localFrame / fpson each visible video element, andwaitForVideoSeeksblocks until every touched element firesseeked(with a 2 s fallback). - Single source of truth. Preview, browser export, and Node export (via
renderer-node) all run the samedrawTimelinefunction. Visual divergence between preview and export is impossible by construction — the preview path has its own DOM renderer, but the export path is unified across browser and Node.
Testing
pnpm --filter @fr0/renderer-browser test48 tests across 10 files, using vitest + jsdom + @testing-library/react. The DOM components have direct rendering tests; the encoder is exercised end-to-end via the smoke test in @fr0/renderer-node (which actually runs Playwright Chromium and produces a real MP4).
The Vite demo in packages/examples/react-player is the manual end-to-end check — pick a sample, click Export, verify the downloaded MP4 plays.
What this package is NOT
- Not the player UI (see
@fr0/player) - Not the canvas renderer (see
@fr0/renderer-canvas) - Not the components library (see
@fr0/components) - Not a server-side renderer (see
@fr0/renderer-node)
