A minimal, production-ready React component for recording HTML5 Canvas animations directly to MP4 using the WebCodecs API. Records at 10x faster than realtime with hardware acceleration.
- ✅ True MP4 output with H.264 codec (not WebM)
- ✅ Hardware accelerated encoding via WebCodecs VideoEncoder
- ✅ 10x faster than realtime (5 second video encodes instantly)
- ✅ Frame-perfect recording - no dropped frames or timing issues
- ✅ Multiple presets for different platforms (Twitter, etc.)
- ✅ Auto-download on completion
npm install mp4-muxer
# or
bun add mp4-muxerBrowser Support: Chrome/Edge/Opera 94+ (WebCodecs API required)
// hooks/useCanvasRecorder.ts
import * as Mp4Muxer from 'mp4-muxer';
import { useCallback, useRef, useState } from 'react';
export type CanvasRecorderOptions = {
width: number;
height: number;
fps?: number;
bitrate?: number;
codec?: 'avc' | 'vp9' | 'av1'; // avc = H.264
};
export function useCanvasRecorder(options: CanvasRecorderOptions) {
const { width, height, fps = 30, bitrate = 8_000_000, codec = 'avc' } = options;
const [state, setState] = useState({
isRecording: false,
frameCount: 0,
duration: 0,
});
const muxerRef = useRef<Mp4Muxer.Muxer<Mp4Muxer.ArrayBufferTarget> | null>(null);
const videoEncoderRef = useRef<VideoEncoder | null>(null);
const frameNumberRef = useRef(0);
// Start recording - initializes encoder and muxer
const startRecording = useCallback((canvas: HTMLCanvasElement | OffscreenCanvas) => {
// Create MP4 muxer
muxerRef.current = new Mp4Muxer.Muxer({
target: new Mp4Muxer.ArrayBufferTarget(),
video: { codec, width, height },
fastStart: 'in-memory',
});
// Create WebCodecs VideoEncoder
videoEncoderRef.current = new VideoEncoder({
output: (chunk, meta) => {
muxerRef.current?.addVideoChunk(chunk, meta);
},
error: (e) => console.error('VideoEncoder error:', e),
});
// Configure encoder
videoEncoderRef.current.configure({
codec: codec === 'avc' ? 'avc1.42001f' : codec,
width,
height,
bitrate,
bitrateMode: 'constant',
});
frameNumberRef.current = 0;
setState({ isRecording: true, frameCount: 0, duration: 0 });
}, [width, height, fps, bitrate, codec]);
// Encode a single frame
const encodeFrame = useCallback((canvas: HTMLCanvasElement | OffscreenCanvas) => {
if (!videoEncoderRef.current || !muxerRef.current) return;
const frameNumber = frameNumberRef.current;
const timestamp = (frameNumber * 1_000_000) / fps; // microseconds
const duration = 1_000_000 / fps;
// Create VideoFrame from canvas
const videoFrame = new VideoFrame(canvas, { timestamp, duration });
// Encode frame (keyframe every 150 frames)
const isKeyFrame = frameNumber % 150 === 0;
videoEncoderRef.current.encode(videoFrame, { keyFrame: isKeyFrame });
// Cleanup frame
videoFrame.close();
frameNumberRef.current++;
setState(prev => ({
...prev,
frameCount: frameNumber + 1,
duration: (frameNumber * 1000) / fps / 1000,
}));
}, [fps]);
// Stop recording and return MP4 blob
const stopRecording = useCallback(async (): Promise<Blob | null> => {
if (!videoEncoderRef.current || !muxerRef.current) return null;
// Flush encoder
await videoEncoderRef.current.flush();
// Finalize muxer
muxerRef.current.finalize();
// Close encoder
videoEncoderRef.current.close();
// Get MP4 data
const buffer = muxerRef.current.target.buffer;
const blob = new Blob([buffer], { type: 'video/mp4' });
// Cleanup
muxerRef.current = null;
videoEncoderRef.current = null;
setState(prev => ({ ...prev, isRecording: false }));
return blob;
}, []);
// Download helper
const downloadRecording = useCallback((blob: Blob, filename = 'recording.mp4') => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}, []);
return {
state,
startRecording,
encodeFrame,
stopRecording,
downloadRecording,
};
}import { useCanvasRecorder } from './hooks/useCanvasRecorder';
import { useEffect, useRef } from 'react';
export function CanvasRecorderExample() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const recorder = useCanvasRecorder({
width: 1280,
height: 720,
fps: 30,
bitrate: 8_000_000, // 8 Mbps
});
// Animation function - draws one frame at given time
const renderFrame = (ctx: CanvasRenderingContext2D, time: number) => {
// Clear canvas
ctx.clearRect(0, 0, 1280, 720);
// Draw your animation based on time (in seconds)
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, 1280, 720);
ctx.fillStyle = '#fff';
ctx.font = 'bold 72px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`Time: ${time.toFixed(1)}s`, 640, 360);
};
// Record function - uses controlled loop (not requestAnimationFrame!)
const handleRecord = async () => {
if (!canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const duration = 5; // seconds
const fps = 30;
const totalFrames = duration * fps;
// Start recording
recorder.startRecording(canvas);
// Render and encode each frame
for (let i = 0; i < totalFrames; i++) {
const time = i / fps;
// Render this frame
renderFrame(ctx, time);
// Encode the frame
recorder.encodeFrame(canvas);
// Small delay to prevent blocking
await new Promise(resolve => setTimeout(resolve, 1));
}
// Stop and download
const blob = await recorder.stopRecording();
if (blob) {
recorder.downloadRecording(blob, 'animation.mp4');
}
};
return (
<div>
<canvas ref={canvasRef} width={1280} height={720} />
<button onClick={handleRecord} disabled={recorder.state.isRecording}>
{recorder.state.isRecording ? 'Recording...' : 'Record MP4'}
</button>
{recorder.state.isRecording && (
<p>Frame {recorder.state.frameCount} ({recorder.state.duration.toFixed(1)}s)</p>
)}
</div>
);
}// Video dimension presets for different platforms
const PRESETS = {
// High quality default
'default': { width: 1920, height: 1080, fps: 50, bitrate: 8_000_000 },
// Twitter/X optimized (30fps recommended)
'twitter-landscape': { width: 1280, height: 720, fps: 30, bitrate: 8_000_000 },
'twitter-portrait': { width: 720, height: 1280, fps: 30, bitrate: 8_000_000 },
'twitter-square': { width: 720, height: 720, fps: 30, bitrate: 8_000_000 },
// Instagram Reels / TikTok
'reels': { width: 1080, height: 1920, fps: 30, bitrate: 10_000_000 },
// YouTube Shorts
'shorts': { width: 1080, height: 1920, fps: 60, bitrate: 12_000_000 },
};For preview playback, use requestAnimationFrame:
const animate = (timestamp: number) => {
renderFrame(ctx, timestamp / 1000);
requestAnimationFrame(animate);
};For recording, use a controlled loop:
// ✅ Good - deterministic frame count
for (let i = 0; i < totalFrames; i++) {
renderFrame(ctx, i / fps);
recorder.encodeFrame(canvas);
}
// ❌ Bad - timing issues, variable frame count
requestAnimationFrame((timestamp) => {
renderFrame(ctx, timestamp / 1000);
recorder.encodeFrame(canvas);
requestAnimationFrame(animate);
});- Timestamps must be in microseconds and monotonically increasing
- Duration =
1_000_000 / fps(microseconds per frame) - Total frames =
fps × duration_in_seconds(must be exact!)
const frameNumber = 0;
const timestamp = (frameNumber * 1_000_000) / fps; // microseconds
const duration = 1_000_000 / fps; // microseconds
const videoFrame = new VideoFrame(canvas, { timestamp, duration });Canvas dimensions must match exactly the encoder configuration:
// ✅ Good - sizes match
<canvas width={1280} height={720} />
useCanvasRecorder({ width: 1280, height: 720 })
// ❌ Bad - will produce black bars
<canvas width={1920} height={1080} />
useCanvasRecorder({ width: 1280, height: 720 })- Check you're encoding exactly
fps × durationframes - Verify timestamps are in microseconds, not milliseconds
- Don't use multiple loops capturing frames simultaneously
- Canvas size must match encoder width/height exactly
- Check
canvas.widthandcanvas.height(not CSS size)
- Use platform-specific presets (Twitter needs 30fps, not 50fps)
- Increase bitrate (8-10 Mbps recommended)
- Ensure codec is H.264 (AVC) for compatibility
- Check for errors in browser console
- Ensure you're not accidentally stopping the recorder early
- Verify the controlled loop completes all iterations
function checkWebCodecsSupport() {
if (typeof VideoEncoder === 'undefined') {
alert('WebCodecs not supported. Use Chrome/Edge/Opera 94+');
return false;
}
return true;
}- Use small delays between frames:
await new Promise(r => setTimeout(r, 1)) - Close VideoFrames immediately after encoding to free memory
- Limit concurrent operations - record one video at a time
- Monitor encoder queue: Check
encoder.encodeQueueSizeif needed - Use 30fps for most content - higher fps = larger files, minimal benefit
MIT - Use freely in your projects!