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.
174 lines (145 loc) • 4.81 kB
JavaScript
import {
Output,
Mp4OutputFormat,
MovOutputFormat,
WebMOutputFormat,
MkvOutputFormat,
BufferTarget,
EncodedVideoPacketSource,
EncodedPacket,
StreamTarget,
} from "mediabunny";
import { AVC, VP } from "media-codecs";
import Encoder from "./Encoder.js";
import { estimateBitRate } from "../utils.js";
const extensionToOutputFormat = {
mp4: Mp4OutputFormat,
mov: MovOutputFormat,
webm: WebMOutputFormat,
mkv: MkvOutputFormat,
};
/**
* @typedef {object} WebCodecsEncoderOptions
* @property {number} [groupOfPictures=20]
* @property {number} [flushFrequency=10]
* @property {WebCodecsEncoderEncoderOptions} [encoderOptions={}]
*/
/**
* @typedef {VideoEncoderConfig} WebCodecsEncoderEncoderOptions
* @see [VideoEncoder.configure]{@link https://developer.mozilla.org/en-US/docs/Web/API/VideoEncoder/configure#config}
*/
/**
* @typedef {import("mediabunny").OutputOptions} WebCodecsMuxerOptions
* @see [mediabunny#output-formats]{@link https://mediabunny.dev/guide/output-formats}
*/
class WebCodecsEncoder extends Encoder {
static supportedExtensions = ["mp4", "mov", "webm", "mkv"];
static supportedTargets = ["in-browser", "file-system"];
static defaultOptions = {
extension: WebCodecsEncoder.supportedExtensions[0],
groupOfPictures: 20,
flushFrequency: 10,
};
get frameMethod() {
return "videoFrame";
}
/**
* @param {WebCodecsEncoderOptions} [options]
*/
constructor(options) {
super({ ...WebCodecsEncoder.defaultOptions, ...options });
}
async init(options) {
super.init(options);
if (this.target === "file-system") {
const fileHandle = await this.getFileHandle(this.filename, {
types: [
{
description: "Video File",
accept: { [this.mimeType.split(";")[0]]: [`.${this.extension}`] },
},
],
});
this.writableFileStream = await this.getWritableFileStream(fileHandle);
}
const format = new extensionToOutputFormat[this.extension]({
fastStart: this.writableFileStream ? false : "in-memory",
});
const isISOBMFF = ["mp4", "mov"].includes(this.extension);
// TODO: use format.getSupportedVideoCodecs();
const codec =
this.encoderOptions?.codec ||
(isISOBMFF
? AVC.getCodec({ profile: "High", level: "5.2" }) // avc1.640034
: VP.getCodec({ name: "VP9", profile: 0, level: "1", bitDepth: 8 })); // vp09.00.10.08
const CCCC = codec.split(".")[0];
this.muxer = new Output({
format,
target: this.writableFileStream
? new StreamTarget(this.writableFileStream)
: new BufferTarget(),
...this.muxerOptions,
});
const videoCodec = isISOBMFF
? // Supported: "avc" | "hevc"
CCCC.startsWith("hev") || CCCC.startsWith("hvc") // https://www.w3.org/TR/webcodecs-hevc-codec-registration/#fully-qualified-codec-strings
? "hevc"
: "avc"
: // Supported: "vp8" | "vp9" | "av1"
CCCC.startsWith("av01")
? "av1"
: VP.VP_CODECS.find((codec) => codec.cccc === CCCC).name.toLowerCase();
const videoSource = new EncodedVideoPacketSource(videoCodec);
this.muxer.addVideoTrack(videoSource, { frameRate: this.frameRate });
this.encoder = new VideoEncoder({
output: async (chunk, meta) => {
await videoSource.add(EncodedPacket.fromEncodedChunk(chunk), meta);
},
error: (e) => console.error(e),
});
const config = {
width: this.width,
height: this.height,
framerate: this.frameRate,
bitrate: estimateBitRate(
this.width,
this.height,
this.frameRate,
this.encoderOptions.bitrateMode,
),
// bitrate: 1e6,
// alpha: "discard", // "keep"
// bitrateMode: "variable", // "constant"
// latencyMode: "quality", // "realtime" (faster encoding)
// hardwareAcceleration: "no-preference", // "prefer-hardware" "prefer-software"
...this.encoderOptions,
codec,
};
this.encoder.configure(config);
if (!(await VideoEncoder.isConfigSupported(config)).supported) {
throw new Error(
`canvas-record: Unsupported VideoEncoder config\n ${JSON.stringify(
config,
)}`,
);
}
}
async encode(frame, number) {
if (number === 0) await this.muxer.start();
const keyFrame = number % this.groupOfPictures === 0;
this.encoder.encode(frame, { keyFrame });
frame.close();
if (this.flushFrequency && (number + 1) % this.flushFrequency === 0) {
await this.encoder.flush();
}
}
async stop() {
await this.encoder.flush();
await this.muxer.finalize();
return this.muxer.target?.buffer;
}
async dispose() {
this.encoder = null;
}
}
export default WebCodecsEncoder;