canvas-record
Version:
Record a video in the browser or directly on the File System from a canvas region (2D/WebGL/WebGPU) as MP4, WebM, MKV, MOV, GIF, PNG/JPG Sequence using WebCodecs and wasm when available.
431 lines (378 loc) • 12.3 kB
JavaScript
import canvasScreenshot from "canvas-screenshot";
import WebCodecsEncoder from "./encoders/WebCodecsEncoder.js";
import H264MP4Encoder from "./encoders/H264MP4Encoder.js";
import GIFEncoder from "./encoders/GIFEncoder.js";
import FrameEncoder from "./encoders/FrameEncoder.js";
import {
downloadBlob,
formatDate,
formatSeconds,
isWebCodecsSupported,
nextMultiple,
captureCanvasRegion,
} from "./utils.js";
/**
* Enum for recorder status
* @readonly
* @enum {number}
*
* @example
* ```js
* // Check recorder status before continuing
* if (canvasRecorder.status !== RecorderStatus.Stopped) {
* rAFId = requestAnimationFrame(() => tick());
* }
* ```
*/
const RecorderStatus = Object.freeze({
Ready: 0,
Initializing: 1,
Recording: 2,
Stopping: 3,
Stopped: 4,
});
/**
* A callback to notify on the status change. To compare with RecorderStatus enum values.
* @callback onStatusChangeCb
* @param {number} RecorderStatus the status
*/
/**
* @typedef {object} RecorderOptions Options for recording. All optional.
* @property {string} [name=""] A name for the recorder, used as prefix for the default file name.
* @property {number} [duration=10] The recording duration in seconds. If set to Infinity, `await canvasRecorder.stop()` needs to be called manually.
* @property {number} [frameRate=30] The frame rate in frame per seconds. Use `await canvasRecorder.step();` to go to the next frame.
* @property {Array} [rect=[]] Sub-region [x, y, width, height] of the canvas to encode from bottom left. Default to 0, 0 and context.drawingBufferWidth/drawingBufferHeight or canvas.width/height.
* @property {boolean} [download=true] Automatically download the recording when duration is reached or when `await canvasRecorder.stop()` is manually called.
* @property {string} [extension="mp4"] Default file extension: infers which Encoder is selected.
* @property {string} [target="in-browser"] Default writing target: in-browser or file-system when available.
* @property {object} [encoder] A specific encoder. Default encoder based on options.extension: GIF > WebCodecs > H264MP4.
* @property {object} [encoderOptions] See `src/encoders` or individual packages for a list of options.
* @property {object} [muxerOptions] See "mediabunny" for a list of options.
* @property {object} [frameOptions] Options for createImageBitmap(), VideoFrame, getImageData() or canvas-screenshot.
* @property {onStatusChangeCb} [onStatusChange]
*/
/**
* @typedef {object} RecorderStartOptions Options for recording initialisation. All optional.
* @property {string} [filename] Overwrite the file name completely.
* @property {boolean} [initOnly] Only initialised the recorder and don't call the first await recorder.step().
*/
class Recorder {
/**
* Sensible defaults for recording so that the recorder "just works".
* @type {RecorderOptions}
*/
static defaultOptions = {
name: "",
duration: 10, // 0 to Infinity
frameRate: 30,
rect: [],
download: true,
extension: "mp4",
target: "in-browser",
onStatusChange: () => {},
};
/**
* A mapping of extension to their mime types
* @type {object}
*/
static mimeTypes = {
mkv: "video/x-matroska;codecs=avc1",
mov: "video/quicktime",
webm: "video/webm",
mp4: "video/mp4",
gif: "image/gif",
};
set width(value) {
this.encoder.width = value;
}
set height(value) {
this.encoder.height = value;
}
get x() {
return this.rect[0] ?? 0;
}
get y() {
return this.rect[1] ?? 0;
}
// Only used if rect is defined
get yFlipped() {
return this.canvasHeight - this.rect[1] - this.rect[3];
}
get width() {
return this.rect[2] ?? this.canvasWidth;
}
get height() {
return this.rect[3] ?? this.canvasHeight;
}
get canvasWidth() {
return this.context.drawingBufferWidth ?? this.context.canvas.width;
}
get canvasHeight() {
return this.context.drawingBufferHeight ?? this.context.canvas.height;
}
get stats() {
if (this.status !== RecorderStatus.Recording) return undefined;
const renderTime = (Date.now() - this.startTime.getTime()) / 1000;
const secondsPerFrame = renderTime / this.frame;
return {
renderTime,
secondsPerFrame,
detail: `Time: ${this.time.toFixed(2)} / ${this.duration.toFixed(2)}
Frame: ${this.frame} / ${this.frameTotal}
Elapsed Time: ${formatSeconds(renderTime)}
Remaining Time: ${formatSeconds(secondsPerFrame * this.frameTotal - renderTime)}
Speedup: x${(this.time / renderTime).toFixed(3)}`,
};
}
#updateStatus(status) {
this.status = status;
this.onStatusChange(this.status);
}
getParamString() {
return `${this.width}x${this.height}@${this.frameRate}fps`;
}
getDefaultFileName(extension) {
return `${[this.name, formatDate(this.startTime), this.getParamString()]
.filter(Boolean)
.join("-")}.${extension}`;
}
getSupportedExtension() {
const CurrentEncoder = this.encoder.constructor;
const isExtensionSupported = CurrentEncoder.supportedExtensions.includes(
this.extension,
);
const extension = isExtensionSupported
? this.extension
: CurrentEncoder.supportedExtensions[0];
if (!isExtensionSupported) {
console.warn(
`canvas-record: unsupported extension for encoder "${CurrentEncoder.name}". Defaulting to "${extension}".`,
);
}
return extension;
}
getSupportedTarget() {
const CurrentEncoder = this.encoder.constructor;
let isTargetSupported = CurrentEncoder.supportedTargets.includes(
this.target,
);
if (this.target === "file-system" && !("showSaveFilePicker" in window)) {
isTargetSupported = false;
}
const target = isTargetSupported
? this.target
: CurrentEncoder.supportedTargets[0];
if (!isTargetSupported) {
console.warn(
`canvas-record: unsupported target for encoder "${CurrentEncoder.name}". Defaulting to "${target}".`,
);
}
return target;
}
/**
* Create a Recorder instance
* @class Recorder
* @param {RenderingContext} context
* @param {RecorderOptions} [options={}]
*/
constructor(context, options = {}) {
this.context = context;
const opts = { ...Recorder.defaultOptions, ...options };
Object.assign(this, opts);
if (!this.encoder) {
if (this.extension === "gif") {
this.encoder = new GIFEncoder(opts);
} else if (["png", "jpg"].includes(this.extension)) {
this.encoder = new FrameEncoder(opts);
} else {
this.encoder = isWebCodecsSupported
? new WebCodecsEncoder(opts)
: new H264MP4Encoder(opts);
}
}
this.is2D = context instanceof CanvasRenderingContext2D;
this.#updateStatus(RecorderStatus.Ready);
}
/**
* Sets up the recorder internals and the encoder depending on supported features.
* @private
*/
async init({ filename } = {}) {
this.#updateStatus(RecorderStatus.Initializing);
this.deltaTime = 1 / this.frameRate;
this.time = 0;
this.frame = 0;
this.frameTotal = this.duration * this.frameRate;
const extension = this.getSupportedExtension();
const target = this.getSupportedTarget();
this.startTime = new Date();
this.filename = filename || this.getDefaultFileName(extension);
await this.encoder.init({
encoderOptions: this.encoderOptions,
muxerOptions: this.muxerOptions,
canvas: this.context.canvas,
width: this.width,
height: this.height,
frameRate: this.frameRate,
extension,
target,
mimeType: Recorder.mimeTypes[extension],
filename: this.filename,
debug: this.debug,
});
this.#updateStatus(RecorderStatus.Initialized);
}
/**
* Start the recording by initializing and optionally calling the initial step.
* @param {RecorderStartOptions} [startOptions={}]
*/
async start(startOptions = {}) {
await this.init(startOptions);
// Ensure initializing worked
if (this.status !== RecorderStatus.Initialized) {
console.debug("canvas-record: recorder not initialized.");
return;
}
this.#updateStatus(RecorderStatus.Recording);
if (!startOptions.initOnly) await this.step();
}
/**
* Convert the context into something encodable (bitmap, blob, buffer...)
* @private
*/
async getFrame(frameMethod) {
switch (frameMethod) {
case "bitmap": {
return await createImageBitmap(
this.context.canvas,
...(this.rect.length
? [
this.x,
this.yFlipped,
this.width,
this.height,
this.frameOptions,
]
: [this.frameOptions]),
);
}
case "videoFrame": {
let { canvas } = this.context;
// Note: visibleRect doesn't crop in WebGL so we need to capture
let visibleRect;
if (this.rect.length) {
const { x, yFlipped: y, width, height } = this;
if (this.is2D) {
visibleRect = { x, y, width, height };
} else {
canvas = captureCanvasRegion(canvas, x, y, width, height);
}
}
return new VideoFrame(canvas, {
timestamp: this.time * 1_000_000, // in µs
duration: 1_000_000 / this.frameRate,
visibleRect,
...this.frameOptions,
});
}
case "requestFrame": {
return undefined;
}
case "imageData": {
if (!this.is2D) {
const width = this.width;
const height = this.height;
const length = width * height * 4;
const pixels = new Uint8Array(length);
const pixelsFlipped = new Uint8Array(length);
this.context.readPixels(
this.x,
this.y,
width,
height,
this.context.RGBA,
this.context.UNSIGNED_BYTE,
pixels,
);
// Flip vertically
const row = width * 4;
const end = (height - 1) * row;
for (let i = 0; i < length; i += row) {
pixelsFlipped.set(pixels.subarray(i, i + row), end - i);
}
return pixelsFlipped;
}
return this.context.getImageData(
this.x,
this.yFlipped,
nextMultiple(this.width, 2),
nextMultiple(this.height, 2),
this.frameOptions,
).data;
}
default: {
const canvas = this.rect.length
? captureCanvasRegion(
this.context.canvas,
this.x,
this.yFlipped,
this.width,
this.height,
)
: this.context.canvas;
return await canvasScreenshot(canvas, {
useBlob: true,
download: false,
filename: `output.${this.encoder.extension}`,
...this.frameOptions,
});
}
}
}
/**
* Encode a frame and increment the time and the playhead.
* Calls `await canvasRecorder.stop()` when duration is reached.
*/
async step() {
if (
this.status === RecorderStatus.Recording &&
this.frame < this.frameTotal
) {
await this.encoder.encode(
await this.getFrame(this.encoder.frameMethod),
this.frame,
);
this.time += this.deltaTime;
this.frame++;
} else {
await this.stop();
}
}
/**
* Stop the recording and return the recorded buffer.
* If options.download is set, automatically start downloading the resulting file.
* Is called when duration is reached or manually.
* @returns {(ArrayBuffer|Uint8Array|Blob[]|undefined)}
*/
async stop() {
if (this.status !== RecorderStatus.Recording) return;
this.#updateStatus(RecorderStatus.Stopping);
const buffer = await this.encoder.stop();
if (this.download && buffer) {
downloadBlob(
this.filename,
Array.isArray(buffer) ? buffer : [buffer],
this.encoder.mimeType,
);
}
this.#updateStatus(RecorderStatus.Stopped);
return buffer;
}
/**
* Clean up the recorder and encoder
*/
async dispose() {
await this.encoder.dispose();
}
}
export { Recorder, RecorderStatus };