mirror of
https://github.com/vibegif/vibegif.lol.git
synced 2026-04-07 10:12:13 +00:00
Fix: pure JS animated webp from canvas blobs
This commit is contained in:
@@ -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' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user