@@ -58,6 +58,12 @@ export interface OptimizeOptions {
5858 * transparent, others the background color)
5959 */
6060 cover ?: boolean ;
61+ /**
62+ * For lossy frames (WebP): how much a color channel may change (at full alpha) for a pixel to count as unchanged,
63+ * like libwebp's lossy animations. Unchanged pixels then only become transparent in whole 8x8 blocks (the alpha of
64+ * scattered unchanged pixels costs more than leaving them as they are). Needs `replace`
65+ */
66+ tolerance ?: number ;
6167}
6268
6369// what transparent pixels make an opaque frame cost: an alpha channel, 4 channels instead of 3 (or a palette entry
@@ -69,14 +75,14 @@ const ALPHA_COST = 4 / 3;
6975 * @param width The canvas width
7076 * @param height The canvas height
7177 */
72- export function optimize ( frames : InputFrame [ ] , width : number , height : number , { replace, even, alpha, palette, cover } : OptimizeOptions ) : OptimizedFrame [ ] {
78+ export function optimize ( frames : InputFrame [ ] , width : number , height : number , { replace, even, alpha, palette, cover, tolerance } : OptimizeOptions ) : OptimizedFrame [ ] {
7379 const size = width * height * 4 ;
7480 // the input animation's canvas, and what the optimized animation shows after its last frame (and its disposal)
7581 const canvas = new Uint8Array ( size ) , canvas32 = new Int32Array ( canvas . buffer ) ;
7682 const shown = new Uint8Array ( size ) , shown32 = new Int32Array ( shown . buffer ) ;
7783 const output : OptimizedFrame [ ] = [ ] ;
7884
79- const state : State = { canvas32, shown32, width, replace } ;
85+ const state : State = { canvas32, shown32, width, replace, tolerance } ;
8086
8187 // for a shared pixel format: whether the animation is opaque when all frames are stored dense, and the changes
8288 // from pixel to pixel all dense and as chosen
@@ -94,7 +100,7 @@ export function optimize(frames: InputFrame[], width: number, height: number, {
94100 return ;
95101 }
96102 if ( even ) changed = evenArea ( changed ) ;
97- let area = sparse ( state , changed ) ;
103+ let area = tolerance === undefined ? sparse ( state , changed ) : sparseBlocks ( state , changed ) ;
98104 let blend = true ;
99105 if ( area . unblendable && replace ) blend = false ;
100106 else if ( area . unblendable ) {
@@ -115,25 +121,31 @@ export function optimize(frames: InputFrame[], width: number, height: number, {
115121 // compresses better when the unchanged pixels are scattered (e.g. in videos): choose the one with fewer
116122 // changes from pixel to pixel, which estimates the compressed size well and costs a lot less than compressing
117123 // both. GIFs blend the dense area too (its unchanged pixels are the same or transparent in both), the others
118- // replace the area with it
124+ // replace the area with it. Lossy frames are sparse whenever a block is unchanged (like libwebp's)
119125 const { x, y, width : w , height : h } = changed ;
120126 const pixels = area . pixels ;
121127 if ( ! output . length && ( w !== width || h !== height ) ) opaque = false ;
122128 if ( ! area . denseOpaque ) opaque = false ;
123129 const sparseCost = alpha === 'frame' && area . denseOpaque ? area . sparseChanges * ALPHA_COST : area . sparseChanges ;
124- const dense = ! blend || area . denseChanges < sparseCost
130+ const dense = ! blend || ( tolerance !== undefined ? ! area . unchangedBlocks : area . denseChanges < sparseCost
125131 || ( palette !== undefined && colors ( new Int32Array ( pixels . buffer ) , w , { x : 0 , y : 0 , width : w , height : h } , palette + 1 ) > palette
126- && colors ( canvas32 , width , changed , palette + 1 ) <= palette ) ;
132+ && colors ( canvas32 , width , changed , palette + 1 ) <= palette ) ) ;
127133 denseTotal += area . denseChanges ;
128134 chosenTotal += dense ? area . denseChanges : area . sparseChanges ;
129135 if ( dense ) {
130136 for ( let row = 0 ; row < h ; row ++ ) pixels . set ( canvas . subarray ( ( ( y + row ) * width + x ) * 4 , ( ( y + row ) * width + x + w ) * 4 ) , row * w * 4 ) ;
131137 if ( replace ) blend = false ;
132138 }
133- for ( let row = 0 ; row < h ; row ++ ) {
134- const start = ( ( y + row ) * width + x ) * 4 ;
135- shown . set ( canvas . subarray ( start , start + w * 4 ) , start ) ;
136- }
139+ // (lossy frames keep showing the pixels of unchanged blocks as they were, with tolerance they can differ)
140+ if ( ! dense && tolerance !== undefined ) {
141+ const pixels32 = new Int32Array ( pixels . buffer ) ;
142+ for ( let row = 0 , k = 0 ; row < h ; row ++ )
143+ for ( let p = ( y + row ) * width + x , end = p + w ; p < end ; p ++ , k ++ ) if ( pixels32 [ k ] & ALPHA ) shown32 [ p ] = pixels32 [ k ] ;
144+ } else
145+ for ( let row = 0 ; row < h ; row ++ ) {
146+ const start = ( ( y + row ) * width + x ) * 4 ;
147+ shown . set ( canvas . subarray ( start , start + w * 4 ) , start ) ;
148+ }
137149 output . push ( { x, y, width : w , height : h , duration, dispose : false , blend, pixels } ) ;
138150 } ;
139151
@@ -221,6 +233,18 @@ interface State {
221233 shown32 : Int32Array ;
222234 width : number ;
223235 replace : boolean ;
236+ tolerance ?: number ;
237+ }
238+
239+ /**
240+ * Whether two pixels (their offsets in RGBA bytes) look the same within a tolerance, like libwebp's PixelsAreSimilar:
241+ * the same alpha, and each color channel differs by at most the tolerance at full alpha
242+ */
243+ function similar ( a : Uint8Array , i : number , b : Uint8Array , j : number , tolerance : number ) : boolean {
244+ const alpha = a [ i + 3 ] ;
245+ if ( alpha !== b [ j + 3 ] ) return false ;
246+ const limit = tolerance * 255 ;
247+ return Math . abs ( a [ i ] - b [ j ] ) * alpha <= limit && Math . abs ( a [ i + 1 ] - b [ j + 1 ] ) * alpha <= limit && Math . abs ( a [ i + 2 ] - b [ j + 2 ] ) * alpha <= limit ;
224248}
225249
226250// the area grown to even offsets (WebP stores them halved)
@@ -236,6 +260,65 @@ interface SparseArea {
236260 unblendable : Rect | null ;
237261 /** whether the area as it is has no transparent pixels */
238262 denseOpaque : boolean ;
263+ /** lossy frames: how many blocks are unchanged (and transparent in the pixels) */
264+ unchangedBlocks ?: number ;
265+ }
266+
267+ // the size of the blocks lossy frames make transparent (libwebp's)
268+ const BLOCK = 8 ;
269+
270+ /**
271+ * For lossy frames: the area with the 8x8 blocks (from its top left) that didn't change, within the tolerance,
272+ * transparent (their color is the average of what's shown, for the encoder's prediction). The other blocks are as they
273+ * are, they can't be blended where they aren't opaque, unless transparent in both
274+ */
275+ function sparseBlocks ( { canvas32, shown32, width, tolerance = 0 } : State , { x, y, width : w , height : h } : Rect ) : SparseArea {
276+ const canvas = new Uint8Array ( canvas32 . buffer ) , shown = new Uint8Array ( shown32 . buffer ) ;
277+ const pixels = new Uint8Array ( w * h * 4 ) , pixels32 = new Int32Array ( pixels . buffer ) ;
278+ let left = w , right = - 1 , top = h , bottom = - 1 , translucent = 0 , unchangedBlocks = 0 ;
279+ for ( let by = 0 ; by < h ; by += BLOCK )
280+ for ( let bx = 0 ; bx < w ; bx += BLOCK ) {
281+ const bw = Math . min ( BLOCK , w - bx ) , bh = Math . min ( BLOCK , h - by ) ;
282+ let unchanged = true ;
283+ for ( let row = by ; row < by + bh && unchanged ; row ++ )
284+ for ( let p = ( y + row ) * width + x + bx , end = p + bw ; p < end ; p ++ ) {
285+ const value = canvas32 [ p ] , before = shown32 [ p ] ;
286+ if ( value !== before && ( ( value | before ) & ALPHA ) !== 0 && ! similar ( canvas , p * 4 , shown , p * 4 , tolerance ) ) {
287+ unchanged = false ;
288+ break ;
289+ }
290+ }
291+
292+ if ( unchanged ) {
293+ unchangedBlocks ++ ;
294+ let r = 0 , g = 0 , b = 0 ;
295+ for ( let row = by ; row < by + bh ; row ++ )
296+ for ( let p = ( ( y + row ) * width + x + bx ) * 4 , end = p + bw * 4 ; p < end ; p += 4 ) {
297+ r += shown [ p ] ; g += shown [ p + 1 ] ; b += shown [ p + 2 ] ;
298+ }
299+ const n = bw * bh ;
300+ r = r / n | 0 ; g = g / n | 0 ; b = b / n | 0 ;
301+ for ( let row = by ; row < by + bh ; row ++ )
302+ for ( let k = ( ( row * w ) + bx ) * 4 , end = k + bw * 4 ; k < end ; k += 4 ) {
303+ pixels [ k ] = r ; pixels [ k + 1 ] = g ; pixels [ k + 2 ] = b ;
304+ }
305+ }
306+ for ( let row = by ; row < by + bh ; row ++ )
307+ for ( let i = bx , k = row * w + bx , p = ( y + row ) * width + x + bx ; i < bx + bw ; i ++ , k ++ , p ++ ) {
308+ const value = canvas32 [ p ] ;
309+ translucent |= ~ value & ALPHA ;
310+ if ( unchanged ) continue ;
311+ pixels32 [ k ] = value ;
312+ if ( ( value & ALPHA ) !== ALPHA && ( ( value | shown32 [ p ] ) & ALPHA ) !== 0 ) {
313+ if ( i < left ) left = i ;
314+ if ( i > right ) right = i ;
315+ if ( row < top ) top = row ;
316+ if ( row > bottom ) bottom = row ;
317+ }
318+ }
319+ }
320+ const unblendable : Rect | null = right < 0 ? null : { x : x + left , y : y + top , width : right - left + 1 , height : bottom - top + 1 } ;
321+ return { pixels, sparseChanges : 0 , denseChanges : 0 , unblendable, denseOpaque : translucent === 0 , unchangedBlocks } ;
239322}
240323
241324/**
@@ -279,14 +362,15 @@ function sparse({ canvas32, shown32, width, replace }: State, { x, y, width: w,
279362}
280363
281364/**
282- * The bounding box of the changed pixels within an area. Searches from the edges inwards, so it stops early when much
365+ * The bounding box of the changed pixels (within the tolerance) within an area. Searches from the edges inwards, so it stops early when much
283366 * changed
284367 */
285- function bounds ( { canvas32, shown32, width } : State , area : Rect ) : Rect | null {
368+ function bounds ( { canvas32, shown32, width, tolerance } : State , area : Rect ) : Rect | null {
286369 const x0 = area . x , x1 = area . x + area . width , y0 = area . y , y1 = area . y + area . height ;
370+ const canvas = new Uint8Array ( canvas32 . buffer ) , shown = new Uint8Array ( shown32 . buffer ) ;
287371 const test = ( p : number ) : boolean => {
288372 const c = canvas32 [ p ] , s = shown32 [ p ] ;
289- return c !== s && ( ( c | s ) & ALPHA ) !== 0 ;
373+ return c !== s && ( ( c | s ) & ALPHA ) !== 0 && ( tolerance === undefined || ! similar ( canvas , p * 4 , shown , p * 4 , tolerance ) ) ;
290374 } ;
291375 const rowHas = ( y : number , from : number , to : number ) : number => {
292376 for ( let x = from , p = y * width + from ; x < to ; x ++ , p ++ ) if ( test ( p ) ) return x ;
0 commit comments