html-video-element-webworker-polyfill/canvas-video

Frame-by-frame video, no <video>

CanvasVideoElement has the HTMLVideoElement API, but decodes with WebCodecs onto its own canvas. Stepping currentTime forward one frame at a time runs as fast as the decoder itself, in a Web Worker or on the main thread.

Live demo

Web Worker CanvasVideoElement Decodes with WebCodecs and draws every frame onto its own canvas.
Web Worker Processed frame Uploaded to WebGL as a texture and filtered in a shader.
–
Process every frame –frames/s Sets currentTime frame by frame and filters each frame.
Run on

The same code runs in both places.

Filter
Video

How it works

The pipeline mirrors ffplay. Mediabunny demuxes MP4 and WebM from HTTP byte ranges into packets. A read loop keeps a few seconds of packets queued, a decode loop feeds one long-lived VideoDecoder, and a presenter picks the frame for the clock or for the seek target. A seek that lands ahead in the same GOP keeps decoding forward. Any other seek bumps a serial number, resets the same decoder and drops stale packets and frames. Events go through a MessageChannel instead of timers, so nothing waits for a hidden tab.

worker.js

import { CanvasVideoElement } from "html-video-element-webworker-polyfill/canvas-video";

const video = new CanvasVideoElement({ canvas: offscreenCanvas });
video.src = "movie.mp4";
await new Promise((resolve) => (video.onloadeddata = resolve));

for (let i = 0; i < frameCount; i++) {
  video.currentTime = (i + 0.5) / fps;
  await new Promise((resolve) => (video.onseeked = resolve));
  process(video.currentFrame);
}