import { GIFEncoder, quantize, applyPalette } from "gifenc";
/**
* Builds an animated GIF from event frames captured during a recording.
* Frames are scaled to max 800px wide; every frame carries the delay of the
* gap that followed its event (clamped), so the GIF paces like the track.
*/
export interface GifFrame {
dataUrl: string;
delayMs: number;
}
export async function buildGif(frames: GifFrame[], maxWidth = 800): Promise<Blob | null> {
if (!frames.length) return null;
const encoder = GIFEncoder();
let size: { w: number; h: number } | null = null;
for (const frame of frames) {
const blob = await (await fetch(frame.dataUrl)).blob();
const image = await createImageBitmap(blob);
try {
if (!size) {
const scale = Math.min(1, maxWidth / image.width);
size = { w: Math.max(1, Math.round(image.width * scale)), h: Math.max(1, Math.round(image.height * scale)) };
}
const canvas = new OffscreenCanvas(size.w, size.h);
const context = canvas.getContext("2d");
if (!context) throw new Error("no 2d context");
context.drawImage(image, 0, 0, size.w, size.h);
await encodeFrame(encoder, context, size.w, size.h, frame.delayMs);
} finally {
image.close();
}
}
encoder.finish();
return new Blob([encoder.bytes().buffer as ArrayBuffer], { type: "image/gif" });
}
async function encodeFrame(
encoder: ReturnType<typeof GIFEncoder>,
context: OffscreenCanvasRenderingContext2D,
width: number,
height: number,
delayMs: number
): Promise<void> {
const { data } = context.getImageData(0, 0, width, height);
const palette = quantize(data, 256, { format: "rgb444" });
const index = applyPalette(data, palette, "rgb444");
encoder.writeFrame(index, width, height, { palette, delay: Math.round(delayMs) });
}
/** A screencast frame captured while recording (JPEG bytes + device-pixel size). */
export interface VideoFrame {
/** client timestamp (Date.now()) of the frame's arrival */
at: number;
jpeg: Uint8Array;
w: number;
h: number;
}
export interface CursorGifOptions {
/** output square size in px */
size: number;
/** CSS viewport the cursor track was recorded in */
viewport: { w: number; h: number };
/** recording start (Date.now()) — frame times convert to track offsets */
startedAt: number;
/** track points: {t (ms from start), x, y} in CSS px */
track: { t: number; x: number; y: number }[];
/** hard cap on GIF frames — keeps the upload under the server limit */
maxFrames?: number;
}
/** Options shared by every cursor-following encoder (GIF and WebM). */
export interface CursorCropOptions {
/** CSS viewport the cursor track was recorded in */
viewport: { w: number; h: number };
/** recording start (Date.now()) — frame times convert to track offsets */
startedAt: number;
/** track points: {t (ms from start), x, y} in CSS px */
track: { t: number; x: number; y: number }[];
/** output square size in CSS px */
size: number;
}
/**
* Source rectangle for a cursor-following `size`×`size` CSS-px square crop of
* a screencast frame, centered on the cursor position (interpolated from the
* mouse track at the frame's time). The square is clamped to stay inside the
* viewport; when the viewport is smaller than the square the crop centers on
* the smaller canvas. Shared by the GIF and WebM encoders.
*/
export function cursorCropRect(
frame: VideoFrame,
options: { viewport: { w: number; h: number }; startedAt: number; track: { t: number; x: number; y: number }[]; size: number }
): { sx: number; sy: number; s: number } {
// device px per CSS px for this frame (fallback: frame pixels are CSS px)
const scale = options.viewport.w > 0 ? frame.w / options.viewport.w : 1;
if (!Number.isFinite(scale) || scale <= 0) throw new Error("bad frame scale");
const cursor = cursorAt(options.track, frame.at - options.startedAt, options.viewport);
// clamp the square so it stays inside the viewport
const crop = Math.min(options.size, options.viewport.w, options.viewport.h);
const half = crop / 2;
const cx = Math.min(Math.max(cursor.x, half), Math.max(options.viewport.w - half, half));
const cy = Math.min(Math.max(cursor.y, half), Math.max(options.viewport.h - half, half));
const sx = Math.min(Math.max((cx - half) * scale, 0), Math.max(frame.w - crop * scale, 0));
const sy = Math.min(Math.max((cy - half) * scale, 0), Math.max(frame.h - crop * scale, 0));
const s = Math.min(crop * scale, frame.w - sx, frame.h - sy);
return { sx, sy, s };
}
/**
* Builds a square GIF that follows the cursor: each screencast frame is
* cropped to a `size`×`size` CSS-px square centered on the cursor position
* (interpolated from the mouse track at the frame's time) and paces like the
* recording. All output frames share the same dimensions; when the viewport
* is smaller than the square, the crop is centered on the smaller canvas.
*/
export async function buildCursorGif(frames: VideoFrame[], options: CursorGifOptions): Promise<Blob | null> {
if (!frames.length) return null;
// decimate long recordings instead of producing a huge GIF
let picked = frames;
while (picked.length > (options.maxFrames ?? 240)) picked = picked.filter((_, i) => i % 2 === 0);
const size = options.size;
const encoder = GIFEncoder();
const canvas = new OffscreenCanvas(size, size);
const context = canvas.getContext("2d");
if (!context) throw new Error("no 2d context");
for (let position = 0; position < picked.length; position++) {
const frame = picked[position];
const nextAt = position + 1 < picked.length ? picked[position + 1].at : null;
const delayMs = nextAt != null ? Math.min(Math.max(nextAt - frame.at, 66), 2000) : 500;
const image = await createImageBitmap(new Blob([frame.jpeg.buffer as ArrayBuffer], { type: "image/jpeg" }));
try {
const { sx, sy, s } = cursorCropRect(frame, options);
context.fillStyle = "#10121c";
context.fillRect(0, 0, size, size);
context.drawImage(image, sx, sy, s, s, 0, 0, size, size);
await encodeFrame(encoder, context, size, size, delayMs);
} finally {
image.close();
}
}
encoder.finish();
return new Blob([encoder.bytes().buffer as ArrayBuffer], { type: "image/gif" });
}
/** Last known cursor position at track offset t (ms); falls back progressively. */
export function cursorAt(
track: { t: number; x: number; y: number }[],
t: number,
viewport: { w: number; h: number }
): { x: number; y: number } {
if (!track.length) return { x: viewport.w / 2, y: viewport.h / 2 };
let point = track[0];
for (const candidate of track) {
if (candidate.t > t) break;
point = candidate;
}
return point;
}