spite/ccapture.js
A library to capture canvas-based animations at a fixed framerate
About spite/ccapture.js
spite/ccapture.js is an open-source project on GitHub, mainly written in JavaScript. A library to capture canvas-based animations at a fixed framerate It currently holds 3,766 stars and 0 forks with 0 open issues, and was last pushed on an unknown date (repository created unknown).
Project Overview
Git Homed tracks it on the Video Trending board and on the AI Video Trending list.
GitHub Repository Details
README
CCapture
Capture canvas-based animations at a fixed framerate, with motion blur and
modern encoders — a ground-up rewrite of CCapture.js
(ES modules, WebCodecs, GPU motion blur, async/await).
Your animation renders as fast (or as slowly) as it needs to; CCapture lies to it about what time it is, so the exported video is perfectly smooth at exactly the framerate you asked for — 30, 60, 240, whatever. Record a 4K frame that takes two seconds to draw and still get a flawless 60fps file.
import CCapture from "ccapture.js";
const capturer = new CCapture({ format: "mp4", framerate: 60, timeLimit: 5 });
capturer.start();
function render() {
requestAnimationFrame(render); // your normal loop
drawMyScene(); // draws using performance.now() / rAF time
capturer.capture(canvas); // ← the only line you add
}
render();
That's it. After 5 seconds an .mp4 downloads. No await, no restructuring.
CCapture is the unified, plug-and-play recorder. It's composed of two pieces
you can also use on their own: FrameWrap (canvas capture — motion blur +
encoding) and TimeWarp (the virtual clock).
---
Table of contents
- How it works
- Install
- The least-intrusive API
- Adding it to an existing sketch
- Options
- Formats & encoders
- Custom encoders
- Motion blur
- Methods & events
- Recipes
- Using TimeWarp on its own
- Using FrameWrap on its own
- The
hookDatetrade-off - Migrating from CCapture 1.x
- Browser support & caveats
- Examples
- License
How it works
The idea (à la ryg's kkapture) is to replace the browser's notion of time with a virtual clock that only advances one fixed step per captured frame. Anything that derives motion from elapsed time then steps deterministically, decoupled from wall-clock.
The library is three composable pieces:
TimeWarp— owns time. Hooksperformance.now,requestAnimationFrame,
setTimeout/setInterval, Date, and media currentTime, and advances a
virtual clock on demand. Knows nothing about canvases.
FrameWrap— owns capture. Takes already-drawn canvas frames, applies
CCapture— composes the two and drives them: the plug-and-play recorder.
requestAnimationFrame is hooked during capture, your render loop only
advances when CCapture.capture() flushes it — so it paces the whole thing, as
fast as your machine can draw and encode, with no wall-clock throttle.
Use CCapture for the normal "record a live animation" case; reach for the
halves directly when you only need one (timing without capture, or encoding
frames you produce yourself without touching the browser clock).
---
Install
npm install ccapture.js
import CCapture from "ccapture.js";
// or: import { CCapture, FrameWrap, TimeWarp } from "ccapture.js";
Or use it straight from source as native ES modules (no build step) — point at
src/index.js and serve over HTTP.
Ships with TypeScript declarations (.d.ts) — full autocomplete and
type-checking out of the box, no @types package needed.
Script tag (no build, no bundler)
A UMD/IIFE global build is included for classic `
Build it yourself with npm run build (outputs build/ccapture.umd[.min].js).
---
The least-intrusive API
The common case is a single capture(canvas) call inside your existing loop:
js
const capturer = new CCapture({ format: "mp4", framerate: 60, timeLimit: 5 });
capturer.start();
function render() { requestAnimationFrame(render); // draw using performance.now() (warped) or the rAF timestamp capturer.capture(canvas); } render();
Notes:
- Set a
timeLimit or frameLimit and it stops and saves itself. Otherwise call
capturer.stop() then capturer.save() when you're done.
- Kick your loop once (
render()), or make sure it's already running, right after
start() — after that, capture() drives it.
- The frame you pass to
capture() should already be drawn. capture() is
async but you don't need to await it.
---
Adding it to an existing sketch
Three steps, whatever you're using: (1) make a CCapture, (2) start()
it, (3) call capture(yourCanvas) once per frame in your loop. Drive motion
from performance.now() / the rAF timestamp / millis() (all warped during
capture) rather than fixed per-frame increments.
three.js — pass the renderer's canvas:
js
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
capturer.capture(renderer.domElement);
}
p5.js — p5 owns the loop, so drive it with redraw() while capturing (see
the p5 example):
js
capturer.on("stop", () => loop()); // resume live preview when done
await capturer.start();
noLoop();
(function tick() {
if (!capturer.capturing) return;
requestAnimationFrame(tick);
redraw(); // your draw() on the warped clock
capturer.capture(document.querySelector("canvas"));
})();
Global-mode p5 users can drop in the UMD build (window.CCapture) with two
`` tags instead of importing.
Plain canvas / WebGL / WebGPU — same as the top example; pass your
``. WebGPU canvases capture fine (WebCodecs reads them directly); GPU
motion blur currently needs WebGL2, so a WebGPU source uses the CPU blur backend.
---
Options
All options are passed to the CCapture constructor (capture options go to its
FrameWrap, timing options to its TimeWarp). Defaults shown.
| Option | Default | Description |
|---|---|---|
| format | "mp4" | mp4, webm, webm-legacy, png, jpg, webp. |
| framerate | 60 | Target framerate of the output. |
| quality | 90 | 0–100. Drives video bitrate and JPEG/WebP quality (PNG ignores it). |
| name | (guid) | Base filename for the download. |
| gifColors | 256 | GIF palette size (2–256). |
| codec | (per container) | avc / vp9 / av1 / vp8. Default avc for mp4, vp9 for webm. |
| bitrate | 0 | Bits/sec for video. 0 derives it from resolution × quality. |
| audio | null | An AudioBuffer to encode + mux as an audio track (mp4/webm). |
| audioBitrate | 128000 | Audio bits/sec. |
| maxQueueSize | 8 | WebCodecs encoder-queue high-water mark (backpressure). |
| motionBlurFrames | 1 | Sub-frames averaged per output frame. 1 = no blur. |
| motionBlur | "auto" | Blur backend: auto / gpu / cpu. |
| timeLimit | 0 | Seconds; auto-stops & saves at this mark. 0 = unlimited. |
| frameLimit | 0 | Output frames; auto-stops & saves at this count. 0 = unlimited. |
| autoSaveTime | 0 | Seconds; if >0, downloads the capture in parts periodically. |
| startTime | 0 | Seconds to skip into the animation before the first frame. |
| media | null | A /`` element (or array) to frame-step. See capturing video. |
| hookMedia | true | Hook HTMLMediaElement (currentTime/play) to frame-step media. |
| hookDate | true | Warp Date.now + Date.prototype.getTime. See the trade-off. |
| hookValueOf | false | Also warp Date.prototype.valueOf, so +new Date() is warped (needs hookDate). |
| display | false | Show a small on-page capture-status widget. |
| verbose | false | Log internal steps to the console. |
| timewarp | (created) | Inject a shared TimeWarp instance instead of creating one. |
---
Formats & encoders
| Format | Output | Encoder |
|---|---|---|
| mp4 | .mp4 (H.264/AV1) | WebCodecs VideoEncoder + mp4 muxer |
| webm | .webm (VP9/VP8/AV1) | WebCodecs; falls back to the legacy writer if unavailable |
| webm-legacy | .webm | webm-writer-js (WebP frames; Chromium-only) |
| gif | .gif | gifenc (256-color, CPU) |
| png / jpg / webp | .tar of images | canvas.toBlob packed into a TAR |
GIF is 256-color and palette-quantized (tune with gifColors), so it needs a
CPU readback per frame and its timing is coarse (delays are whole centiseconds,
so 60fps plays back at ~50fps). Prefer a video format unless you specifically
need a GIF. Need your own format? See Custom encoders.
WebCodecs is the default and preferred path — it's hardware-accelerated and
back-pressured, so you can run flat-out with bounded memory. Codec strings are
negotiated at runtime via VideoEncoder.isConfigSupported, and odd/resized
frames are automatically fitted to a valid even-dimension coded size.
If a browser lacks WebCodecs, webm transparently falls back to the legacy
writer; mp4 throws a clear error (switch to webm).
---
Custom encoders
An encoder is just an object with a small interface, so you can wrap anything — a
WASM codec, a server upload, APNG, whatever. There are two base classes for the
two kinds of encoder.
Frame-by-frame encoders — FrameEncoder
For codecs that take one frame at a time (WebCodecs, image sequences, GIF via
gifenc). Extend FrameEncoder for free filename, multipart-autosave, and event
handling:
js
import { FrameEncoder } from "ccapture.js";
class MyEncoder extends FrameEncoder { constructor(params) { super(params); this.extension = "bin"; // download filename extension this.mimeType = "application/octet-stream"; } async start() { await super.start(); / init / } async add(canvas) { / consume this frame / await super.add(); } async save() { return new Blob([/ … /], { type: this.mimeType }); } // or null async dispose() { / free resources / } // optional: async backpressure() {} // FrameWrap awaits this before each frame // (this.emit("progress", 0..1) bubbles up as the "progress" event) }
Batch encoders — BatchEncoder
For libraries that need all frames at once (e.g. gifski). BatchEncoder does
the buffering for you — frames are stored compressed during capture
(bufferMode: "webp" | "png" | "raw") to keep memory bounded, then handed back
at save time. You implement one method:
js
import { BatchEncoder } from "ccapture.js";
class MyBatchEncoder extends BatchEncoder { constructor(params) { super(params); this.extension = "gif"; this.mimeType = "image/gif"; } async encodeAll({ frameCount, width, height, framerate, getFrame }) { const frames = []; for (let i = 0; i < frameCount; i++) { frames.push(await getFrame(i)); // decoded ImageData, on demand this.emit("progress", (i + 1) / frameCount); } const bytes = await myBatchLib.encode({ frames, width, height, fps: framerate }); return new Blob([bytes], { type: this.mimeType }); } }
Registering / using either kind
js
// Pass directly (class or ready instance) — overrides format:
new CCapture({ encoder: MyEncoder });
new CCapture({ encoder: new MyEncoder(params) });
// Or register a named format (great for plugins): CCapture.registerEncoder("mine", MyEncoder); // also on FrameWrap new CCapture({ format: "mine" });
CCapture.formats; // → all registered format names
The built-in GIFEncoder (frame-by-frame) and GifskiEncoder (batch) are
real-world examples of each base.
High-quality GIF via gifski
GifskiEncoder wraps gifski-wasm
(libimagequant + dithering) — much better quality than the default gif, at the
cost of buffering frames (batch) and pulling in gifski. It's not registered by
default; opt in:
js
import { CCapture, GifskiEncoder } from "ccapture.js";
CCapture.registerEncoder("gif-hq", GifskiEncoder); new CCapture({ format: "gif-hq", quality: 90, frameLimit: 120 });
gifski needs every frame as raw RGBA in one buffer, so GIFs must stay small.
GifskiEncoder downscales frames to gifMaxSize (longest side, default
640) — set gifMaxSize, or explicit gifWidth/gifHeight, to change it. It
throws early with guidance if the batch would still be too large; keep clips
short (and remember GIF playback is coarse, ~≤50fps).
gifski loads lazily from a CDN by default. To vendor it or use the multithreaded
build (needs Cross-Origin-Opener-Policy/Cross-Origin-Embedder-Policy
headers), pass your own: { encoder: new GifskiEncoder({ gifski: encodeFn }) }
or { gifskiUrl: "/vendor/gifski/encode.js" }. Best for short, quality-critical
GIFs — for long captures the streaming gif keeps memory bounded.
Motion blur
Set motionBlurFrames to the number of sub-frames to average per output frame.
Each sub-frame is rendered at a fractional time step, then blended — the same
blur a real camera shutter produces.
js
new CCapture({ format: "mp4", motionBlurFrames: 16 });
You don't change your loop: internally capture() runs N sub-renders per output
frame. The backend is chosen automatically (motionBlur: "auto"):
- GPU (WebGL2) when the source is a WebGL canvas and
motionBlurFrames > 2 —
accumulation stays on the GPU, no per-sub-frame readback.
- CPU otherwise — a
Float32 accumulator (no overflow, alpha included).
Force it with motionBlur: "gpu" or "cpu".
---
Methods & events
Methods
start() → Promise — install hooks, start the encoder, begin capturing.
capture(canvas) → Promise — capture the just-drawn frame (auto-drives
the loop; see above).
stop() → Promise — stop capturing and restore the real clock.
save(callback?) → Promise — finalize. With no callback it
downloads the file; with a callback it hands you the Blob instead. Safe to
call while still capturing to grab everything so far.
step() — advance the virtual clock by one (sub-)frame manually. Only
needed for the manual driving mode.
record(canvas, render) → Promise — headless convenience: give
it a render(frame) function and it owns a flat capture loop (needs a
frameLimit/timeLimit). Use when you don't have an existing rAF loop.
dispose() → Promise — release the encoder and any GPU blur context.
Call it if you create a new FrameWrap per recording.
on(event, handler) — subscribe to an event (one handler per event).
Events
| Event | Payload | Fired when |
|---|---|---|
| start | — | capture begins |
| frame | frameCount | an output frame is encoded |
| progress | 0..1 | encoder reports progress (where supported) |
| save | Blob | the file is finalized |
| stop | — | capture stops (auto or manual) |
| error | Error | a capture/encode error occurred |
---
Recipes
Stop manually and get a Blob instead of downloading
js
capturer.start();
// … your loop calling capture(canvas) …
await capturer.stop();
await capturer.save((blob) => {
const url = URL.createObjectURL(blob);
video.src = url;
});
Headless / no existing loop
js
const capturer = new CCapture({ format: "mp4", framerate: 60, frameLimit: 300 });
await capturer.record(canvas, (frame) => {
const t = frame / 60; // seconds
drawMyScene(t);
});
Long captures without blowing memory — download in parts:
js
new CCapture({ format: "mp4", autoSaveTime: 20 }); // a file every 20s
Skip into the animation
js
new CCapture({ format: "mp4", startTime: 3, timeLimit: 5 }); // seconds 3→8
Capturing a or `` element
Register the element with media and CCapture frame-steps it in lockstep: each
frame it seeks the element to the exact time and waits for seeked before your
render draws, so a video drawn to canvas is frame-accurate (not the usual
best-effort realtime grab).
js
const capturer = new CCapture({ format: "mp4", framerate: 60, timeLimit: 8, media: video });
capturer.start();
function render() {
requestAnimationFrame(render);
ctx.drawImage(video, 0, 0); // shows the frame for this exact moment
capturer.capture(canvas);
}
render();
- Register the element even if you only
drawImage it — drawImage never reads
currentTime, so it wouldn't otherwise be discovered. (Code that does read
video.currentTime is auto-registered.)
- The element must be same-origin; a cross-origin video taints the canvas and
encoding will throw.
play() is suppressed during capture (CCapture drives time by seeking).
- Audio-reactive visuals that use the Web Audio API can't be frame-stepped
(see caveats).
Frame-accurate audio-reactive visuals
A realtime AnalyserNode reads audio at wall-clock time, so it desyncs during a
slow capture. renderAudioFrames() renders the audio offline and samples it at
each frame's exact time, so the visuals line up with the soundtrack
frame-for-frame (add the audio back in an editor afterwards):
js
import { CCapture, renderAudioFrames } from "ccapture.js";
const framerate = 60, frames = 300;
// Decode once, then use the buffer for both the analysis and the muxed track. const arrayBuffer = await (await fetch("track.mp3")).arrayBuffer(); const buffer = await new OfflineAudioContext(1, 1, 44100).decodeAudioData(arrayBuffer);
const audio = await renderAudioFrames({ buffer, framerate, frames, fftSize: 1024 });
const capturer = new CCapture({ format: "mp4", framerate, frameLimit: frames, audio: buffer }); await capturer.record(canvas, (i) => { const { frequency, waveform } = audio[i]; // this frame's spectrum / waveform (Uint8Array) drawBars(frequency); }); // → an mp4 with frame-accurate visuals AND the audio track muxed in.
Share one clock across two recorders (e.g. two canvases in lockstep):
js
import { CCapture, TimeWarp } from "ccapture.js";
const timewarp = new TimeWarp({ framerate: 60 });
const a = new CCapture({ format: "mp4", timewarp });
const b = new CCapture({ format: "mp4", timewarp });
---
Using TimeWarp on its own
TimeWarp is fully standalone — useful for deterministic testing or your own
capture pipeline.
js
import { TimeWarp } from "ccapture.js";
const tw = new TimeWarp({ framerate: 60 }); tw.start(); // performance.now/rAF/timers now virtual // … drive your animation … tw.step(); // advance exactly one frame (1000/60 ms) tw.step(8); // or advance a custom dt in ms tw.stop(); // restore the real clock
Properties: time (virtual performance.now), frameStep (ms/frame),
enabled. Methods: start/stop/enable/disable/step(dt?).
Using FrameWrap on its own
If you already produce frames deterministically (a server render, your own fixed
loop) and don't want the browser clock touched, use FrameWrap directly — it's
pure capture (motion blur + encoding + limits + save), no timing:
js
import { FrameWrap } from "ccapture.js";
const fw = new FrameWrap({ format: "mp4", framerate: 60 }); await fw.start(); for (const frameCanvas of myFrames) await fw.capture(frameCanvas); await fw.stop(); await fw.save();
---
The hookDate trade-off
With hookDate: true (default), TimeWarp overrides both Date.now and
Date.prototype.getTime to return the virtual clock — so animations that time
themselves with Date.now() or new Date().getTime() work.
The catch: overriding Date.prototype.getTime affects every Date object,
not just "now". While capturing, real date math breaks:
js
new Date("2020-01-01").getTime(); // returns the virtual clock, not 2020
laterDate.getTime() - earlierDate.getTime(); // garbage — both are "now"
```
Set hookDate: false if your animation uses performance.now() / the rAF
timestamp anyway (three.js, GSAP, most modern code) and something on the page
does real date math during capture. performance.now, rAF, and timers stay
hooked either way.
+new Date() and .valueOf() are not warped by hookDate alone (they go
through Date.prototype.valueOf, a separate function). If your code times off
+new Date(), add hookValueOf: true — off by default because it changes
the numeric value of every Date, not just "now".
---
Migrating from CCapture 1.x
The concepts are identical; the surface is modernized.
| CCapture 1.x | CCapture 2.x |
|---|---|
| new CCapture({ format: 'webm' }) | new CCapture({ format: 'mp4' }) (or webm) |
| global ` + window.CCapture | ESM import, or the UMD build (window.CCapture` still works) |
| capturer.save(cb) (callback only) | await capturer.save() (downloads) or save(cb) for the Blob |
| format: 'ffmpegserver' / 'webm-mediarecorder' | removed (server-based / realtime) |
| workersPath (gif worker) | n/a — gif uses gifenc, no worker script |
| motionBlurFrames doubled internally | motionBlurFrames = exact sample count |
| CPU-only motion blur | CPU and GPU (auto) |
| WebM via WebP frames (Chromium) | WebCodecs MP4/WebM (all modern browsers) |
new CCapture({...}) keeps working and the .start() / .capture() /
.stop() / .save() flow is unchanged — but CCapture is now its own class
(composing FrameWrap + TimeWarp) rather than the whole library. If you were
relying on CCapture being the capture engine itself, that role is now
FrameWrap.
---
Browser support & caveats
- WebCodecs (
mp4,webm): recent Chrome, Edge, Safari, and Firefox.
webm falls back to the legacy writer; mp4 errors.
- GPU motion blur needs WebGL2 +
EXT_color_buffer_float(widely available);
- Capturing a cross-origin canvas taints it and encoding will throw — a
- Odd or mid-capture-resized canvases are fitted to an even coded size
What TimeWarp does and doesn't warp.
Always hooked (they're how capture is driven): performance.now,
requestAnimationFrame/cancelAnimationFrame, setTimeout/setInterval.
Hooked but opt-out-able:
Date.now+Date.prototype.getTime—hookDate(default on);Date.prototype.valueOf(so+new Date()) —hookValueOf(default off);/`currentTime+play—hookMedia` (default on).
- Web Audio — a realtime
AudioContextcan't be stepped, but for
renderAudioFrames() renders the audio offline and
samples an AnalyserNode at each frame time, giving frame-accurate spectrum /
waveform data. See Frame-accurate audio. You can also mux an
AudioBuffer into the output via the audio option (AAC/Opus for mp4, Opus
for webm — where the browser's AudioEncoder supports it; falls back to
video-only otherwise).
Not covered — these run on clocks the library can't step, so they'll drift during a capture:
- the Web Animations API (
element.animate) and CSS animations/transitions,
Note on +new Date(): by default it reads the real construction time (only
Date.now()/getTime()are warped). Set `hookValueOf: