A high-performance video player built with WebCodecs API, featuring GPU-accelerated rendering, worker-based architecture, and Web Audio API synchronization.
- ✅ Zero-copy GPU rendering via WebGPU with ImageBitmap fallback
- ✅ Worker-based architecture for non-blocking video decoding
- ✅ Audio-video synchronization using Web Audio API timeline
- ✅ Chunked streaming for efficient memory usage and fast seeking
- ✅ MP4 demuxing with webcodecs-utils
- ✅ Play/pause/seek controls with frame-accurate seeking
Main Thread (player.ts)
│
├─> Clock (clock.ts) - Time Management
│ ├─> Queries audio timeline (source of truth)
│ ├─> Manages requestAnimationFrame loop (30fps)
│ ├─> Emits tick events for UI updates
│ └─> Coordinates video rendering
│
├─> file.ts Worker (MP4 Demuxer)
│ ├─> Parses MP4 files using webcodecs-utils MP4Demuxer
│ ├─> Extracts audio chunks → Main thread (audio player)
│ └─> Extracts video chunks → Video worker via MessagePort
│
├─> video.worker.ts (Video Decoder Worker)
│ ├─> Receives chunks from file worker via MessagePort
│ ├─> Manages VideoRenderer instances (chunked playback)
│ ├─> Decodes VideoFrames using VideoDecoder
│ ├─> Renders to OffscreenCanvas with GPUFrameRenderer
│ └─> Passively renders at times provided by Clock
│
└─> audio.ts (Web Audio Player)
├─> Requests audio chunks from file worker
├─> Muxes chunks to AAC buffer using mp4-muxer
├─> Decodes with Web Audio API (AudioContext.decodeAudioData)
└─> Provides timeline (AudioContext.currentTime) as source of truth
Main controller that coordinates all components:
- Sets up MessageChannel for worker-to-worker communication
- Manages file worker, video worker, audio player, and clock
- Delegates playback control to Clock
- Forwards events from Clock to external listeners
Central time manager that coordinates playback:
- Uses audio timeline (Web Audio API) as source of truth
- Manages requestAnimationFrame loop at 30fps
- Emits
tickevents for UI updates - Coordinates play/pause/seek across audio and video
- Provides
getCurrentTime()for on-demand queries - Why separate? Makes timing logic explicit and easier to understand
Pure MP4 demuxer worker:
- Uses webcodecs-utils
MP4Demuxer - Single source of truth for file parsing
- Sends audio chunks directly to main thread
- Forwards video chunks to video worker via MessagePort
Video decoding and rendering worker:
- Receives video chunks from file worker
- Manages multiple
VideoRendererinstances (one per chunk) - Decodes frames with
VideoDecoder - Renders to OffscreenCanvas
Manages video decoding and GPU rendering:
- Uses webcodecs-utils
GPUFrameRendererfor zero-copy rendering - Buffers decoded frames for smooth playback
- Handles frame dropping when behind timeline
- Automatically recovers from decoder errors
Web Audio API integration:
- Muxes EncodedAudioChunks to AAC with mp4-muxer
- Decodes audio with
AudioContext.decodeAudioData - Provides timeline (
AudioContext.currentTime) as source of truth - Clock queries this timeline to get current playback position
import { WebCodecsPlayer } from 'webcodecs-examples/player';
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
const file = await getVideoFile(); // File object
const player = new WebCodecsPlayer({
src: file,
canvas: canvas
});
await player.initialize();
// Play/pause
await player.play();
await player.pause();
// Seek
await player.seek(10.5); // Seek to 10.5 seconds
// Get current time
const currentTime = player.getCurrentTime();
// Get duration
const duration = player.duration;
// Listen to time updates
player.on('timeupdate', (time) => {
console.log('Current time:', time);
updateUI(time);
});
// Listen to play/pause events
player.on('play', () => console.log('Playing'));
player.on('pause', () => console.log('Paused'));
// Cleanup
player.terminate();const player = new WebCodecsPlayer({ src: file, canvas });
await player.initialize();
// Update timeline UI
player.on('timeupdate', (time) => {
const progress = (time / player.duration) * 100;
progressBar.value = progress;
timeDisplay.textContent = formatTime(time);
});
// Seek on timeline click
progressBar.addEventListener('input', () => {
const seekTime = (progressBar.value / 100) * player.duration;
player.seek(seekTime);
});
// Play/pause button
playButton.addEventListener('click', () => {
if (player.getCurrentTime() === 0) {
player.play();
} else {
player.pause();
}
});new WebCodecsPlayer(params: WebCodecsPlayerParams)Parameters:
params.src: File- Video file to play (MP4 format)params.canvas: HTMLCanvasElement- Canvas element for rendering
Initialize the player. Must be called before playing.
await player.initialize();Start playback from current position.
await player.play();Pause playback.
await player.pause();Seek to a specific time in seconds.
await player.seek(30.5); // Seek to 30.5 secondsGet the current playback time in seconds.
const currentTime = player.getCurrentTime();Clean up all resources (workers, decoders, audio context).
player.terminate();Video duration in seconds (available after initialization).
console.log(`Video is ${player.duration} seconds long`);The player extends EventEmitter and emits the following events:
Emitted continuously during playback with current time.
player.on('timeupdate', (time: number) => {
console.log('Current time:', time);
});Emitted when playback starts.
player.on('play', () => {
console.log('Playback started');
});Emitted when playback pauses.
player.on('pause', () => {
console.log('Playback paused');
});Emitted when player is terminated.
player.on('terminated', () => {
console.log('Player terminated');
});- WebGPU mode: Zero-copy rendering via
importExternalTexture()- optimal performance - ImageBitmap fallback: For browsers without WebGPU support
- Hardware-accelerated linear filtering by default
- Videos are split into chunks (default: 300 seconds per chunk)
- Only active chunks are kept in memory
- Fast seeking by loading relevant chunks on-demand
- Automatic chunk preloading before playback reaches chunk end
- Audio chunks are muxed to AAC using mp4-muxer
- Decoded with Web Audio API (hardware accelerated)
- Chunked loading prevents memory issues with long videos
- Clock runs at 30fps via requestAnimationFrame
- Audio timeline (AudioContext.currentTime) provides sub-millisecond precision
- Frame rate throttling prevents excessive rendering and saves CPU/battery
- Main thread handles Clock loop, audio playback, and UI updates
- File demuxing in dedicated worker (non-blocking)
- Video decoding in dedicated worker (non-blocking)
- Main thread only handles Clock, audio playback, and UI updates
- Chrome 94+ (full support with WebGPU)
- Edge 94+ (full support with WebGPU)
- Safari 17.4+ (partial support, falls back to ImageBitmap)
Requires:
- WebCodecs API
- Web Audio API
- Workers with module support
- OffscreenCanvas
- WebGPU (optional, falls back to ImageBitmap)
webcodecs-utils- MP4Demuxer and GPUFrameRenderermp4-muxer- Muxing audio chunks for Web Audio APIuuid- Unique identifiers for worker messages
src/player/
├── player.ts # Main player controller
├── clock.ts # Time management and coordination
├── file.ts # MP4 demuxer worker
├── renderers/
│ ├── audio/
│ │ └── audio.ts # Web Audio player (timeline source of truth)
│ └── video/
│ ├── video.ts # Video worker manager
│ ├── video.worker.ts # Video decoding worker
│ └── decoder.ts # VideoRenderer with GPU rendering
└── README.md # This file
Audio-Video Sync via Clock:
// Clock queries audio timeline (source of truth)
const currentTime = audioPlayer.getCurrentTime();
// Clock emits tick events at 30fps
clock.on('tick', (time) => {
// UI updates
updateProgressBar(time);
});
// Clock tells video worker to render (passive)
videoWorker.render(currentTime);Why use a Clock?
// Instead of this (scattered timing logic):
audioPlayer.on('time', (time) => {
videoWorker.render(time);
updateUI(time);
});
// We have this (centralized timing):
clock.tick() {
const time = audioPlayer.getCurrentTime(); // Query audio timeline
this.emit('tick', time); // UI updates
videoWorker.render(time); // Video renders
}Worker-to-Worker Communication:
// Main thread creates MessageChannel
const channel = new MessageChannel();
// Pass one port to each worker
fileWorker.postMessage({ port: channel.port1 }, [channel.port1]);
videoWorker.postMessage({ port: channel.port2 }, [channel.port2]);
// Workers communicate directly via MessagePortsMemory Management:
// Always close VideoFrames
decoder.output = (frame) => {
renderer.drawImage(frame);
frame.close(); // Important!
};MIT - See LICENSE file for details