Newer
Older
bugtrail / packages / extension / src / background / gif.ts
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);
      const { data } = context.getImageData(0, 0, size.w, size.h);
      const palette = quantize(data, 256, { format: "rgb444" });
      const index = applyPalette(data, palette, "rgb444");
      encoder.writeFrame(index, size.w, size.h, { palette, delay: Math.round(frame.delayMs) });
    } finally {
      image.close();
    }
  }

  encoder.finish();
  return new Blob([encoder.bytes().buffer as ArrayBuffer], { type: "image/gif" });
}