Skip to content

Commit 2180106

Browse files
committed
move frame offsets with every size-changing method and add them to Animation
1 parent 9bfa152 commit 2180106

4 files changed

Lines changed: 278 additions & 40 deletions

File tree

‎src/animation.ts‎

Lines changed: 110 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import * as jxl from './codecs/jxl.ts';
66
import { availableThreads } from './util/threads.ts';
77
import { BinaryData, view } from './util/mem.ts';
88
import { GainMap } from './gainmap.ts';
9-
import { Image, DecodeOptions, EncodeOptions, GIFEncodeOptions, ContainOptions, ResizeOptions, ResizeToOptions, resizeArguments, insideSize } from './image.ts';
9+
import { Image, DecodeOptions, EncodeOptions, GIFEncodeOptions, ContainOptions, ResizeOptions, ResizeToOptions, RotateOptions, TransformOptions, ShadowOptions, OutlineOptions, TrimOptions, resizeArguments, insideSize } from './image.ts';
1010
import { Frame } from './frame.ts';
1111
import { ImageType } from './image-type.ts';
1212
import { optimize, OptimizeOptions, OptimizedFrame } from './util/optimize.ts';
@@ -283,16 +283,120 @@ export class Animation {
283283
}
284284
this.__canvas__ = [boxWidth, boxHeight];
285285
if (backgroundColor !== 0) {
286-
const composited = png.compositeAnimation({ width: boxWidth, height: boxHeight, frames: this.__apngFrames__() });
287-
this.frames = composited.map(({ pixels, duration }) => {
288-
const content = new Image(boxWidth, boxHeight);
289-
content.bitmap = pixels;
290-
return new Frame(new Image(boxWidth, boxHeight).fill(backgroundColor).composite(content), { duration, disposalMode: 'background' });
286+
this.frames = this.__flattened__().map(frame => {
287+
frame.image = new Image(boxWidth, boxHeight).fill(backgroundColor).composite(frame.image);
288+
return frame;
291289
});
292290
}
293291
return this;
294292
}
295293

294+
/**
295+
* Scales the animation by a factor: every frame and its offsets, and the canvas
296+
* @param factor The factor
297+
* @param options See {@link ResizeOptions}
298+
*/
299+
scale(factor: number, options?: ResizeOptions): this {
300+
if (!(finite(factor, 'factor') > 0)) throw new RangeError(`Invalid scale factor: ${factor}`);
301+
return this.resize(this.width * factor, this.height * factor, options);
302+
}
303+
304+
/**
305+
* Trims the canvas: removes the borders of one color around what any frame shows (in the composited animation),
306+
* like {@link Image.trim}. The frames keep their pixels, the canvas is cropped like {@link Animation.crop}
307+
* @param options The color (by default the top left pixel of the first frame) and the tolerance, see
308+
* {@link TrimOptions}
309+
*/
310+
trim(options: TrimOptions = {}): this {
311+
checkOptions(options, 'trim({ tolerance: 0.1 })');
312+
const frames = this.__flattened__();
313+
if (!frames.length) return this;
314+
const { color = frames[0].image.getPixelAt(0, 0), ...rest } = options;
315+
let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity;
316+
for (const { image } of frames) {
317+
const bounds = image.__trim_bounds__(color, rest);
318+
if (!bounds) continue;
319+
left = Math.min(left, bounds[0]);
320+
top = Math.min(top, bounds[1]);
321+
right = Math.max(right, bounds[0] + bounds[2]);
322+
bottom = Math.max(bottom, bounds[1] + bounds[3]);
323+
}
324+
if (left === Infinity) return this;
325+
return this.crop(left, top, right - left, bottom - top);
326+
}
327+
328+
/**
329+
* Rotates the animation clockwise, like {@link Image.rotate}: the frames become full canvas frames of the
330+
* composited animation (a partial frame turned by any angle wouldn't cover a rectangle anymore), each one rotated,
331+
* and the canvas takes their size
332+
* @param angle The angle in degrees, clockwise
333+
* @param options See {@link RotateOptions}
334+
*/
335+
rotate(angle: number, options?: RotateOptions): this {
336+
return this.__each__(image => image.rotate(angle, options));
337+
}
338+
339+
/**
340+
* Transforms the animation with an affine transform, like {@link Image.transform}: the frames become full canvas
341+
* frames of the composited animation, each one transformed (by default around the canvas' middle), and the canvas
342+
* takes their size
343+
* @param matrix The six values [a, b, c, d, e, f]
344+
* @param options See {@link TransformOptions}
345+
*/
346+
transform(matrix: ArrayLike<number>, options?: TransformOptions): this {
347+
return this.__each__(image => image.transform(matrix, options));
348+
}
349+
350+
/**
351+
* Adds a shadow behind what the animation shows, like {@link Image.dropShadow}: the frames become full canvas
352+
* frames of the composited animation, each one with its shadow, and the canvas grows with them
353+
* @param options See {@link ShadowOptions}
354+
*/
355+
dropShadow(options?: ShadowOptions): this {
356+
return this.__each__(image => image.dropShadow(options));
357+
}
358+
359+
/**
360+
* Draws an outline around what the animation shows, like {@link Image.outline}: the frames become full canvas
361+
* frames of the composited animation, each one outlined, and the canvas grows with them
362+
* @param options See {@link OutlineOptions}
363+
*/
364+
outline(options?: OutlineOptions): this {
365+
return this.__each__(image => image.outline(options));
366+
}
367+
368+
/**
369+
* The composited animation: one full canvas frame per frame, with its duration (gain maps are dropped)
370+
* @internal
371+
* @ignore
372+
*/
373+
__flattened__(): Frame[] {
374+
const { width, height } = this;
375+
if (!this.length) return [];
376+
return png.compositeAnimation({ width, height, frames: this.__apngFrames__() }).map(({ pixels, duration }) => {
377+
const image = new Image(width, height);
378+
image.bitmap = pixels;
379+
return new Frame(image, { duration, disposalMode: 'background' });
380+
});
381+
}
382+
383+
/**
384+
* Applies a change to every frame of the composited animation; the canvas takes the frames' new size. Nothing
385+
* changes if the change throws
386+
* @internal
387+
* @ignore
388+
*/
389+
private __each__(change: (image: Image) => unknown): this {
390+
const { width, height } = this;
391+
if (!width || !height) throw new RangeError('An animation without frames or canvas can\'t be changed');
392+
const frames = this.__flattened__();
393+
const probe = frames[0]?.image ?? new Image(width, height);
394+
for (const frame of frames.length ? frames : [new Frame(probe)]) change(frame.image);
395+
this.frames = frames;
396+
this.__canvas__ = [probe.width, probe.height];
397+
return this;
398+
}
399+
296400
/**
297401
* Resizes the animation to cover the given box, keeping its aspect ratio, and crops the overflowing edges, like
298402
* {@link Image.cover}: the canvas gets exactly that size

‎src/frame.ts‎

Lines changed: 92 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Image, ResizeOptions, ResizeToOptions, TrimOptions, resizeArguments } from './image.ts';
1+
import { Image, ResizeOptions, ResizeToOptions, TrimOptions, ContainOptions, RotateOptions, TransformOptions, ShadowOptions, OutlineOptions } from './image.ts';
22
import { checkOptions, finite } from './util/options.ts';
33

44
/**
@@ -29,8 +29,10 @@ export interface FrameOptions {
2929

3030
/**
3131
* A frame of an {@link Animation}: an {@link Image} ({@link Frame.image}) with a duration, offsets and a disposal mode.
32-
* The frame's own methods (resize, scale, crop, trim) keep its pixels where they are in the animation by moving the
33-
* offsets along; every other change goes to the image (`frame.image.blur(2)`), which leaves the offsets as they are
32+
* The frame has the methods that change the image's size (resize, scale, contain, cover, crop, trim, rotate, transform,
33+
* dropShadow, outline), which move the offsets along so the frame stays in place in the animation; every other change
34+
* goes to the image (`frame.image.blur(2)`). The image's own size-changing methods leave the offsets as they are.
35+
* Offsets can become negative: the parts of a frame outside the animation's canvas aren't encoded
3436
*/
3537
export class Frame {
3638
/** @internal @ignore */
@@ -177,6 +179,25 @@ export class Frame {
177179
return new Frame(this.__image__.clone(), { duration: this.duration, xOffset: this.xOffset, yOffset: this.yOffset, disposalMode: this.disposalMode });
178180
}
179181

182+
/**
183+
* Runs a change of the image, moving the offsets the way the image reports its pixels moved
184+
* @internal
185+
* @ignore
186+
*/
187+
private __tracked__(change: (image: Image) => unknown): this {
188+
const image = this.__image__;
189+
image.__moved__ = (scaleX, scaleY, x, y) => {
190+
this.xOffset = Math.round(this.xOffset * scaleX + x);
191+
this.yOffset = Math.round(this.yOffset * scaleY + y);
192+
};
193+
try {
194+
change(image);
195+
} finally {
196+
delete image.__moved__;
197+
}
198+
return this;
199+
}
200+
180201
/**
181202
* Resizes the frame, scaling its offsets along with it (as if the whole animation was resized by the same factor)
182203
* @param width The new width
@@ -191,15 +212,9 @@ export class Frame {
191212
*/
192213
resize(options: ResizeToOptions): this;
193214
resize(widthOrOptions: number | ResizeToOptions, heightArgument?: number, optionsArgument?: ResizeOptions): this {
194-
const originalWidth = this.width, originalHeight = this.height;
195-
if (typeof widthOrOptions === 'number') this.__image__.resize(widthOrOptions, heightArgument as number, optionsArgument);
196-
else {
197-
resizeArguments(widthOrOptions, heightArgument, optionsArgument);
198-
this.__image__.resize(widthOrOptions);
199-
}
200-
this.xOffset = Math.round(this.xOffset * this.width / originalWidth);
201-
this.yOffset = Math.round(this.yOffset * this.height / originalHeight);
202-
return this;
215+
return this.__tracked__(image => typeof widthOrOptions === 'number'
216+
? image.resize(widthOrOptions, heightArgument as number, optionsArgument)
217+
: image.resize(widthOrOptions));
203218
}
204219

205220
/**
@@ -208,11 +223,30 @@ export class Frame {
208223
* @param options See {@link ResizeOptions}
209224
*/
210225
scale(factor: number, options?: ResizeOptions): this {
211-
const originalWidth = this.width, originalHeight = this.height;
212-
this.__image__.scale(factor, options);
213-
this.xOffset = Math.round(this.xOffset * this.width / originalWidth);
214-
this.yOffset = Math.round(this.yOffset * this.height / originalHeight);
215-
return this;
226+
return this.__tracked__(image => image.scale(factor, options));
227+
}
228+
229+
/**
230+
* Resizes the frame to fit inside the given box and pads it to exactly that size, like {@link Image.contain}. The
231+
* offsets scale with the image and then move by the padding, so the image stays where the scaled frame would be
232+
* @param width The width of the box
233+
* @param height The height of the box
234+
* @param options The background and how to resize, see {@link ContainOptions}
235+
*/
236+
contain(width: number, height: number, options?: ContainOptions): this {
237+
return this.__tracked__(image => image.contain(width, height, options));
238+
}
239+
240+
/**
241+
* Resizes the frame to cover the given box and crops the overflowing edges, like {@link Image.cover}. The offsets
242+
* scale with the image and then move by the cropped amount, so the remaining pixels stay where the scaled frame
243+
* had them
244+
* @param width The width of the box
245+
* @param height The height of the box
246+
* @param options See {@link ResizeOptions}
247+
*/
248+
cover(width: number, height: number, options?: ResizeOptions): this {
249+
return this.__tracked__(image => image.cover(width, height, options));
216250
}
217251

218252
/**
@@ -224,26 +258,52 @@ export class Frame {
224258
* @param height The new height
225259
*/
226260
crop(x: number, y: number, width: number, height: number): this {
227-
const left = Math.max(0, Math.floor(finite(x, 'x'))), top = Math.max(0, Math.floor(finite(y, 'y')));
228-
this.__image__.crop(x, y, width, height);
229-
this.xOffset += left;
230-
this.yOffset += top;
231-
return this;
261+
return this.__tracked__(image => image.crop(x, y, width, height));
232262
}
233263

234264
/**
235265
* Removes borders of one color, moving the offsets so the remaining pixels stay where they are in the animation
236266
* @param options See {@link TrimOptions}
237267
*/
238-
trim(options?: TrimOptions): this;
239-
trim(options: TrimOptions = {}): this {
240-
checkOptions(options, 'trim({ tolerance: 0.1 })');
241-
const { color, ...rest } = options;
242-
const bounds = this.__image__.__trim_bounds__(color, rest);
243-
if (!bounds) return this;
244-
this.__image__.crop(...bounds);
245-
this.xOffset += bounds[0];
246-
this.yOffset += bounds[1];
247-
return this;
268+
trim(options?: TrimOptions): this {
269+
return this.__tracked__(image => image.trim(options));
270+
}
271+
272+
/**
273+
* Rotates the frame clockwise around its middle, like {@link Image.rotate}: when it grows (or shrinks), the
274+
* offsets move so its middle stays where it is in the animation
275+
* @param angle The angle in degrees, clockwise
276+
* @param options See {@link RotateOptions}
277+
*/
278+
rotate(angle: number, options?: RotateOptions): this {
279+
return this.__tracked__(image => image.rotate(angle, options));
280+
}
281+
282+
/**
283+
* Transforms the frame with an affine transform, like {@link Image.transform}: when it grows, the offsets move
284+
* so the transform's origin stays where it is in the animation
285+
* @param matrix The six values [a, b, c, d, e, f]
286+
* @param options See {@link TransformOptions}
287+
*/
288+
transform(matrix: ArrayLike<number>, options?: TransformOptions): this {
289+
return this.__tracked__(image => image.transform(matrix, options));
290+
}
291+
292+
/**
293+
* Adds a shadow behind the frame's image, like {@link Image.dropShadow}: when it grows up or left, the offsets
294+
* move so the image stays where it is in the animation
295+
* @param options See {@link ShadowOptions}
296+
*/
297+
dropShadow(options?: ShadowOptions): this {
298+
return this.__tracked__(image => image.dropShadow(options));
299+
}
300+
301+
/**
302+
* Draws an outline around the frame's shape, like {@link Image.outline}: when it grows up or left, the offsets
303+
* move so the image stays where it is in the animation
304+
* @param options See {@link OutlineOptions}
305+
*/
306+
outline(options?: OutlineOptions): this {
307+
return this.__tracked__(image => image.outline(options));
248308
}
249309
}

0 commit comments

Comments
 (0)