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.
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
Get a ref to the root timegroup element and call renderToVideo on it:
import { useRef } from "react";
import { TimelineRoot } from "@editframe/react";
import type { EFTimegroup } from "@editframe/elements";
function App() {
const tgRef = useRef<EFTimegroup>(null);
async function handleRender() {
const { buffer, mimeType } = await tgRef.current!.renderToVideo({
fps: 30,
onProgress: ({ frame, totalFrames }) => console.log(`${frame}/${totalFrames}`),
});
// download new Blob([buffer], { type: mimeType }) …
}
return (
<>
<TimelineRoot id="root" component={MyComposition} />
<button onClick={handleRender}>Render</button>
</>
);
}TimelineRoot ensures createRenderClone() works correctly when renderToVideo forks the composition for parallel encoding.