Skip to content

Commit cd7bf5c

Browse files
xfalcoxclaude
andcommitted
feat: add animated WebP encoding and decoding support
Encoder: - encodeAnimated(frames, options) accepts array of {imageData, duration} and produces animated WebP with infinite loop via WebPAnimEncoder API - Links against libwebpmux for muxing frames into container Decoder: - decode(buffer) now handles both static and animated WebP (falls back to demux API for animated files, returning first composited frame) - decodeAnimated(buffer) returns array of {imageData, duration} frames with proper alpha blending and disposal method handling - isAnimated(buffer) returns boolean - Links against libwebpdemux for demuxing animated WebP Also upgrades EMSDK from 2.0.34 to 3.1.57 to fix terser minifier crash. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent da47a2b commit cd7bf5c

16 files changed

Lines changed: 609 additions & 26 deletions

File tree

examples/animated-webp/index.html

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>Animated WebP Decoder Test</title>
7+
<style>
8+
body {
9+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
10+
max-width: 900px;
11+
margin: 40px auto;
12+
padding: 0 20px;
13+
background: #1a1a2e;
14+
color: #eee;
15+
}
16+
h1 { color: #e94560; }
17+
#drop-zone {
18+
border: 3px dashed #555;
19+
border-radius: 12px;
20+
padding: 60px 20px;
21+
text-align: center;
22+
cursor: pointer;
23+
transition: border-color 0.2s, background 0.2s;
24+
margin-bottom: 20px;
25+
}
26+
#drop-zone:hover, #drop-zone.dragover {
27+
border-color: #e94560;
28+
background: rgba(233, 69, 96, 0.05);
29+
}
30+
#status {
31+
padding: 10px;
32+
border-radius: 6px;
33+
margin-bottom: 20px;
34+
display: none;
35+
font-size: 14px;
36+
}
37+
#status.info { display: block; background: #16213e; color: #a8d8ea; }
38+
#status.error { display: block; background: #3d0000; color: #ff6b6b; }
39+
#status.success { display: block; background: #002a00; color: #69db7c; }
40+
.section { margin-bottom: 30px; display: none; }
41+
.section h2 { color: #a8d8ea; margin-bottom: 12px; font-size: 18px; }
42+
.section canvas {
43+
max-width: 100%;
44+
border-radius: 8px;
45+
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
46+
background: repeating-conic-gradient(#1a1a2e 0% 25%, #222244 0% 50%) 0 0 / 16px 16px;
47+
}
48+
.controls {
49+
display: flex;
50+
align-items: center;
51+
gap: 10px;
52+
margin: 12px 0;
53+
}
54+
.controls button {
55+
background: #e94560;
56+
color: white;
57+
border: none;
58+
border-radius: 6px;
59+
padding: 8px 18px;
60+
cursor: pointer;
61+
font-size: 14px;
62+
}
63+
.controls button:hover { background: #c73650; }
64+
.controls input[type="range"] { flex: 1; accent-color: #e94560; }
65+
.controls .frame-info {
66+
color: #888;
67+
font-size: 13px;
68+
min-width: 140px;
69+
text-align: right;
70+
}
71+
#frames-strip {
72+
display: flex;
73+
gap: 6px;
74+
overflow-x: auto;
75+
padding: 10px 0;
76+
}
77+
#frames-strip .thumb {
78+
flex-shrink: 0;
79+
border: 2px solid transparent;
80+
border-radius: 4px;
81+
cursor: pointer;
82+
position: relative;
83+
}
84+
#frames-strip .thumb.active { border-color: #e94560; }
85+
#frames-strip .thumb canvas {
86+
display: block;
87+
border-radius: 2px;
88+
}
89+
#frames-strip .thumb .label {
90+
position: absolute;
91+
bottom: 2px;
92+
right: 2px;
93+
background: rgba(0,0,0,0.7);
94+
color: #fff;
95+
font-size: 10px;
96+
padding: 1px 4px;
97+
border-radius: 2px;
98+
}
99+
.meta { color: #888; font-size: 14px; margin-top: 8px; }
100+
input[type="file"] { display: none; }
101+
</style>
102+
</head>
103+
<body>
104+
<h1>Animated WebP Decoder Test</h1>
105+
106+
<div id="drop-zone">
107+
<p>Drop a .webp file here or click to select</p>
108+
<input type="file" id="file-input" accept=".webp">
109+
</div>
110+
111+
<div id="status"></div>
112+
113+
<div class="section" id="static-section">
114+
<h2>Static WebP — decode()</h2>
115+
<canvas id="static-canvas"></canvas>
116+
<div class="meta" id="static-meta"></div>
117+
</div>
118+
119+
<div class="section" id="animated-section">
120+
<h2>Animated WebP — decodeAnimated()</h2>
121+
<canvas id="animated-canvas"></canvas>
122+
<div class="controls">
123+
<button id="play-btn">Pause</button>
124+
<input type="range" id="frame-slider" min="0" value="0">
125+
<span class="frame-info" id="frame-info"></span>
126+
</div>
127+
<div id="frames-strip"></div>
128+
<div class="meta" id="animated-meta"></div>
129+
</div>
130+
131+
<script type="module">
132+
import webpDecFactory from '../../packages/webp/codec/dec/webp_dec.js';
133+
134+
const dropZone = document.getElementById('drop-zone');
135+
const fileInput = document.getElementById('file-input');
136+
const statusEl = document.getElementById('status');
137+
138+
let wasmModule = null;
139+
140+
function setStatus(msg, type) {
141+
statusEl.textContent = msg;
142+
statusEl.className = type;
143+
}
144+
145+
async function initModule() {
146+
if (wasmModule) return wasmModule;
147+
setStatus('Initializing WASM module...', 'info');
148+
wasmModule = await webpDecFactory({ noInitialRun: true });
149+
return wasmModule;
150+
}
151+
152+
function drawImageData(canvas, imageData) {
153+
canvas.width = imageData.width;
154+
canvas.height = imageData.height;
155+
canvas.getContext('2d').putImageData(imageData, 0, 0);
156+
}
157+
158+
async function handleFile(file) {
159+
document.getElementById('static-section').style.display = 'none';
160+
document.getElementById('animated-section').style.display = 'none';
161+
162+
try {
163+
const module = await initModule();
164+
const buffer = await file.arrayBuffer();
165+
166+
// decode() — first frame (works for both static and animated)
167+
setStatus('Decoding first frame...', 'info');
168+
const t0 = performance.now();
169+
const staticResult = module.decode(buffer);
170+
const staticMs = (performance.now() - t0).toFixed(1);
171+
172+
if (!staticResult) throw new Error('decode() returned null');
173+
174+
drawImageData(document.getElementById('static-canvas'), staticResult);
175+
document.getElementById('static-meta').textContent =
176+
`${staticResult.width}×${staticResult.height}${(file.size / 1024).toFixed(1)} KB — decoded in ${staticMs}ms`;
177+
document.getElementById('static-section').style.display = '';
178+
179+
// isAnimated()
180+
const animated = module.isAnimated(buffer);
181+
if (!animated) {
182+
setStatus(`Static WebP decoded in ${staticMs}ms`, 'success');
183+
return;
184+
}
185+
186+
// decodeAnimated()
187+
setStatus('Decoding all frames...', 'info');
188+
const t1 = performance.now();
189+
const frames = module.decodeAnimated(buffer);
190+
const animMs = (performance.now() - t1).toFixed(1);
191+
192+
if (!frames || frames.length === 0) {
193+
setStatus(`WebP decoded in ${staticMs}ms (animated but decodeAnimated returned ${frames ? frames.length : 'null'} frames)`, 'success');
194+
return;
195+
}
196+
197+
document.getElementById('animated-section').style.display = '';
198+
199+
// Player
200+
const ac = document.getElementById('animated-canvas');
201+
const slider = document.getElementById('frame-slider');
202+
const frameInfo = document.getElementById('frame-info');
203+
const playBtn = document.getElementById('play-btn');
204+
const strip = document.getElementById('frames-strip');
205+
206+
slider.max = frames.length - 1;
207+
slider.value = 0;
208+
209+
let currentFrame = 0;
210+
let playing = true;
211+
let timer = null;
212+
213+
function showFrame(idx) {
214+
currentFrame = idx;
215+
drawImageData(ac, frames[idx].imageData);
216+
slider.value = idx;
217+
frameInfo.textContent = `Frame ${idx + 1}/${frames.length}${frames[idx].duration}ms`;
218+
strip.querySelectorAll('.thumb').forEach((el, i) =>
219+
el.classList.toggle('active', i === idx));
220+
}
221+
222+
function scheduleNext() {
223+
if (!playing) return;
224+
timer = setTimeout(() => {
225+
showFrame((currentFrame + 1) % frames.length);
226+
scheduleNext();
227+
}, frames[currentFrame].duration);
228+
}
229+
230+
function stop() { playing = false; playBtn.textContent = 'Play'; clearTimeout(timer); }
231+
function start() { playing = true; playBtn.textContent = 'Pause'; scheduleNext(); }
232+
233+
playBtn.onclick = () => playing ? stop() : start();
234+
slider.oninput = () => { stop(); showFrame(parseInt(slider.value)); };
235+
236+
// Thumbnail strip
237+
strip.innerHTML = '';
238+
const thumbSize = 64;
239+
for (let i = 0; i < frames.length; i++) {
240+
const div = document.createElement('div');
241+
div.className = 'thumb';
242+
const tc = document.createElement('canvas');
243+
const f = frames[i].imageData;
244+
const scale = Math.min(thumbSize / f.width, thumbSize / f.height);
245+
tc.width = Math.round(f.width * scale);
246+
tc.height = Math.round(f.height * scale);
247+
const tmp = document.createElement('canvas');
248+
tmp.width = f.width;
249+
tmp.height = f.height;
250+
tmp.getContext('2d').putImageData(f, 0, 0);
251+
tc.getContext('2d').drawImage(tmp, 0, 0, tc.width, tc.height);
252+
253+
const label = document.createElement('span');
254+
label.className = 'label';
255+
label.textContent = `${frames[i].duration}ms`;
256+
257+
div.appendChild(tc);
258+
div.appendChild(label);
259+
div.onclick = () => { stop(); showFrame(i); };
260+
strip.appendChild(div);
261+
}
262+
263+
const totalDuration = frames.reduce((s, f) => s + f.duration, 0);
264+
document.getElementById('animated-meta').textContent =
265+
`${frames.length} frames — ${totalDuration}ms total — decoded in ${animMs}ms`;
266+
267+
showFrame(0);
268+
start();
269+
270+
setStatus(`Animated WebP: ${frames.length} frames decoded in ${animMs}ms`, 'success');
271+
} catch (err) {
272+
setStatus(`Error: ${err.message}`, 'error');
273+
console.error(err);
274+
}
275+
}
276+
277+
dropZone.addEventListener('click', () => fileInput.click());
278+
fileInput.addEventListener('change', (e) => {
279+
if (e.target.files[0]) handleFile(e.target.files[0]);
280+
});
281+
dropZone.addEventListener('dragover', (e) => {
282+
e.preventDefault();
283+
dropZone.classList.add('dragover');
284+
});
285+
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
286+
dropZone.addEventListener('drop', (e) => {
287+
e.preventDefault();
288+
dropZone.classList.remove('dragover');
289+
if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]);
290+
});
291+
</script>
292+
</body>
293+
</html>

packages/webp/codec/Makefile

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ all: $(OUT_JS)
1616
# Define dependencies for all variations of build artifacts.
1717
$(filter enc/%,$(OUT_JS)): enc/webp_enc.o
1818
$(filter dec/%,$(OUT_JS)): dec/webp_dec.o
19-
enc/webp_enc.js dec/webp_dec.js: $(CODEC_BASELINE_BUILD_DIR)/libwebp.a
20-
enc/webp_enc_simd.js: $(CODEC_SIMD_BUILD_DIR)/libwebp.a
19+
enc/webp_enc.js: $(CODEC_BASELINE_BUILD_DIR)/libwebp.a $(CODEC_BASELINE_BUILD_DIR)/libwebpmux.a
20+
dec/webp_dec.js: $(CODEC_BASELINE_BUILD_DIR)/libwebp.a $(CODEC_BASELINE_BUILD_DIR)/libwebpdemux.a
21+
enc/webp_enc_simd.js: $(CODEC_SIMD_BUILD_DIR)/libwebp.a $(CODEC_SIMD_BUILD_DIR)/libwebpmux.a
2122

2223
$(OUT_JS):
2324
$(LD) \
@@ -38,8 +39,8 @@ $(OUT_JS):
3839
-o $@ \
3940
$<
4041

41-
%/libwebp.a: %/Makefile
42-
$(MAKE) -C $(@D)
42+
%/libwebp.a %/libwebpmux.a %/libwebpdemux.a: %/Makefile
43+
$(MAKE) -C $(@D) webp libwebpmux webpdemux
4344

4445
# Enable SIMD on a SIMD build.
4546
$(CODEC_SIMD_BUILD_DIR)/Makefile: CMAKE_FLAGS+=-DWEBP_ENABLE_SIMD=1
@@ -52,7 +53,7 @@ $(CODEC_SIMD_BUILD_DIR)/Makefile: CMAKE_FLAGS+=-DWEBP_ENABLE_SIMD=1
5253
-DWEBP_BUILD_CWEBP=0 \
5354
-DWEBP_BUILD_DWEBP=0 \
5455
-DWEBP_BUILD_GIF2WEBP=0 \
55-
-DWEBP_BUILD_IMG2WEBP=0 \
56+
-DWEBP_BUILD_IMG2WEBP=1 \
5657
-DWEBP_BUILD_VWEBP=0 \
5758
-DWEBP_BUILD_WEBPINFO=0 \
5859
-DWEBP_BUILD_WEBPMUX=0 \

0 commit comments

Comments
 (0)