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