const WORKER_URL = '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_URL); if (!res.ok) throw new Error('Failed to fetch gif worker'); const blob = await res.blob(); workerBlobUrl = URL.createObjectURL(blob); return workerBlobUrl; } /** * Load a base64 data URL into an HTMLImageElement. */ function loadImage(src) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = reject; img.src = src; }); } /** * Assembles frames into an animated GIF blob. * @param {string[]} base64Frames - array of data:image/png;base64,... strings * @param {number} fps - frames per second * @returns {Promise} */ export async function assembleGif(base64Frames, fps) { const workerScript = await getWorkerUrl(); const images = await Promise.all(base64Frames.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'); return new Promise((resolve, reject) => { const gif = new GIF({ workers: 2, quality: 10, width: w, height: h, workerScript, repeat: 0, }); for (const img of images) { ctx.clearRect(0, 0, w, h); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, w, h); ctx.drawImage(img, 0, 0, w, h); gif.addFrame(ctx, { copy: true, delay }); } gif.on('finished', resolve); gif.on('error', reject); gif.render(); }); }