@@ -38,6 +38,12 @@ export interface AnimationDecodeOptions {
3838export interface AnimationEncodeOptions {
3939 /** Metadata to include, see {@link Metadata} */
4040 metadata ?: Metadata ;
41+ /**
42+ * Whether to store each frame as the area that changed since the frame before and merge identical frames, which
43+ * usually makes files a lot smaller. The file shows the same animation either way, but only decodes to the same
44+ * frames without it, which also encodes faster when little changes. Default: `true`
45+ */
46+ optimize ?: boolean ;
4147}
4248
4349/** Options of {@link Animation.encodeGIF} */
@@ -165,72 +171,105 @@ export class Animation extends Array<Frame> {
165171
166172 /**
167173 * Encodes the animation as GIF. Like all encoders, it stores each frame as the area that changed since the frame
168- * before and merges identical frames, so the file decodes to what the animation shows, but not necessarily to the
169- * same frames. Semi-transparent pixels become opaque or transparent after the frames are composited
174+ * before and merges identical frames (unless the `optimize` option is false), so the file decodes to what the
175+ * animation shows, but not necessarily to the same frames. Semi-transparent pixels become opaque or transparent
176+ * after the frames are composited
170177 * @param options See {@link GIFEncodeOptions}
171178 */
172179 encodeGIF ( options ?: GIFEncodeOptions ) : Promise < Uint8Array > ;
173180 /**
174181 * Encodes the animation as GIF. Like all encoders, it stores each frame as the area that changed since the frame
175- * before and merges identical frames, so the file decodes to what the animation shows, but not necessarily to the
176- * same frames. Semi-transparent pixels become opaque or transparent after the frames are composited
182+ * before and merges identical frames (unless the `optimize` option is false), so the file decodes to what the
183+ * animation shows, but not necessarily to the same frames. Semi-transparent pixels become opaque or transparent
184+ * after the frames are composited
177185 * @param quality 1-100, scales the palette size from 8 colors (1) to 256 colors (90 and above)
178186 */
179187 encodeGIF ( quality ?: number ) : Promise < Uint8Array > ;
180188 async encodeGIF ( qualityOrOptions : number | GIFEncodeOptions = 95 ) : Promise < Uint8Array > {
181- const { quality = 95 , metadata = { } } = isOptions ( qualityOrOptions ) ? qualityOrOptions : { quality : qualityOrOptions } ;
189+ const { quality = 95 , metadata = { } , optimize = true } = isOptions ( qualityOrOptions ) ? qualityOrOptions : { quality : qualityOrOptions } ;
182190 this . __validate__ ( ) ;
183- const frames = this . __optimized__ ( { replace : false , even : false , alpha : 'frame' , palette : 256 , cover : true } ) . map ( f => ( {
184- x : f . x ,
185- y : f . y ,
186- width : f . width ,
187- height : f . height ,
188- // gifs store centiseconds; rounding keeps e.g. 16 ms (60 fps) from becoming 10 ms
189- delay : Math . round ( f . duration / 10 ) ,
190- dispose : f . dispose ? 3 : 1 ,
191- pixels : f . pixels
192- } ) ) ;
193-
194- return gifCodec . encode ( { width : this . width , height : this . height , loopCount : this . loopCount , frames } , quality , gifComments ( resolveMetadata ( metadata ) ) ) ;
191+ const frames = optimize
192+ ? this . __optimized__ ( { replace : false , even : false , alpha : 'frame' , palette : 256 , cover : true } ) . map ( f => ( { ...f , dispose : f . dispose ? 3 : 1 } ) )
193+ : Array . from ( this , frame => ( {
194+ x : frame . xOffset ,
195+ y : frame . yOffset ,
196+ width : frame . width ,
197+ height : frame . height ,
198+ duration : frame . duration ,
199+ dispose : frame . __disposal_id__ ,
200+ pixels : new Uint8Array ( frame . bitmap . buffer , frame . bitmap . byteOffset , frame . bitmap . byteLength )
201+ } ) ) ;
202+ // gifs store centiseconds; rounding keeps e.g. 16 ms (60 fps) from becoming 10 ms
203+ const gifFrames = frames . map ( ( { duration, ...f } ) => ( { ...f , delay : Math . round ( duration / 10 ) } ) ) ;
204+
205+ return gifCodec . encode ( { width : this . width , height : this . height , loopCount : this . loopCount , frames : gifFrames } , quality , gifComments ( resolveMetadata ( metadata ) ) ) ;
195206 }
196207
197208 /**
198209 * Encodes the animation as APNG (animated PNG, lossless and with full alpha). Viewers without APNG support show
199210 * the first frame. Each frame is stored as the area that changed since the frame before, identical frames are
200- * merged: the file shows exactly the same animation, but doesn't necessarily decode to the same frames
211+ * merged (unless the `optimize` option is false): the file shows exactly the same animation, but doesn't
212+ * necessarily decode to the same frames
201213 * @param options See {@link APNGEncodeOptions}
202214 */
203215 encodeAPNG ( options ?: APNGEncodeOptions ) : Promise < Uint8Array > ;
204216 /**
205217 * Encodes the animation as APNG (animated PNG, lossless and with full alpha). Viewers without APNG support show
206218 * the first frame. Each frame is stored as the area that changed since the frame before, identical frames are
207- * merged: the file shows exactly the same animation, but doesn't necessarily decode to the same frames
219+ * merged (unless the `optimize` option is false): the file shows exactly the same animation, but doesn't
220+ * necessarily decode to the same frames
208221 * @param compression The zlib compression level (0-9), higher is smaller but slower
209222 */
210223 encodeAPNG ( compression ?: number ) : Promise < Uint8Array > ;
211224 async encodeAPNG ( compressionOrOptions : number | APNGEncodeOptions = 1 ) : Promise < Uint8Array > {
212- const { compression = 1 , metadata = { } } = isOptions ( compressionOrOptions ) ? compressionOrOptions : { compression : compressionOrOptions } ;
225+ const { compression = 1 , metadata = { } , optimize = true } = isOptions ( compressionOrOptions ) ? compressionOrOptions : { compression : compressionOrOptions } ;
213226 this . __validate__ ( ) ;
214227 if ( ! this . length ) throw new Error ( 'An animation needs at least one frame' ) ;
215228
216- const frames = this . __optimized__ ( { replace : true , even : false , alpha : frames => png . alphaCost ( frames . map ( f => f . pixels ) ) } ) . map ( f => ( { ...f , dispose : f . dispose ? 1 : 0 } ) ) ;
229+ const frames = optimize
230+ ? this . __optimized__ ( { replace : true , even : false , alpha : frames => png . alphaCost ( frames . map ( f => f . pixels ) ) } ) . map ( f => ( { ...f , dispose : f . dispose ? 1 : 0 } ) )
231+ : this . __apngFrames__ ( ) . map ( ( { blend : _ , ...f } ) => f ) ; // blended, the encoder's default
217232 return png . encodeAnimation ( frames , this . width , this . height , toPlays ( this . loopCount ) , compression , pngText ( resolveMetadata ( metadata ) ) ) ;
218233 }
219234
220235 /**
221236 * Encodes the animation as lossless animated WebP. Each frame is stored as the area that changed since the frame
222- * before, identical frames are merged: the file shows exactly the same animation, but doesn't necessarily decode
223- * to the same frames
237+ * before, identical frames are merged (unless the `optimize` option is false) : the file shows exactly the same
238+ * animation, but doesn't necessarily decode to the same frames
224239 * @param options See {@link AnimationEncodeOptions}
225240 */
226- async encodeWebP ( { metadata = { } } : AnimationEncodeOptions = { } ) : Promise < Uint8Array > {
241+ async encodeWebP ( { metadata = { } , optimize = true } : AnimationEncodeOptions = { } ) : Promise < Uint8Array > {
227242 this . __validate__ ( ) ;
228243 if ( ! this . length ) throw new Error ( 'An animation needs at least one frame' ) ;
229244
230- const frames = this . __optimized__ ( { replace : true , even : true , alpha : 'frame' } ) ;
245+ const frames = optimize ? this . __optimized__ ( { replace : true , even : true , alpha : 'frame' } ) : this . __webpFrames__ ( ) ;
231246 return webp . encodeAnimation ( frames , this . width , this . height , toPlays ( this . loopCount ) , exifData ( resolveMetadata ( metadata ) ) ) ;
232247 }
233248
249+ /** @internal the frames as they are, in the WebP codec's terms */
250+ __webpFrames__ ( ) : webp . WebPEncodeFrame [ ] {
251+ const width = this . width , height = this . height ;
252+ // WebP knows no "previous" disposal and stores offsets halved: such animations are stored as full frames
253+ const fullFrames = this . some ( f => f . disposalMode === 'previous' || ( f . disposalMode === 'background' && ( f . xOffset & 1 || f . yOffset & 1 ) ) ) ;
254+ if ( fullFrames )
255+ return png . compositeAnimation ( { width, height, frames : this . __apngFrames__ ( ) } )
256+ . map ( c => ( { x : 0 , y : 0 , width, height, duration : c . duration , dispose : true , pixels : c . pixels } ) ) ;
257+
258+ return Array . from ( this , frame => {
259+ let { xOffset : x , yOffset : y , width : w , height : h } = frame ;
260+ let pixels = new Uint8Array ( frame . bitmap . buffer , frame . bitmap . byteOffset , frame . bitmap . byteLength ) ;
261+ // odd offsets: one transparent pixel more on the left/top (blending makes it invisible)
262+ if ( x & 1 || y & 1 ) {
263+ const dx = x & 1 , dy = y & 1 ;
264+ const padded = new Uint8Array ( ( w + dx ) * ( h + dy ) * 4 ) ;
265+ for ( let row = 0 ; row < h ; row ++ ) padded . set ( pixels . subarray ( row * w * 4 , ( row + 1 ) * w * 4 ) , ( ( row + dy ) * ( w + dx ) + dx ) * 4 ) ;
266+ pixels = padded ;
267+ x -= dx ; y -= dy ; w += dx ; h += dy ;
268+ }
269+ return { x, y, width : w , height : h , duration : frame . duration , dispose : frame . disposalMode === 'background' , pixels } ;
270+ } ) ;
271+ }
272+
234273 /**
235274 * The frames as the encoders store them: each frame only covers what changed since the one before (see
236275 * util/optimize.ts)
0 commit comments