Skip to content

Commit e6cf594

Browse files
committed
add applyOrientation, toneMapping and gainMap to Animation.decode
1 parent 525a0d3 commit e6cf594

6 files changed

Lines changed: 152 additions & 15 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,14 @@
99
values). The old values (`'RESIZE_BILINEAR'`, `1`-`3`, still the values of the deprecated `Image.RESIZE_*` and
1010
`Image.SVG_MODE_*`) keep working until 4.0: `image.resize(256, 256, 'bicubic')`.
1111

12+
- **`Animation.decode()` applies the orientation of APNGs and animated WebPs** (their eXIf and EXIF chunks, and XMP
13+
in PNGs), like `Image.decode()` does (it already did for JPEG XLs). `applyOrientation: false` keeps the stored
14+
frames. Still PNGs decoded by `Animation.decode()` are rotated as well now.
15+
1216
### New
1317

18+
- `Animation.decode()` takes `applyOrientation`, `toneMapping` and `gainMap` like `Image.decode()`: JPEG XL
19+
animations can be tone mapped with `'clamp'`, and their `jhgm` gain map is read into the `gainMap` of every frame.
1420
- `resize({ width })` and `resize({ height })` on images, frames and animations: the missing side keeps the aspect
1521
ratio, `resize({ width: 256, mode: 'bicubic' })`.
1622
- `image.coordinates()` yields `[x, y]` and `image.pixels()` yields `[x, y, color]` for every pixel, row by row.

‎src/animation.ts‎

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import * as webp from './codecs/webp.ts';
55
import * as jxl from './codecs/jxl.ts';
66
import { availableThreads } from './util/threads.ts';
77
import { BinaryData, view } from './util/mem.ts';
8+
import { GainMap } from './gainmap.ts';
9+
import type { ToneMapping } from './codecs/hdr.ts';
810
import { Image, ResizeMode, ResizeModes, ResizeOptions, ResizeToOptions, resizeArguments } from './image.ts';
911
import { Frame, FrameDisposalModes } from './frame.ts';
1012
import { ImageType } from './image-type.ts';
@@ -51,6 +53,22 @@ export interface AnimationDecodeOptions {
5153
applyColorProfile?: boolean;
5254
/** At most this many worker threads decode in parallel, like `threads` of {@link Image.decode}. Default: the processors available, at most 8 */
5355
threads?: number;
56+
/**
57+
* Whether to rotate/mirror the frames as the orientation tag (Exif, JPEG XL's header, PNG's eXIf chunk or XMP)
58+
* says, like `applyOrientation` of {@link Image.decode}: APNGs, animated WebPs and JPEG XLs. Default: `true`
59+
*/
60+
applyOrientation?: boolean;
61+
/**
62+
* How HDR (PQ or HLG) JPEG XL animations, whose light can be brighter than (SDR) white, become 8 bit sRGB, like
63+
* `toneMapping` of {@link Image.decode}: `'reinhard'` compresses the brightest parts, `'clamp'` clamps everything
64+
* brighter than white. Default: `'reinhard'`
65+
*/
66+
toneMapping?: ToneMapping;
67+
/**
68+
* Whether to read the gain map (jhgm box) of JPEG XL animations into {@link Image.gainMap} of every frame, like
69+
* `gainMap` of {@link Image.decode}. Default: `true`
70+
*/
71+
gainMap?: boolean;
5472
}
5573

5674
/** The options all animation encoders have */
@@ -445,6 +463,8 @@ export class Animation extends Array<Frame> {
445463
const { maxPixels = MAX_PIXELS, threads = 1 } = onlyFirstFrameOrOptions;
446464
if (!Number.isInteger(threads) || threads < 1) throw new RangeError(`threads must be a positive integer, not ${threads}`);
447465
if (!(Number(maxPixels) > 0)) throw new RangeError(`maxPixels must be a positive number, not ${maxPixels}`);
466+
const { toneMapping = 'reinhard' } = onlyFirstFrameOrOptions;
467+
if (toneMapping !== 'reinhard' && toneMapping !== 'clamp') throw new TypeError(`toneMapping must be 'reinhard' or 'clamp', not ${String(toneMapping)}`);
448468
}
449469
return decoding(() => Animation.__decode__(input, onlyFirstFrameOrOptions));
450470
}
@@ -454,7 +474,11 @@ export class Animation extends Array<Frame> {
454474
* @ignore
455475
*/
456476
private static async __decode__(input: BinaryData, onlyFirstFrameOrOptions: boolean | AnimationDecodeOptions): Promise<Animation> {
457-
const { onlyFirstFrame: onlyExtractFirstFrame = false, maxPixels = MAX_PIXELS, applyColorProfile = true, threads = Math.max(1, Math.min(availableThreads(), 8)) } = isOptions(onlyFirstFrameOrOptions) ? onlyFirstFrameOrOptions : { onlyFirstFrame: onlyFirstFrameOrOptions };
477+
const { onlyFirstFrame: onlyExtractFirstFrame = false, maxPixels = MAX_PIXELS, applyColorProfile = true, threads = Math.max(1, Math.min(availableThreads(), 8)), applyOrientation = true, toneMapping = 'reinhard', gainMap = true } = isOptions(onlyFirstFrameOrOptions) ? onlyFirstFrameOrOptions : { onlyFirstFrame: onlyFirstFrameOrOptions };
478+
const orient = (frames: Frame[], orientation: number): Frame[] => {
479+
if (applyOrientation && orientation > 1) for (const frame of frames) frame.__orient__(orientation);
480+
return frames;
481+
};
458482
const profile = (frames: Frame[], icc: Uint8Array | undefined, space?: jxl.ColorSpace): Frame[] => {
459483
const transform = !applyColorProfile ? null : icc ? profileTransform(icc) : space ? colorSpaceTransform(space.xy, space.curve) : null;
460484
if (transform) for (const frame of frames) transform(frame.bitmap);
@@ -467,37 +491,55 @@ export class Animation extends Array<Frame> {
467491

468492
if (type === 'webp') {
469493
const decoded = webp.decodeAnimation(data, onlyExtractFirstFrame, maxPixels);
470-
const frames = profile(canvasFrames(decoded.width, decoded.height, webp.composite(decoded)), decoded.icc);
494+
const frames = orient(profile(canvasFrames(decoded.width, decoded.height, webp.composite(decoded)), decoded.icc), decoded.orientation);
471495
return new Animation(frames, fromPlays(decoded.loopCount));
472496
}
473497

474-
// (still PNGs decode like images, keeping their orientation as it is like before)
498+
// (still PNGs decode like images)
475499
if (type === 'png' && !png.isAnimated(data)) {
476-
const image = await Image.decode(data, { maxPixels, applyOrientation: false, applyColorProfile });
500+
const image = await Image.decode(data, { maxPixels, applyOrientation, applyColorProfile });
477501
return new Animation(canvasFrames(image.width, image.height, [{ pixels: image.bitmap, duration: 0 }]), -1);
478502
}
479503
if (type === 'png') {
480504
const decoded = await png.decodeAnimation(data, onlyExtractFirstFrame, maxPixels);
481-
const frames = profile(canvasFrames(decoded.width, decoded.height, png.compositeAnimation(decoded)), decoded.icc);
505+
const frames = orient(profile(canvasFrames(decoded.width, decoded.height, png.compositeAnimation(decoded)), decoded.icc), decoded.orientation);
482506
return new Animation(frames, decoded.frames.length > 1 ? fromPlays(decoded.plays) : -1);
483507
}
484508

485509
if (type === 'jxl') {
486510
const frames: Frame[] = [];
487511
let loopCount = -1, icc: Uint8Array | undefined, space: jxl.ColorSpace | undefined;
488-
for await (const decoded of jxl.frames(data, maxPixels, 'reinhard', threads)) {
512+
let orientation = 1;
513+
for await (const decoded of jxl.frames(data, maxPixels, toneMapping, threads)) {
489514
const frame = new Frame(decoded.width, decoded.height, decoded.duration, 0, 0, FrameDisposalModes.BACKGROUND);
490515
frame.bitmap = decoded.pixels;
491-
frames.push(frame.__orient__(decoded.orientation));
516+
frames.push(frame);
517+
orientation = decoded.orientation;
492518
loopCount = fromPlays(decoded.loopCount);
493519
icc = decoded.icc;
494520
space = decoded.colorSpace;
495521
if (onlyExtractFirstFrame) break;
496522
}
497-
return new Animation(profile(frames, icc, space), frames.length > 1 ? loopCount : -1);
523+
// (a gain map that can't be read leaves the frames without one, it doesn't fail the decoding)
524+
const map = gainMap && data[0] !== 0xff ? await jxl.decodeGainMap(data, maxPixels).catch(() => undefined) : undefined;
525+
if (map) for (const frame of frames) {
526+
const image = new Image(map.width, map.height);
527+
image.bitmap = map.pixels.slice();
528+
frame.gainMap = GainMap.__from_pixels__(image, map.params);
529+
}
530+
for (const frame of orient(profile(frames, icc, space), orientation)) {
531+
const mapOfFrame = frame.gainMap;
532+
if (!mapOfFrame || !applyOrientation || orientation <= 1) continue;
533+
try {
534+
mapOfFrame.image = mapOfFrame.image.__orient__(orientation);
535+
} catch {
536+
frame.gainMap = null;
537+
}
538+
}
539+
return new Animation(frames, frames.length > 1 ? loopCount : -1);
498540
}
499541

500-
if (type) return new Animation([Frame.from(await Image.decode(data, { maxPixels, applyColorProfile }), 0)]);
542+
if (type) return new Animation([Frame.from(await Image.decode(data, { maxPixels, applyOrientation, applyColorProfile, toneMapping, gainMap, threads }), 0)]);
501543
throw new Error('Unsupported image type');
502544
}
503545

‎src/codecs/jxl.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ export async function decode(data: Uint8Array, maxPixels = MAX_PIXELS, toneMappi
198198

199199
// the jhgm box's gain map (libjxl's JxlGainMapReadBundle): version 0, the ISO 21496-1 metadata (its size, 16 bits),
200200
// the alternate image's color encoding and ICC profile (their sizes, 8 and 32 bits), the map's codestream
201-
async function decodeGainMap(data: Uint8Array, maxPixels: number): Promise<{ width: number, height: number, pixels: Uint8Array, params: Params } | undefined> {
201+
export async function decodeGainMap(data: Uint8Array, maxPixels: number): Promise<{ width: number, height: number, pixels: Uint8Array, params: Params } | undefined> {
202202
for (const { type, content: c } of boxesOf(data)) {
203203
if (type !== 'jhgm') continue;
204204
if (c.length < 3 || c[0] !== 0) return undefined;

‎src/codecs/png.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -718,16 +718,17 @@ export function isAnimated(array: Uint8Array): boolean {
718718
* a single frame
719719
* @return plays: 0 = forever
720720
*/
721-
export async function decodeAnimation(array: Uint8Array, onlyFirstFrame = false, maxPixels = MAX_PIXELS): Promise<{ width: number, height: number, plays: number, frames: APNGFrame[], icc?: Uint8Array }> {
721+
export async function decodeAnimation(array: Uint8Array, onlyFirstFrame = false, maxPixels = MAX_PIXELS): Promise<{ width: number, height: number, plays: number, frames: APNGFrame[], icc?: Uint8Array, orientation: number }> {
722722
const p = parse(array);
723723
const { width, height } = p.header;
724+
const orientation = p.exif ? exifOrientation(p.exif) : await textOrientation(p.text);
724725
// every frame is composited onto a canvas of its own
725726
checkPixels(width, height, maxPixels, !p.animation || p.animationError || onlyFirstFrame ? 1 : Math.max(1, p.frames.length));
726727

727728
// broken animations show the default image, like browsers do
728729
if (!p.animation || p.animationError) {
729730
const pixels = await decodeImageData(p, p.idat, width, height);
730-
return { width, height, plays: 0, frames: [{ x: 0, y: 0, width, height, duration: 0, dispose: 0, blend: 0, pixels }], icc: await profile(p.iccp) };
731+
return { width, height, plays: 0, frames: [{ x: 0, y: 0, width, height, duration: 0, dispose: 0, blend: 0, pixels }], icc: await profile(p.iccp), orientation };
731732
}
732733

733734
const frames: APNGFrame[] = [];
@@ -743,7 +744,7 @@ export async function decodeAnimation(array: Uint8Array, onlyFirstFrame = false,
743744
if (onlyFirstFrame) break;
744745
}
745746

746-
return { width, height, plays: p.animation.plays, frames, icc: await profile(p.iccp) };
747+
return { width, height, plays: p.animation.plays, frames, icc: await profile(p.iccp), orientation };
747748
}
748749

749750
/**

‎src/codecs/webp.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ export interface WebPAnimation {
4343
frames: WebPFrame[];
4444
/** the embedded ICC profile */
4545
icc?: Uint8Array;
46+
/** Exif orientation (1-8) */
47+
orientation: number;
4648
}
4749

4850
interface Chunk {
@@ -186,7 +188,7 @@ export function decodeAnimation(data: Uint8Array, onlyFirstFrame = false, maxPix
186188
const header = list[0];
187189
if (!header || header.type !== VP8X || !list.some(c => c.type === ANMF)) {
188190
const { width, height, pixels } = decodeBitstream(list, maxPixels);
189-
return { width, height, loopCount: 0, background: 0, frames: [{ x: 0, y: 0, width, height, duration: 0, blend: false, dispose: false, pixels }], icc: list.find(c => c.type === ICCP)?.data };
191+
return { width, height, loopCount: 0, background: 0, frames: [{ x: 0, y: 0, width, height, duration: 0, blend: false, dispose: false, pixels }], icc: list.find(c => c.type === ICCP)?.data, orientation: orientationOf(list) };
190192
}
191193

192194
if (header.data.length < 10) throw new Error('WebP: invalid VP8X chunk');
@@ -221,7 +223,13 @@ export function decodeAnimation(data: Uint8Array, onlyFirstFrame = false, maxPix
221223
}
222224

223225
if (!frames.length) throw new Error('WebP: animation without frames');
224-
return { width, height, loopCount, background, frames, icc: list.find(c => c.type === ICCP)?.data };
226+
return { width, height, loopCount, background, frames, icc: list.find(c => c.type === ICCP)?.data, orientation: orientationOf(list) };
227+
}
228+
229+
// the Exif orientation of a WebP's chunks, 1 without one
230+
function orientationOf(list: Chunk[]): number {
231+
const exif = list.find(c => c.type === EXIF);
232+
return exif ? exifOrientation(exif.data) : 1;
225233
}
226234

227235
/**

‎tests/regressions.js‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,86 @@ const rgba = (image, x, y) => Image.colorToRGBA(image.getPixelAt(x, y));
507507
}
508508
}
509509

510+
// Animation.decode: orientation, tone mapping and gain maps like Image.decode
511+
{
512+
const {GainMap} = require('..');
513+
const fs = require('fs');
514+
const pixel = (image, x, y) => Array.from(image.bitmap.subarray((y * image.width + x) * 4, (y * image.width + x) * 4 + 4));
515+
const tiff = orientation => Buffer.from([0x4d, 0x4d, 0, 42, 0, 0, 0, 8, 0, 1, 0x01, 0x12, 0, 3, 0, 0, 0, 1, 0, orientation, 0, 0, 0, 0, 0, 0, 0, 0]);
516+
const source = () => [0, 1].map(i => Frame.from(new Image(3, 2).fill((x, y) => ColorHelpers.fromRGBA(x * 100, y * 100, i * 120, 255)), 100));
517+
518+
// APNG (eXIf) and animated WebP (EXIF): orientation 6 turns the top left into the top right
519+
const png = Buffer.from(await new Animation(source(), 2).encodeAPNG({metadata: {software: ''}}));
520+
const chunk = (type, body) => {
521+
const out = Buffer.alloc(12 + body.length);
522+
out.writeUInt32BE(body.length);
523+
out.write(type, 4, 'latin1');
524+
body.copy(out, 8);
525+
out.writeUInt32BE(zlib.crc32(out.subarray(4, 8 + body.length)), 8 + body.length);
526+
return out;
527+
};
528+
const withExif = Buffer.concat([png.subarray(0, png.length - 12), chunk('eXIf', tiff(6)), png.subarray(png.length - 12)]);
529+
const webp = Buffer.from(await new Animation(source(), 2).encodeWebP({metadata: {software: ''}}));
530+
const exifChunk = Buffer.concat([Buffer.from('EXIF'), Buffer.from(new Uint8Array(new Uint32Array([tiff(6).length]).buffer)), tiff(6)]);
531+
// (the encoder's own EXIF chunk, with the creation time, becomes one decoders skip)
532+
const own = webp.indexOf('EXIF', 12, 'latin1');
533+
assert(own > 12, 'the animated WebP has an EXIF chunk of its own');
534+
webp.write('JUNK', own, 'latin1');
535+
const webpWithExif = Buffer.concat([webp, exifChunk]);
536+
webpWithExif.writeUInt32LE(webpWithExif.length - 8, 4);
537+
// (the VP8X flags: bit 3 is Exif)
538+
webpWithExif[20] |= 0x08;
539+
540+
for (const [name, data] of [['APNG', withExif], ['WebP', webpWithExif]]) {
541+
const stored = await Animation.decode(data, {applyOrientation: false});
542+
assert(stored.width === 3 && stored.height === 2 && stored.length === 2, `${name}: applyOrientation false keeps the stored pixels`);
543+
for (const options of [undefined, {}, {applyOrientation: true}]) {
544+
const oriented = await Animation.decode(data, options);
545+
assert(oriented.width === 2 && oriented.height === 3 && oriented.length === 2, `${name}: rotated (${JSON.stringify(options)})`);
546+
for (let i = 0; i < 2; i++)
547+
assert(pixel(oriented[i], 1, 0).join() === pixel(stored[i], 0, 0).join() && pixel(oriented[i], 0, 2).join() === pixel(stored[i], 2, 1).join(), `${name}: frame ${i} not oriented like Image.decode`);
548+
}
549+
}
550+
// (without an orientation tag nothing changes)
551+
assert((await Animation.decode(png)).width === 3, 'APNG without orientation');
552+
553+
// JPEG XL: the gain map of a jhgm box on every frame, in a container around the animation's codestream
554+
const boxes = data => {
555+
const out = [];
556+
for (let o = 0; o < data.length;) {
557+
const size = data.readUInt32BE(o), type = data.toString('latin1', o + 4, o + 8);
558+
out.push({type, box: data.subarray(o, o + size)});
559+
o += size;
560+
}
561+
return out;
562+
};
563+
const box = (type, ...body) => {
564+
const content = Buffer.concat(body);
565+
const out = Buffer.alloc(8 + content.length);
566+
out.writeUInt32BE(out.length);
567+
out.write(type, 4, 'latin1');
568+
content.copy(out, 8);
569+
return out;
570+
};
571+
const mapped = new Image(8, 8).fill(0x808080ff);
572+
mapped.gainMap = new GainMap(new Image(4, 4).fill(0xc0c0c0ff), {gainMapMin: 0, gainMapMax: 2});
573+
const jhgm = boxes(Buffer.from(await mapped.encodeJXL())).find(b => b.type === 'jhgm').box;
574+
const codestream = fs.readFileSync('./tests/jxl/animation.jxl');
575+
const container = Buffer.concat([box('JXL ', Buffer.from([0x0d, 0x0a, 0x87, 0x0a])), box('ftyp', Buffer.from('jxl \0\0\0\0jxl ', 'latin1')), jhgm, box('jxlc', codestream)]);
576+
const plain = await Animation.decode(container, {gainMap: false});
577+
const withMap = await Animation.decode(container);
578+
assert(plain.length > 1 && withMap.length === plain.length, 'JPEG XL animation with a jhgm box decodes');
579+
assert(plain.every(f => f.gainMap === null) && (await Animation.decode(codestream)).every(f => f.gainMap === null), 'JPEG XL: gainMap false, or no jhgm box');
580+
assert(withMap.every(f => f.gainMap && f.gainMap.width === 4 && f.gainMap.gainMapMax[0] === 2), 'JPEG XL: every frame has the gain map');
581+
assert(withMap[0].gainMap !== withMap[1].gainMap && withMap.every((f, i) => Buffer.from(f.bitmap).equals(Buffer.from(plain[i].bitmap))), 'JPEG XL: the frames are the same with the map, with maps of their own');
582+
583+
// toneMapping is checked
584+
let threw = false;
585+
try { await Animation.decode(png, {toneMapping: 'linear'}); } catch (e) { threw = e instanceof TypeError; }
586+
assert(threw, 'Animation.decode validates toneMapping');
587+
assert((await Animation.decode(codestream, {toneMapping: 'clamp'})).length === plain.length, 'Animation.decode with toneMapping clamp');
588+
}
589+
510590
// Colors: the color helpers, and the deprecated Image ones they replaced, give the same values
511591
{
512592
const { Colors } = require('..');

0 commit comments

Comments
 (0)