/**
* Encodes the recorded screencast frames into a silent WebM video. WebCodecs
* VideoEncoder is not exposed to service workers, so this runs in the
* offscreen document (hosted by src/offscreen/main.ts). Returns null when
* WebCodecs is unavailable (Firefox) so the caller can fall back to the GIF
* path.
*/
import { Muxer, ArrayBufferTarget } from "webm-muxer";
import type { VideoFrame } from "./gif";
/** VP8 needs even dimensions. */
function even(value: number): number {
return Math.max(2, Math.floor(value / 2) * 2);
}
export async function buildVideoWebm(frames: import("./gif").VideoFrame[]): Promise<Blob | null> {
if (!frames.length || typeof VideoEncoder === "undefined") return null;
// fixed output size: first frame's dimensions capped at 800px wide
const first = frames[0];
const scale = Math.min(1, 800 / first.w);
const width = even(first.w * scale);
const height = even(first.h * scale);
const muxer = new Muxer({
target: new ArrayBufferTarget(),
video: { codec: "V_VP8", width, height, frameRate: 15 },
firstTimestampBehavior: "offset",
});
// the encoder reports async failures through its error callback
const failure: { error: Error | null } = { error: null };
const encoder = new VideoEncoder({
output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
error: (error) => {
failure.error = error instanceof Error ? error : new Error(String(error));
},
});
encoder.configure({ codec: "vp8", width, height, bitrate: 1_200_000 });
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext("2d");
if (!context) throw new Error("no 2d context");
try {
// media timestamps start at the first frame: the muxer requires a
// zero-based timeline (see firstTimestampBehavior above)
const baseAt = frames[0].at;
for (let position = 0; position < frames.length; position++) {
const frame = frames[position];
const nextAt = position + 1 < frames.length ? frames[position + 1].at : null;
const timestampUs = Math.max(frame.at - baseAt, 0) * 1000;
const durationUs = nextAt != null ? Math.max(nextAt - frame.at, 1) * 1000 : 33_000;
const image = await createImageBitmap(new Blob([frame.jpeg.buffer as ArrayBuffer], { type: "image/jpeg" }));
try {
context.drawImage(image, 0, 0, width, height);
} finally {
image.close();
}
const videoFrame = new VideoFrame(canvas, { timestamp: timestampUs, duration: durationUs });
encoder.encode(videoFrame, { keyFrame: position % 30 === 0 });
videoFrame.close();
if (failure.error) throw failure.error;
}
if (encoder.state === "configured") await encoder.flush();
} finally {
encoder.close();
}
if (failure.error) throw failure.error;
muxer.finalize();
const { buffer } = muxer.target as ArrayBufferTarget;
return new Blob([buffer], { type: "video/webm" });
}