Call renderToVideo on the root ef-timegroup element to encode the composition to MP4 entirely in the browser using WebCodecs — no server required.
Basic usage
const timegroup = document.getElementById("root");
const { buffer, mimeType } = await timegroup.renderToVideo({ fps: 30, videoCodec: "avc" });
const blob = new Blob([buffer], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement("a"), { href: url, download: "output.mp4" });
a.click();buffer holds the finished MP4. It is omitted when you pass a custom mediabunny target, which receives the output instead.
Progress
Pass onProgress to receive an update after each encoded frame:
await timegroup.renderToVideo({
fps: 30,
onProgress: ({ frame, totalFrames, estimatedRemainingMs }) => {
progressBar.style.width = `${(frame / totalFrames) * 100}%`;
console.log(`frame ${frame}/${totalFrames}, ETA ${estimatedRemainingMs}ms`);
},
});Each update also carries time and duration in composition seconds, elapsedMs, and speedMultiplier (composition seconds rendered per wall-clock second). estimatedRemainingMs is based on the mean cost per frame so far.
Live frame preview
framePreviewCanvas is a thumbnail of the last captured frame, redrawn every progressPreviewInterval frames (15 by default). The same canvas is passed on every update, so attach it once:
await timegroup.renderToVideo({
fps: 30,
progressPreviewInterval: 15,
onProgress: ({ frame, totalFrames, framePreviewCanvas }) => {
progressBar.style.width = `${(frame / totalFrames) * 100}%`;
if (framePreviewCanvas && !framePreviewCanvas.isConnected) {
previewContainer.replaceChildren(framePreviewCanvas);
}
},
});Set progressPreviewInterval: 0 to skip the preview.
Audio and text
Export uses the same source trims and playbackRate as preview, including pitch-preserving audio speed changes. A media element's volumeAtTime callback is evaluated for each output audio sample; see volume and fades.
Text capture retains computed styles, including default values such as normal. Relative @font-face URLs resolve against the stylesheet that declares them. Use CSS line-height to choose your title spacing and ensure the browser can load the font files.
Export after development hot reload
If a hot reload replaces media classes while their custom elements remain registered, export stops with “Reload the page to export”. Reload the page, then retry. This prevents an export with missing audio from stale development elements.
Cancellation
Pass an AbortSignal to stop mid-render:
const controller = new AbortController();
cancelButton.onclick = () => controller.abort();
await timegroup.renderToVideo({
fps: 30,
signal: controller.signal,
onProgress: ({ frame, totalFrames }) => {
progressBar.style.width = `${(frame / totalFrames) * 100}%`;
},
});Options
| Option | Type | Default | Description |
|---|---|---|---|
fps | number | the timegroup's fps | Frames per second |
width / height | number | the timegroup's size | Output size in pixels |
from / to | number | whole composition | Render a range, in composition seconds |
videoCodec | "avc" | "hevc" | "vp9" | "av1" | "vp8" | "avc" | Output video codec |
videoBitrate | number | Quality | QUALITY_HIGH | Bits per second, or a mediabunny quality preset |
includeAudio | boolean | true | Mix audio tracks into the output |
scale | number | 1 | Downscale the encoded output (0.5 = half size) |
onProgress | (p: RenderProgress) => void | — | Called after each encoded frame |
progressPreviewInterval | number | 15 | Redraw framePreviewCanvas every N frames (0 = off) |
signal | AbortSignal | — | Cancellation signal |
React
Use TimelineRoot to register a React composition for render cloning, then call renderToVideo on its live root timegroup:
import { useRef } from "react";
import { TimelineRoot, Timegroup, Text } from "@editframe/react";
import type { EFTimegroupElement } from "@editframe/elements";
function MyComposition({ id }: { id: string }) {
return (
<Timegroup id={id} duration="3s" style={{ width: 1920, height: 1080 }}>
<Text>Hello from React</Text>
</Timegroup>
);
}
function App() {
const hostRef = useRef<HTMLDivElement>(null);
async function handleRender() {
const timegroup = hostRef.current?.querySelector<EFTimegroupElement>("ef-timegroup");
if (!timegroup) return;
const { buffer, mimeType } = await timegroup.renderToVideo({
fps: 30,
onProgress: ({ frame, totalFrames }) => console.log(`${frame}/${totalFrames}`),
});
// download new Blob([buffer], { type: mimeType }) …
}
return (
<>
<div ref={hostRef}>
<TimelineRoot id="root" component={MyComposition} />
</div>
<button onClick={handleRender}>Render</button>
</>
);
}TimelineRoot remounts the composition when export creates its isolated render clone, preserving hooks, state setup, and function props. Forward its supplied id to the root Timegroup. Keep animation and fade functions deterministic and driven by composition time.