const WORKER_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.worker.js'; let workerBlobUrl = null; async function getWorkerUrl() { if (workerBlobUrl) return workerBlobUrl; const res = await fetch(WORKER_CDN); const blob = await res.blob(); workerBlobUrl = URL.createObjectURL(blob); return workerBlobUrl; } function loadImage(src) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new Error('Failed to load frame image')); img.src = src; }); } export async function assembleGif(frames, fps) { if (!frames?.length) throw new Error('No frames to assemble.'); if (!window.GIF) throw new Error('GIF.js not loaded.'); const workerScript = await getWorkerUrl(); const images = await Promise.all(frames.map(loadImage)); const w = images[0].naturalWidth; const h = images[0].naturalHeight; const delay = Math.round(1000 / fps); const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('Could not create 2D canvas context.'); return new Promise((resolve, reject) => { const gif = new window.GIF({ workers: 2, quality: 10, width: w, height: h, workerScript, repeat: 0 }); for (const image of images) { ctx.clearRect(0, 0, w, h); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, w, h); ctx.drawImage(image, 0, 0, w, h); gif.addFrame(ctx, { copy: true, delay }); } gif.on('finished', (blob) => resolve(blob)); gif.on('error', (err) => reject(err)); gif.render(); }); }