Start
//

Render to Video Tutorial

Build a composition and export it as MP4 video directly in the browser using WebCodecs.

Prerequisites

Browser must support WebCodecs API (Chrome 94+, Edge 94+, Safari 16.4+). Check support:

const supported = "VideoEncoder" in window && "VideoDecoder" in window;

Step 1: Create a Composition

Start with a basic composition that you want to render:

Live
Welcome This will be exported as video

Step 2: Add Export Button

Create UI for triggering the export:

<button
id="exportBtn"
class="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
Export Video
</button>
<div id="progressContainer" class="mt-4 hidden">
<div class="flex justify-between text-sm text-gray-300 mb-2">
<span id="progressText">Preparing...</span>
<span id="progressPercent">0%</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-3">
<div
id="progressBar"
class="bg-blue-600 h-3 rounded-full transition-all"
style="width: 0%"
></div>
</div>
<div class="mt-2 text-xs text-gray-400">
<span id="timeInfo"></span>
</div>
</div>

Step 3: Implement Basic Render

Call renderToVideo() on the timegroup element:

const timegroup = document.getElementById("myComposition");
const exportBtn = document.getElementById("exportBtn");
exportBtn.addEventListener("click", async () => {
exportBtn.disabled = true;
try {
await timegroup.renderToVideo({
fps: 30,
codec: "avc",
filename: "my-video.mp4",
});
alert("Video exported successfully!");
} catch (error) {
alert("Export failed: " + error.message);
} finally {
exportBtn.disabled = false;
}
});

The video automatically downloads when rendering completes.

Step 4: Add Progress Tracking

Show real-time progress with the onProgress callback:

const progressContainer = document.getElementById("progressContainer");
const progressBar = document.getElementById("progressBar");
const progressText = document.getElementById("progressText");
const progressPercent = document.getElementById("progressPercent");
const timeInfo = document.getElementById("timeInfo");
exportBtn.addEventListener("click", async () => {
exportBtn.disabled = true;
progressContainer.classList.remove("hidden");
try {
await timegroup.renderToVideo({
fps: 30,
codec: "avc",
filename: "my-video.mp4",
onProgress: (progress) => {
// Update progress bar
const percent = Math.round(progress.progress * 100);
progressBar.style.width = `${percent}%`;
progressPercent.textContent = `${percent}%`;
// Update status text
progressText.textContent = `Rendering frame ${progress.currentFrame} of ${progress.totalFrames}`;
// Show timing information
const elapsed = Math.round(progress.elapsedMs / 1000);
const remaining = Math.round(progress.estimatedRemainingMs / 1000);
const speed = progress.speedMultiplier.toFixed(1);
timeInfo.textContent = `Elapsed: ${elapsed}s | Remaining: ${remaining}s | Speed: ${speed}x`;
},
});
progressText.textContent = "Export complete!";
progressPercent.textContent = "100%";
} catch (error) {
progressText.textContent = "Export failed: " + error.message;
} finally {
exportBtn.disabled = false;
}
});

Step 5: Add Codec Selection

Let users choose the codec based on browser support:

<select id="codecSelect" class="px-4 py-2 bg-gray-700 text-white rounded">
<option value="avc">H.264 (Best Compatibility)</option>
<option value="hevc">H.265 (Better Compression)</option>
<option value="vp9">VP9 (Open Codec)</option>
<option value="av1">AV1 (Best Quality)</option>
</select>
const codecSelect = document.getElementById("codecSelect");
// Check codec support on load
const codecs = ["avc", "hevc", "vp9", "av1"];
codecs.forEach(async (codec) => {
const config = {
codec:
codec === "avc"
? "avc1.42E01E"
: codec === "hevc"
? "hvc1.1.6.L93.B0"
: codec === "vp9"
? "vp09.00.10.08"
: "av01.0.05M.08",
width: 1280,
height: 720,
bitrate: 5_000_000,
framerate: 30,
};
const supported = await VideoEncoder.isConfigSupported(config);
if (!supported.supported) {
const option = codecSelect.querySelector(`option[value="${codec}"]`);
option.disabled = true;
option.textContent += " (Not Supported)";
}
});
// Use selected codec when rendering
exportBtn.addEventListener("click", async () => {
const codec = codecSelect.value;
// ... rest of render code
await timegroup.renderToVideo({
fps: 30,
codec: codec,
filename: "my-video.mp4",
onProgress: (progress) => {
/* ... */
},
});
});

Step 6: Include Audio

Audio from ef-video and ef-audio elements is automatically mixed:

<ef-timegroup id="compositionWithAudio" mode="sequence" class="w-[1280px] h-[720px]">
<ef-timegroup mode="fixed" duration="5s" class="absolute w-full h-full">
<ef-video src="/assets/clip.mp4" class="size-full object-cover"></ef-video>
<ef-audio src="/assets/music.mp3" volume="0.3"></ef-audio>
</ef-timegroup>
</ef-timegroup>
await timegroup.renderToVideo({
fps: 30,
codec: "avc",
includeAudio: true, // Default: true
audioBitrate: 192_000, // 192 kbps for high quality
preferredAudioCodecs: ["opus", "aac"], // Preference order
filename: "video-with-audio.mp4",
});

Step 7: Add Cancel Support

Allow users to abort long renders:

<button id="cancelBtn" class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 hidden">
Cancel Render
</button>
let abortController = null;
const cancelBtn = document.getElementById("cancelBtn");
exportBtn.addEventListener("click", async () => {
exportBtn.disabled = true;
cancelBtn.classList.remove("hidden");
progressContainer.classList.remove("hidden");
abortController = new AbortController();
try {
await timegroup.renderToVideo({
fps: 30,
codec: "avc",
filename: "my-video.mp4",
signal: abortController.signal,
onProgress: (progress) => {
// Update progress UI
const percent = Math.round(progress.progress * 100);
progressBar.style.width = `${percent}%`;
progressPercent.textContent = `${percent}%`;
},
});
progressText.textContent = "Export complete!";
} catch (error) {
if (error.name === "RenderCancelledError") {
progressText.textContent = "Render cancelled by user";
} else {
progressText.textContent = "Export failed: " + error.message;
}
} finally {
exportBtn.disabled = false;
cancelBtn.classList.add("hidden");
abortController = null;
}
});
cancelBtn.addEventListener("click", () => {
if (abortController) {
abortController.abort();
}
});

Complete Example

Live
Editframe Export to Video

Advanced Options

High-Resolution Export

Render at higher resolution than the composition:

await timegroup.renderToVideo({
scale: 2, // 2x resolution (2560x1440 from 1280x720)
bitrate: 10_000_000, // Increase bitrate for quality
filename: "high-res.mp4",
});

Partial Export

Export only a portion of the composition:

await timegroup.renderToVideo({
fromMs: 2000, // Start at 2 seconds
toMs: 8000, // End at 8 seconds
filename: "clip.mp4",
});

Programmatic Access

Get the video as a buffer instead of downloading:

const videoBuffer = await timegroup.renderToVideo({
returnBuffer: true,
filename: "video.mp4",
});
// Upload to server
const formData = new FormData();
formData.append("video", new Blob([videoBuffer], { type: "video/mp4" }));
await fetch("/api/upload", { method: "POST", body: formData });

Next Steps

  • See render-api.md for all options
  • See render-strategies.md for choosing between browser, CLI, and cloud rendering
  • See the editframe-cli skill for server-side rendering