A lightweight WebAssembly-based Apple ProRes encoder that encodes .mov files directly in the browser.
No server required.
- Tiny: The lightest possible way to get ProRes in a browser (38 KB gzipped vs 10.2 MB for ffmpeg.wasm)
- All ProRes Profiles: Proxy, LT, Standard, HQ, 4444, and 4444 XQ
- Alpha Channel: Full transparency support with 4444 profiles
- Real .mov output: With any framerate or resolution, compatible with any editing software
- Capture Canvas: Encode directly from
HTMLCanvasElementorOffscreenCanvas - Pure WASM: Runs entirely in the browser, no server-side processing
- Multi-threaded: Optional frame-parallel encoding across Web Workers
- MediaBunny Compatible: Drop-in custom encoder for MediaBunny's conversion and muxing pipelines
npm install prores-wasm-encoderTypeScript types are included and need TypeScript 5.7 or later.
import { createProResEncoder, ProResProfile, downloadMov } from 'prores-wasm-encoder';
// Create encoder
const encoder = await createProResEncoder();
// Initialize with settings
encoder.initialize({
width: 1920,
height: 1080,
frameRate: 30,
profile: ProResProfile.HQ,
});
// Encode frames from canvas
const canvas = document.getElementById('myCanvas');
for (let i = 0; i < 100; i++) {
drawFrame(canvas, i); // Your rendering code
encoder.addFrameFromCanvas(canvas);
}
// Download the .mov file
const movData = encoder.finalize();
downloadMov(movData, 'my-video.mov');
// Clean up
encoder.destroy();createProResEncoder() |
createProResEncoderPool() |
|
|---|---|---|
| Import from | prores-wasm-encoder |
prores-wasm-encoder/parallel |
| Runs on | the calling thread | Web Workers |
| Frame methods | synchronous | async, backpressured |
| Use it for | Web Workers, Node, short clips | browser exports (recommended) |
The single-thread encoder is synchronous: a tight export loop on the main thread freezes the page, progress UI included, until it finishes. In a browser, prefer the pool. If you use the single-thread encoder on the main thread, yield to the event loop every few frames:
for (let i = 0; i < totalFrames; i++) {
drawFrame(canvas, i);
encoder.addFrameFromCanvas(canvas);
if (i % 4 === 3) await new Promise((r) => setTimeout(r, 0)); // let the UI update
}Both encoders produce byte-identical output and share the same frame and finalize methods.
ProRes is intra-only (every frame is independent) so frames encode in parallel across Web Workers (real threads) with byte-identical output to the single-thread encoder. No SharedArrayBuffer or COOP/COEP headers required, each worker holds its own WASM instance, and the main thread muxes the results in frame order. Scaling is near-linear with core count (measured ~6.4x on 8 workers for complex 1080p HQ).
Multi-threaded encoding lives in its own entry point, so apps that don't use it never pay for it:
import { createProResEncoderPool } from 'prores-wasm-encoder/parallel';
import { ProResProfile } from 'prores-wasm-encoder';
const pool = await createProResEncoderPool({
width: 1920,
height: 1080,
frameRate: 30,
profile: ProResProfile.HQ,
workers: 4, // default: min(hardwareConcurrency, 8)
});
// Same frame methods as the single-thread encoder, but async
for (let i = 0; i < totalFrames; i++) {
drawFrame(canvas, i);
await pool.addFrameFromCanvas(canvas); // backpressured
}
const mov = await pool.finalize(); // or finalizeToBlob()
await pool.destroy();Workers are spawned from an inlined Blob URL, so no bundler configuration
is required (it works in Vite, webpack, esbuild, plain <script>, and
CDN builds alike). The WASM binary ships and compiles exactly once: the
compiled module is shared with every worker, so adding workers adds no
download or compile cost. Requires Web Workers with module support
(Chrome 80+, Safari 15+, Firefox 114+); where those are unavailable, use
the single-thread createProResEncoder(). If your site sets a
Content-Security-Policy, allow Blob workers with worker-src blob:;
otherwise createProResEncoderPool() rejects with an error saying so.
Streaming (onFrameData) and finalizeToBlob() work on the pool exactly as
on the single-thread encoder, so long parallel recordings also stay within
constant memory.
This library can plug into MediaBunny's custom-encoder
API, so any MediaBunny Output or Conversion targeting the 'prores'
codec encodes through it.
import { Output, BufferTarget, MovOutputFormat, CanvasSource } from 'mediabunny';
import { registerProResEncoder } from 'prores-wasm-encoder/mediabunny';
registerProResEncoder(); // multi-threaded when Web Workers are available
const output = new Output({
format: new MovOutputFormat(),
target: new BufferTarget(),
});
const source = new CanvasSource(canvas, {
codec: 'prores',
fullCodecString: 'apch', // pick the variant by fourcc: apco, apcs, apcn, apch, ap4h, ap4x
bitrate: 100_000_000,
});
output.addVideoTrack(source, { frameRate: 30 });
await output.start();
for (let f = 0; f < totalFrames; f++) {
drawFrame(f); // your rendering code
await source.add(f / 30, 1 / 30); // capture + encode
}
await output.finalize();
// output.target.buffer is the finished .movmediabunny is an optional peer dependency (npm install mediabunny), and
this entry point is ESM-only. Select the ProRes variant with
fullCodecString (a ProRes fourcc), or omit it and MediaBunny infers one
from bitrate and alpha settings. Encoded frames are identical to the ones
this library's own muxer writes.
addFrameFromCanvas() reads any HTMLCanvasElement or OffscreenCanvas:
2D, WebGL, WebGL2 or WebGPU (p5.js WEBGL, three.js and so on).
- Size: the canvas backing store (
canvas.width×canvas.height) must match the encoder'swidth×height, or the call throws. On high-DPI screens the backing store is often larger than the CSS size. - WebGL timing: read the frame in the same task as the draw. The
pool reads the pixels as soon as you call
addFrameFromCanvas(), before it waits for anything, sodrawFrame(); await pool.addFrameFromCanvas(canvas)is safe. If you capture later, create the context withpreserveDrawingBuffer: true. - Alpha: 2D canvases store premultiplied alpha, so semi-transparent
pixels lose some precision on the way to 4444. For exact alpha, pass
straight (non-premultiplied) RGBA to
addFrameRgba().
The WASM binary and the worker code are inlined into the JavaScript, so there are no extra files for a bundler to copy or serve. The test suite builds and runs the package with Vite.
Vite dev server: if you load the encoder lazily
(await import('prores-wasm-encoder')), Vite discovers it on first use,
re-bundles, and reloads the page, which loses the export in progress.
Pre-bundle it at startup:
// vite.config.js
export default {
optimizeDeps: {
include: ['prores-wasm-encoder', 'prores-wasm-encoder/parallel'],
},
};Invalid options, wrong-sized frames and calls in the wrong order (a frame
after finalize(), anything after destroy()) throw an Error with a
message that says what to fix.
Creates and returns a new encoder instance. The WASM module is loaded automatically.
Imported from prores-wasm-encoder/parallel. Creates a multi-threaded,
frame-parallel encoder pool (see Multi-threaded Encoding).
Accepts the same options as initialize() plus workers (worker count).
Frame-adding methods (addFrameRgba, addFrameFromCanvas,
addFrameFromImageData) and finalize() / finalizeToBlob() are async.
Initialize the encoder with the specified options:
| Option | Type | Default | Description |
|---|---|---|---|
width |
number | required | Frame width in pixels |
height |
number | required | Frame height in pixels |
frameRate |
number | 30 |
Frame rate (e.g. 23.976, 30, 60). Standard rates are mapped to exact num/den pairs |
frameRateNum |
number | Advanced: explicit numerator (overrides frameRate when both num and den are set) |
|
frameRateDen |
number | Advanced: explicit denominator (overrides frameRate when both num and den are set) |
|
profile |
ProResProfile | HQ |
ProRes profile to use |
range |
string | "limited" |
Color range. Only "limited" (TV/studio) is supported for now; "full" logs a warning and encodes limited range |
onFrameData |
function | Streaming mode: called with each encoded frame (Uint8Array) instead of buffering it. See Long Recordings |
Add a frame from raw RGBA pixel data (Uint8Array or Uint8ClampedArray, must be width * height * 4 bytes).
Add a frame directly from an HTMLCanvasElement or OffscreenCanvas (2D,
WebGL or WebGPU). See Canvas Sources.
Add a frame from an ImageData object (e.g., from ctx.getImageData()).
Finalize encoding and return the .mov file as a Uint8Array (backed by a
plain ArrayBuffer, so new Blob([mov]) type-checks). An encoder
finalizes once; create a new one for the next file.
Like finalize(), but returns a Blob (video/quicktime). Preferred for
long recordings.
Streaming mode (onFrameData) only: returns the bytes that go before and
after your frame chunks. The file is [header, ...chunks, moov].
Free all WASM memory and resources. Always call this when done encoding.
| Property | Type | Description |
|---|---|---|
frameCount |
number | Number of frames encoded so far |
width |
number | Encoder width |
height |
number | Encoder height |
initialized |
boolean | Whether the encoder is initialized |
Returned by createProResEncoderPool().
| Member | Description |
|---|---|
addFrameRgba(data), addFrameFromCanvas(canvas), addFrameFromImageData(imageData) |
Submit a frame. Resolve once the frame is accepted; await them to apply backpressure |
flush() |
Wait until every submitted frame is encoded. More frames can follow |
finalize() |
Wait for all frames, then resolve with the .mov as a Uint8Array |
finalizeToBlob() |
Same, as a Blob |
finalizeStreaming() |
Streaming mode: wait for all frames, then resolve with { header, moov } |
destroy() |
Terminate the workers. Always call it when done |
frameCount, width, height, workerCount |
Read-only properties |
Triggers a browser download of the .mov file. Default filename is "output.mov".
Converts the Uint8Array to a Blob with MIME type video/quicktime.
Converts the Uint8Array to an Object URL. Remember to call URL.revokeObjectURL() when done.
Profile enum with values: PROXY (0), LT (1), STANDARD (2), HQ (3), P4444 (4), P4444XQ (5).
Maps profile values to display names (e.g., ProfileNames[ProResProfile.HQ] returns "ProRes 422 HQ"). Useful for building profile selector UIs.
| Profile | Constant | Approx. Bitrate (1080p30) | Best For |
|---|---|---|---|
| 422 Proxy | ProResProfile.PROXY |
~45 Mbps | Lightweight proxies for offline editing |
| 422 LT | ProResProfile.LT |
~102 Mbps | Editing with limited storage |
| 422 Standard | ProResProfile.STANDARD |
~147 Mbps | General-purpose editing |
| 422 HQ | ProResProfile.HQ |
~220 Mbps | High quality mastering (recommended) |
| 4444 | ProResProfile.P4444 |
~330 Mbps | Content with alpha transparency |
| 4444 XQ | ProResProfile.P4444XQ |
~500 Mbps | Maximum quality with alpha |
Bitrates are approximate and vary with content complexity.
Just pass the frame rate as a number:
encoder.initialize({
width: 1920,
height: 1080,
frameRate: 23.976,
profile: ProResProfile.HQ,
})For fractional framerates, you can also use frameRateNum/frameRateDen:
encoder.initialize({
width: 1920,
height: 1080,
frameRateNum: 24000,
frameRateDen: 1001,
profile: ProResProfile.HQ,
});Frame data never accumulates in WASM memory: each encoded frame is handed to JavaScript immediately, so encoder memory stays constant (~64 MB) regardless of recording length.
finalize()returns the file as oneUint8Array(simple, fine for short recordings)finalizeToBlob()returns aBlob: preferred for long recordings, since Blob parts are browser-managed and need no single contiguous allocation- For unbounded recordings, pass
onFrameDatatoinitialize()and stream each chunk to disk (e.g. OPFS), then assemble[header, ...chunks, moov]fromfinalizeHeaders()
Files over 4 GB are written with 64-bit offsets (co64 + large mdat)
automatically.
- Chrome 91+
- Firefox 89+
- Safari 16.4+
- Edge 91+
Requires WebAssembly with SIMD128 (universal in browsers since early 2023).
This library matches (and slightly beats) ffmpeg.wasm single-threaded, at a fraction of the size (38 KB vs 10.2 MB gzipped, about 270× smaller).
ProRes 422 HQ
| Encoder | fps | vs real-time |
|---|---|---|
| ffmpeg.wasm, 1 thread | 3.26 | 0.11× |
| this library, 1 thread | 3.52 | 0.12× |
| ffmpeg.wasm, 8 threads (browser) | 22.52 | 0.75× |
| this library, 8-worker pool | 26.25 | 0.87× |
| native FFmpeg, software (all cores) | 36.4 | 1.21× |
Measured on an Apple M1 Pro (10 cores), 1080p.
See Benchmark.
I'm a motion designer and creative developer working in branding, and I kept running into the same wall: there was no good way to export transparent, high quality video directly from the web. Everything I built needed a server or a round-trip through desktop software just to get an alpha-capable master out of a canvas.
So I built this for my own custom tools. It's a port of FFmpeg's prores_ks
to C and WebAssembly. I'm not a C developer, and this started as an
experiment to see how far I could push a browser-native ProRes encoder.
Despite being built almost entirely with AI-assisted tools, it ended up
working surprisingly well, matching FFmpeg's output closely enough that I
decided to polish it into a proper library.
It's still primarily a tool I use in my own work, but if it's useful to you, that makes me happy :)
GNU Lesser General Public License v2.1 or later.
The encoder core is derived from FFmpeg's proresenc_kostya.c (© Anatoliy
Wasserman, Konstantin Shishkov), which is LGPL-2.1+. As a derivative work,
this project is distributed under the same license. The integer DCT is
derived from libjpeg's jfdctint.c (IJG License).
"Apple ProRes" and "ProRes" are trademarks of Apple Inc. This project is an independent, unofficial implementation and is not affiliated with, endorsed by, or certified by Apple Inc. The codec bitstream is described in the public SMPTE RDD 36 specification. This software license grants no patent rights; see the SMPTE RDD 36 IP declarations.
- Encoder derived from the FFmpeg project's ProRes encoder (
proresenc_kostya.c) - Integer DCT derived from libjpeg's
jfdctint.c(IJG License) - MOV container format based on Apple QuickTime File Format specification
- Inspired by h264-mp4-encoder architecture and mp4-wasm
- Logo Design by Detetiive