Skills/Video Composition/Render to Video Tutorial

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