Skip to content

Commit 85e76d9

Browse files
authored
Add AVIF export to the tools that export WebP (#349)
> Add AVIF export to every tool that currently has WebP export, use feature detection and lazy load necessary extras from a CDN if the browser cannot handle it (but only when needed) Opus 5.5: https://claude.ai/code/session_01BLg3KPjUPF9uTqra3w4Hbn
1 parent e5e5bf7 commit 85e76d9

6 files changed

Lines changed: 872 additions & 121 deletions

‎image-resize-quality.html‎

Lines changed: 135 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
margin-top: 20px;
8686
display: flex;
8787
flex-wrap: wrap;
88+
align-content: flex-start;
8889
gap: 20px;
8990
}
9091
.image-container {
@@ -203,7 +204,8 @@ <h1>Image resize, crop, and quality comparison</h1>
203204
const QUALITY_VALUES = [1, 0.9, 0.7, 0.5, 0.3];
204205
const OUTPUT_FORMATS = {
205206
jpeg: { label: 'JPEG', mime: 'image/jpeg', extension: 'jpg', supportsAlpha: false, qualityValues: QUALITY_VALUES },
206-
webp: { label: 'WebP', mime: 'image/webp', extension: 'webp', supportsAlpha: true, qualityValues: QUALITY_VALUES },
207+
webp: { label: 'WebP', mime: 'image/webp', extension: 'webp', supportsAlpha: true, qualityValues: QUALITY_VALUES, wasmPackage: '@jsquash/webp' },
208+
avif: { label: 'AVIF', mime: 'image/avif', extension: 'avif', supportsAlpha: true, qualityValues: QUALITY_VALUES, wasmPackage: '@jsquash/avif' },
207209
png: { label: 'PNG', mime: 'image/png', extension: 'png', supportsAlpha: true, qualityValues: [null] }
208210
};
209211

@@ -213,6 +215,7 @@ <h1>Image resize, crop, and quality comparison</h1>
213215
let supportedFormatKeys = [];
214216
let cropHasTransparency = false;
215217
let renderGeneration = 0;
218+
let renderAbortController = null;
216219
let outputUrls = [];
217220
const MIN_CROP = 20; // minimum crop size in image pixels
218221

@@ -252,8 +255,8 @@ <h1>Image resize, crop, and quality comparison</h1>
252255
tab.setAttribute('role', 'tab');
253256
tab.setAttribute('aria-selected', String(key === activeFormat));
254257
tab.textContent = format.label;
255-
if (key === 'webp' && !nativeWebpSupported) {
256-
tab.title = 'This browser cannot encode WebP itself, so the @jsquash/webp encoder is loaded from jsdelivr on first use.';
258+
if (format.wasmPackage && !nativeEncodeSupported[key]) {
259+
tab.title = `This browser has no ${format.label} encoder that respects quality settings, so the ${format.wasmPackage} encoder is loaded from jsdelivr on first use.`;
257260
}
258261
tab.addEventListener('click', () => {
259262
if (activeFormat === key) return;
@@ -272,14 +275,16 @@ <h1>Image resize, crop, and quality comparison</h1>
272275
});
273276
}
274277

275-
/* ---------- WebP encoding ----------
276-
Chrome and Firefox encode WebP natively from a canvas, but Safari
277-
(desktop and iOS) silently returns a PNG when asked for image/webp.
278-
Feature-detect that once, and only when native encoding is missing
279-
load the @jsquash/webp wasm encoder (Squoosh's libwebp) from jsdelivr
280-
the first time a WebP is actually requested. */
278+
/* ---------- WebP and AVIF encoding ----------
279+
Browsers that cannot encode a format from a canvas silently return a
280+
PNG instead: Safari (desktop and iOS) cannot do WebP, and most browsers
281+
cannot do AVIF. Feature-detect each format once, and only when native
282+
encoding is missing (or ignores quality) load a jSquash wasm encoder
283+
(Squoosh's libwebp or libavif) from jsdelivr the first time that format
284+
is actually requested. */
281285
const JSQUASH_WEBP_ENCODE_URL = 'https://cdn.jsdelivr.net/npm/@jsquash/webp@1.5.0/encode.js/+esm';
282-
let nativeWebpSupported = false;
286+
const JSQUASH_AVIF_ENCODE_URL = 'https://cdn.jsdelivr.net/npm/@jsquash/avif@2.1.1/encode.js/+esm';
287+
const nativeEncodeSupported = {};
283288
let jsquashWebpPromise = null;
284289

285290
function loadJsquashWebpEncoder() {
@@ -296,17 +301,85 @@ <h1>Image resize, crop, and quality comparison</h1>
296301
return jsquashWebpPromise;
297302
}
298303

299-
function wasmWebpFallbackAvailable() {
300-
return typeof WebAssembly !== 'undefined' && typeof WebAssembly.instantiate === 'function';
304+
// The AVIF encoder takes seconds per image, so unlike WebP it runs in a
305+
// module worker to keep the page responsive.
306+
const AVIF_WORKER_SOURCE = `
307+
let encoderPromise = null;
308+
self.onmessage = async (e) => {
309+
const { data, width, height, quality } = e.data;
310+
try {
311+
if (!encoderPromise) {
312+
encoderPromise = import(${JSON.stringify(JSQUASH_AVIF_ENCODE_URL)}).then(async (mod) => {
313+
await mod.init();
314+
return mod.default;
315+
});
316+
}
317+
const encode = await encoderPromise;
318+
const buffer = await encode({ data, width, height }, { quality });
319+
self.postMessage({ buffer }, [buffer]);
320+
} catch (err) {
321+
self.postMessage({ error: String((err && err.message) || err) });
322+
}
323+
};
324+
`;
325+
let avifWorkerUrl = null;
326+
let avifWorker = null;
327+
// Encodes run one at a time, in the order they were requested.
328+
let avifQueue = Promise.resolve();
329+
330+
function runAvifWorkerJob(imageData, quality) {
331+
if (!avifWorker) {
332+
avifWorkerUrl = avifWorkerUrl || URL.createObjectURL(new Blob([AVIF_WORKER_SOURCE], { type: 'text/javascript' }));
333+
avifWorker = new Worker(avifWorkerUrl, { type: 'module' });
334+
}
335+
const worker = avifWorker;
336+
return new Promise((resolve, reject) => {
337+
const fail = message => {
338+
// Start from a fresh worker next time, so a failed encoder download
339+
// can be retried instead of being cached for the life of the page.
340+
worker.terminate();
341+
if (avifWorker === worker) avifWorker = null;
342+
reject(new Error(message));
343+
};
344+
worker.onmessage = e => {
345+
if (e.data.error !== undefined) fail(e.data.error);
346+
else resolve(new Blob([e.data.buffer], { type: 'image/avif' }));
347+
};
348+
worker.onerror = e => {
349+
e.preventDefault();
350+
fail(e.message || 'AVIF encoder worker failed');
351+
};
352+
const { data, width, height } = imageData;
353+
worker.postMessage({ data, width, height, quality }, [data.buffer]);
354+
});
355+
}
356+
357+
// The pixels are read when the job starts, so the canvas must not be
358+
// drawn on again until the returned promise settles. A job whose `signal`
359+
// has been aborted by then (the crop or format changed) is skipped.
360+
function encodeAvifWithWasm(canvas, quality, signal) {
361+
const job = avifQueue.then(() => {
362+
if (signal && signal.aborted) throw new DOMException('AVIF encode cancelled', 'AbortError');
363+
const imageData = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height);
364+
return runAvifWorkerJob(imageData, Math.round(clamp(0, quality, 1) * 100));
365+
});
366+
avifQueue = job.catch(() => {});
367+
return job;
368+
}
369+
370+
function wasmFallbackAvailable(format) {
371+
const hasWasm = typeof WebAssembly !== 'undefined' && typeof WebAssembly.instantiate === 'function';
372+
return hasWasm && (format.mime !== 'image/avif' || typeof Worker !== 'undefined');
301373
}
302374

303375
function detectSupportedOutputFormats() {
304376
return Promise.all(
305377
Object.entries(OUTPUT_FORMATS).map(([key, format]) => {
306-
return canExportMime(format.mime).then(isSupported => {
307-
if (key === 'webp') {
308-
nativeWebpSupported = isSupported;
309-
return isSupported || wasmWebpFallbackAvailable() ? key : null;
378+
const detect = format.wasmPackage ? nativeEncoderHonoursQuality : canExportMime;
379+
return detect(format.mime).then(isSupported => {
380+
if (format.wasmPackage) {
381+
nativeEncodeSupported[key] = isSupported;
382+
return isSupported || wasmFallbackAvailable(format) ? key : null;
310383
}
311384
return isSupported ? key : null;
312385
});
@@ -329,6 +402,32 @@ <h1>Image resize, crop, and quality comparison</h1>
329402
});
330403
}
331404

405+
// A native encoder with a wasm alternative is only used if it also
406+
// honours the quality setting. Mobile Safari encodes AVIF but ignores
407+
// quality, which made every quality level the same (huge) file.
408+
async function nativeEncoderHonoursQuality(mime) {
409+
const canvas = document.createElement('canvas');
410+
canvas.width = 64;
411+
canvas.height = 64;
412+
if (!canvas.toBlob) return false;
413+
// Noise, so that a lower quality has detail to throw away.
414+
const ctx = canvas.getContext('2d');
415+
const noise = ctx.createImageData(64, 64);
416+
for (let i = 0; i < noise.data.length; i++) {
417+
noise.data[i] = i % 4 === 3 ? 255 : Math.random() * 256;
418+
}
419+
ctx.putImageData(noise, 0, 0);
420+
const encodeAt = quality => new Promise(resolve => {
421+
try {
422+
canvas.toBlob(resolve, mime, quality);
423+
} catch (err) {
424+
resolve(null);
425+
}
426+
});
427+
const [low, high] = await Promise.all([encodeAt(0.2), encodeAt(0.9)]);
428+
return Boolean(low && high && low.type === mime && high.type === mime && low.size !== high.size);
429+
}
430+
332431
function updateFormatTabsSelectedState() {
333432
formatTabs.querySelectorAll('.format-tab').forEach(tab => {
334433
tab.setAttribute('aria-selected', String(tab.dataset.format === activeFormat));
@@ -655,6 +754,9 @@ <h1>Image resize, crop, and quality comparison</h1>
655754
/* ---------- Processing: resize qualities based on CROP area (rounded dims) ---------- */
656755
function processImage(img, options = {}) {
657756
const generation = ++renderGeneration;
757+
if (renderAbortController) renderAbortController.abort();
758+
renderAbortController = new AbortController();
759+
const { signal } = renderAbortController;
658760
const format = OUTPUT_FORMATS[activeFormat];
659761
if (!format) return;
660762

@@ -708,25 +810,21 @@ <h1>Image resize, crop, and quality comparison</h1>
708810
ctx.fillRect(0, 0, w, h);
709811
}
710812

711-
// Draw the CROPPED region scaled to the canvas (rounded source rect)
813+
// Draw the CROPPED region scaled to the canvas (rounded source rect).
814+
// Every quality level is encoded from this same, unchanging canvas.
712815
const sx = Math.round(cropRect.x);
713816
const sy = Math.round(cropRect.y);
714817
const sw = roundedCropW;
715818
const sh = roundedCropH;
819+
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, w, h);
716820

717821
qualities.forEach(quality => {
718-
ctx.clearRect(0, 0, w, h);
719-
if (shouldFillBackground(format)) {
720-
ctx.fillStyle = colorPicker.value;
721-
ctx.fillRect(0, 0, w, h);
722-
}
723-
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, w, h);
724-
725822
const container = document.createElement('div');
726823
container.className = 'image-container';
824+
container.textContent = `Encoding ${format.label}…`;
727825
output.appendChild(container);
728826

729-
createOutputBlob(canvas, format, quality, blob => {
827+
createOutputBlob(canvas, format, quality, signal, blob => {
730828
if (generation !== renderGeneration) return;
731829
if (!blob) {
732830
container.textContent = `Unable to generate ${format.label}.`;
@@ -760,6 +858,7 @@ <h1>Image resize, crop, and quality comparison</h1>
760858
downloadLink.download = `image_crop_${sw}x${sh}_w${w}${quality === null ? '' : `_q${quality.toFixed(1)}`}.${format.extension}`;
761859
downloadLink.textContent = 'Download';
762860

861+
container.textContent = '';
763862
container.appendChild(resultImg);
764863
container.appendChild(infoDiv);
765864
container.appendChild(downloadLink);
@@ -787,10 +886,9 @@ <h1>Image resize, crop, and quality comparison</h1>
787886
}
788887
}
789888

790-
function createOutputBlob(canvas, format, quality, callback) {
791-
if (format.mime === 'image/webp' && !nativeWebpSupported) {
792-
// Copy the pixels now: the caller reuses this canvas for the next
793-
// quality level before the async encoder runs.
889+
function createOutputBlob(canvas, format, quality, signal, callback) {
890+
if (format.mime === 'image/webp' && !nativeEncodeSupported.webp) {
891+
// Copy the pixels now, before the async encoder loads.
794892
const imageData = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height);
795893
const webpQuality = Math.round(clamp(0, quality === null ? 0.75 : quality, 1) * 100);
796894
loadJsquashWebpEncoder()
@@ -803,6 +901,14 @@ <h1>Image resize, crop, and quality comparison</h1>
803901
return;
804902
}
805903

904+
if (format.mime === 'image/avif' && !nativeEncodeSupported.avif) {
905+
encodeAvifWithWasm(canvas, quality === null ? 0.5 : quality, signal).then(callback, err => {
906+
if (err.name !== 'AbortError') console.error('AVIF encoding failed', err);
907+
callback(null);
908+
});
909+
return;
910+
}
911+
806912
if (!canvas.toBlob) {
807913
callback(null);
808914
return;

0 commit comments

Comments
 (0)