diff --git a/src/services/webp.js b/src/services/webp.js index a0f3e66..f68d5d4 100644 --- a/src/services/webp.js +++ b/src/services/webp.js @@ -1,35 +1,3 @@ -const LIB_URLS = [ - 'https://esm.sh/wasm-webp@0.1.0', - 'https://cdn.jsdelivr.net/npm/wasm-webp@0.1.0/+esm' -]; - -let webpLib = null; -let loadingPromise = null; - -async function loadWebpLib() { - if (webpLib) return webpLib; - if (loadingPromise) return loadingPromise; - - loadingPromise = (async () => { - let lastErr = null; - for (const url of LIB_URLS) { - try { - const mod = await import(url); - if (typeof mod?.encodeAnimation !== 'function') { - throw new Error(`encodeAnimation export not found from ${url}`); - } - webpLib = mod; - return webpLib; - } catch (err) { - lastErr = err; - } - } - throw lastErr || new Error('Failed to load animated WebP encoder.'); - })(); - - return loadingPromise; -} - function loadImage(src) { return new Promise((resolve, reject) => { const img = new Image(); @@ -40,9 +8,109 @@ function loadImage(src) { }); } +function canvasToWebpBlob(canvas) { + return new Promise((resolve, reject) => { + canvas.toBlob( + (b) => (b ? resolve(b) : reject(new Error('canvas.toBlob(image/webp) failed'))), + 'image/webp', + 0.9 + ); + }); +} + +function blobToUint8(blob) { + return blob.arrayBuffer().then((ab) => new Uint8Array(ab)); +} + +function le32(v) { + const b = new Uint8Array(4); + b[0] = v & 0xff; + b[1] = (v >> 8) & 0xff; + b[2] = (v >> 16) & 0xff; + b[3] = (v >> 24) & 0xff; + return b; +} + +function le24(v) { + return new Uint8Array([v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff]); +} + +function le16(v) { + return new Uint8Array([v & 0xff, (v >> 8) & 0xff]); +} + +function fourCC(s) { + return new Uint8Array([s.charCodeAt(0), s.charCodeAt(1), s.charCodeAt(2), s.charCodeAt(3)]); +} + +function extractWebpPayload(raw) { + if (String.fromCharCode(raw[0], raw[1], raw[2], raw[3]) !== 'RIFF') { + throw new Error('Not a RIFF/WebP file'); + } + const inner = raw.subarray(12); + + for (let off = 0; off < inner.length; ) { + const tag = String.fromCharCode(inner[off], inner[off + 1], inner[off + 2], inner[off + 3]); + const sz = inner[off + 4] | (inner[off + 5] << 8) | (inner[off + 6] << 16) | (inner[off + 7] << 24); + const chunkData = inner.subarray(off, off + 8 + sz + (sz & 1)); + + if (tag === 'VP8 ' || tag === 'VP8L') { + return { tag, chunkData, hasAlpha: tag === 'VP8L' }; + } + if (tag === 'VP8X') { + const flags = inner[off + 8]; + const hasAlphaFlag = !!(flags & 0x10); + off += 8 + sz + (sz & 1); + for (; off < inner.length; ) { + const t2 = String.fromCharCode(inner[off], inner[off + 1], inner[off + 2], inner[off + 3]); + const s2 = inner[off + 4] | (inner[off + 5] << 8) | (inner[off + 6] << 16) | (inner[off + 7] << 24); + if (t2 === 'ALPH') { + const alphChunk = inner.subarray(off, off + 8 + s2 + (s2 & 1)); + off += 8 + s2 + (s2 & 1); + const t3 = String.fromCharCode(inner[off], inner[off + 1], inner[off + 2], inner[off + 3]); + const s3 = inner[off + 4] | (inner[off + 5] << 8) | (inner[off + 6] << 16) | (inner[off + 7] << 24); + const vp8Chunk = inner.subarray(off, off + 8 + s3 + (s3 & 1)); + const combined = new Uint8Array(alphChunk.length + vp8Chunk.length); + combined.set(alphChunk, 0); + combined.set(vp8Chunk, alphChunk.length); + return { tag: t3, chunkData: combined, hasAlpha: true }; + } + if (t2 === 'VP8 ' || t2 === 'VP8L') { + return { tag: t2, chunkData: inner.subarray(off, off + 8 + s2 + (s2 & 1)), hasAlpha: hasAlphaFlag || t2 === 'VP8L' }; + } + off += 8 + s2 + (s2 & 1); + } + } + off += 8 + sz + (sz & 1); + } + throw new Error('No VP8/VP8L payload found in WebP'); +} + +function buildAnmfChunk(frameData, w, h, duration, hasAlpha) { + const frameX = 0, frameY = 0; + const blend = 0; + const dispose = 1; + const flagsByte = ((blend & 1) << 1) | (dispose & 1); + + const anmfPayload = new Uint8Array(16 + frameData.length); + anmfPayload.set(le24(frameX / 2), 0); + anmfPayload.set(le24(frameY / 2), 3); + anmfPayload.set(le24(w - 1), 6); + anmfPayload.set(le24(h - 1), 9); + anmfPayload.set(le24(duration), 12); + anmfPayload[15] = flagsByte; + anmfPayload.set(frameData, 16); + + const padded = anmfPayload.length & 1 ? anmfPayload.length + 1 : anmfPayload.length; + const chunk = new Uint8Array(8 + padded); + chunk.set(fourCC('ANMF'), 0); + chunk.set(le32(anmfPayload.length), 4); + chunk.set(anmfPayload, 8); + return chunk; +} + export async function assembleAnimatedWebp(frames, fps, maxSize, onProgress) { if (!frames?.length) throw new Error('No frames to assemble.'); - const { encodeAnimation } = await loadWebpLib(); const images = await Promise.all(frames.map(loadImage)); let w = images[0].naturalWidth; @@ -62,27 +130,76 @@ export async function assembleAnimatedWebp(frames, fps, maxSize, onProgress) { const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; - const ctx = canvas.getContext('2d', { willReadFrequently: true }); + const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('Could not create 2D canvas context.'); - const animFrames = []; + const anmfChunks = []; + let anyAlpha = false; + for (let i = 0; i < images.length; i++) { ctx.clearRect(0, 0, w, h); ctx.drawImage(images[i], 0, 0, w, h); - const imgData = ctx.getImageData(0, 0, w, h); - animFrames.push({ - data: new Uint8Array(imgData.data), - duration: delay - }); + onProgress?.(Math.round(((i) / images.length) * 70), `encoding frame ${i + 1} of ${images.length} as webp...`); - onProgress?.(Math.round(((i + 1) / images.length) * 90), `packing frame ${i + 1} of ${images.length}...`); + const blob = await canvasToWebpBlob(canvas); + const raw = await blobToUint8(blob); + const { chunkData, hasAlpha } = extractWebpPayload(raw); + + if (hasAlpha) anyAlpha = true; + anmfChunks.push(buildAnmfChunk(chunkData, w, h, delay, hasAlpha)); + + onProgress?.(Math.round(((i + 1) / images.length) * 70), `frame ${i + 1} of ${images.length} encoded`); } - onProgress?.(95, 'encoding animated webp...'); - const webpData = await encodeAnimation(w, h, true, animFrames); - if (!webpData) throw new Error('Failed to encode animated WebP.'); + onProgress?.(75, 'building animated webp container...'); + + const vp8xFlags = (anyAlpha ? 0x10 : 0) | 0x02; + const vp8xPayload = new Uint8Array(10); + vp8xPayload[0] = vp8xFlags; + vp8xPayload[1] = 0; + vp8xPayload[2] = 0; + vp8xPayload[3] = 0; + const wm1 = w - 1, hm1 = h - 1; + vp8xPayload[4] = wm1 & 0xff; + vp8xPayload[5] = (wm1 >> 8) & 0xff; + vp8xPayload[6] = (wm1 >> 16) & 0xff; + vp8xPayload[7] = hm1 & 0xff; + vp8xPayload[8] = (hm1 >> 8) & 0xff; + vp8xPayload[9] = (hm1 >> 16) & 0xff; + + const vp8xChunk = new Uint8Array(18); + vp8xChunk.set(fourCC('VP8X'), 0); + vp8xChunk.set(le32(10), 4); + vp8xChunk.set(vp8xPayload, 8); + + const animPayload = new Uint8Array(6); + animPayload.set(le32(0x00000000), 0); + animPayload.set(le16(0), 4); + + const animChunk = new Uint8Array(14); + animChunk.set(fourCC('ANIM'), 0); + animChunk.set(le32(6), 4); + animChunk.set(animPayload, 8); + + let totalAnmf = 0; + for (const c of anmfChunks) totalAnmf += c.length; + + const bodySize = vp8xChunk.length + animChunk.length + totalAnmf; + const fileSize = 4 + bodySize; + + const file = new Uint8Array(12 + bodySize); + let off = 0; + file.set(fourCC('RIFF'), off); off += 4; + file.set(le32(fileSize), off); off += 4; + file.set(fourCC('WEBP'), off); off += 4; + file.set(vp8xChunk, off); off += vp8xChunk.length; + file.set(animChunk, off); off += animChunk.length; + for (const c of anmfChunks) { + file.set(c, off); + off += c.length; + } onProgress?.(100, 'animated webp ready'); - return new Blob([webpData], { type: 'image/webp' }); + return new Blob([file], { type: 'image/webp' }); }