Skip to content

Instantly share code, notes, and snippets.

@ariel-frischer
Created October 2, 2025 23:46
Show Gist options
  • Save ariel-frischer/14b22e315fdee210ff654ae2591f260b to your computer and use it in GitHub Desktop.
Save ariel-frischer/14b22e315fdee210ff654ae2591f260b to your computer and use it in GitHub Desktop.
Canvas to MP4 Example

Canvas Animation to MP4 Recorder

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.

Features

  • 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

Prerequisites

npm install mp4-muxer
# or
bun add mp4-muxer

Browser Support: Chrome/Edge/Opera 94+ (WebCodecs API required)

Core Hook: useCanvasRecorder

// 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,
  };
}

Usage Example

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>
  );
}

Platform Presets

// 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 },
};

Key Concepts

Why Not requestAnimationFrame?

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);
});

Frame Timing Rules

  • 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 Size = Encoder Size

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 })

Common Issues

Recording is too fast/slow

  • Check you're encoding exactly fps × duration frames
  • Verify timestamps are in microseconds, not milliseconds
  • Don't use multiple loops capturing frames simultaneously

Black bars in video

  • Canvas size must match encoder width/height exactly
  • Check canvas.width and canvas.height (not CSS size)

Poor quality after uploading to social media

  • Use platform-specific presets (Twitter needs 30fps, not 50fps)
  • Increase bitrate (8-10 Mbps recommended)
  • Ensure codec is H.264 (AVC) for compatibility

Recording only captures a few frames

  • Check for errors in browser console
  • Ensure you're not accidentally stopping the recorder early
  • Verify the controlled loop completes all iterations

Browser Compatibility Check

function checkWebCodecsSupport() {
  if (typeof VideoEncoder === 'undefined') {
    alert('WebCodecs not supported. Use Chrome/Edge/Opera 94+');
    return false;
  }
  return true;
}

Performance Tips

  1. Use small delays between frames: await new Promise(r => setTimeout(r, 1))
  2. Close VideoFrames immediately after encoding to free memory
  3. Limit concurrent operations - record one video at a time
  4. Monitor encoder queue: Check encoder.encodeQueueSize if needed
  5. Use 30fps for most content - higher fps = larger files, minimal benefit

References

License

MIT - Use freely in your projects!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment