Skip to content

Commit ee517f2

Browse files
matmenclaude
andcommitted
AVIF and JPEG XL encoders: metadata as Exif
encodeAVIF() and encodeJXL() take metadata like the other encoders. AVIFs get an Exif item that describes the primary image (cdsc), JPEG XLs the container with an Exif box; the codestream is unchanged. The AVIF handler name is now empty, so software: '' leaves ImageScript out of the file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
1 parent 2b1dc38 commit ee517f2

7 files changed

Lines changed: 89 additions & 31 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
# Changelog
22

3-
## 3.5.1
3+
## 3.6.0
4+
5+
### Features
6+
7+
- `Image.encodeAVIF()` and `Image.encodeJXL()` take `metadata` like the other encoders: author, description,
8+
copyright, creation time and software (by default ImageScript and its version) as Exif. In AVIFs it is an Exif item
9+
describing the image, JPEG XLs get the container (with an Exif box; the codestream stays what `cjxl -e 1` writes).
410

511
### Improvements
612

‎src/codecs/avif-enc.ts‎

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*!
22
* Encodes AVIFs: the image as an AV1 still picture (YUV of BT.601's matrix, full range, like avifenc's defaults; RGB
3-
* if lossless, monochrome if gray), transparency as a monochrome auxiliary image, in a HEIF container with the boxes
4-
* libavif writes. The quality maps to the quantizer like avifenc's, with libaom's quantizer_to_qindex (Copyright (c)
3+
* if lossless, monochrome if gray), transparency as a monochrome auxiliary image, Exif as a metadata item, in a HEIF
4+
* container with the boxes libavif writes. The quality maps to the quantizer like avifenc's, with libaom's quantizer_to_qindex (Copyright (c)
55
* 2016, Alliance for Open Media, BSD-2-Clause License, see THIRD_PARTY_NOTICES.md)
66
*/
77
import { encodeAV1 } from './av1/encode.ts';
@@ -16,6 +16,8 @@ export interface AVIFOptions {
1616
subsampling: '4:4:4' | '4:2:0';
1717
/** how hard to search for the best modes, 0-2 */
1818
effort: number;
19+
/** Exif data (a TIFF header and IFDs) */
20+
exif?: Uint8Array;
1921
}
2022

2123
// libaom's quantizer_to_qindex (avifenc's quality: (100 - quality) * 63 / 100, rounded)
@@ -168,7 +170,7 @@ function seqMonochrome(d: Uint8Array, o: number): boolean {
168170
}
169171

170172
/** Encodes RGBA pixels as an AVIF (with an alpha image if the image has transparency) */
171-
export function encode(rgba: Uint8Array, width: number, height: number, { quality, alphaQuality, subsampling, effort }: AVIFOptions): Uint8Array {
173+
export function encode(rgba: Uint8Array, width: number, height: number, { quality, alphaQuality, subsampling, effort, exif }: AVIFOptions): Uint8Array {
172174
if (width < 1 || height < 1) throw new RangeError('AVIF: dimensions must be at least 1');
173175
if (width > 65536 || height > 65536) throw new RangeError('AVIF: dimensions must be at most 65536');
174176
let opaque = true;
@@ -195,11 +197,18 @@ export function encode(rgba: Uint8Array, width: number, height: number, { qualit
195197
// the boxes: ftyp, meta (the items, their locations in mdat, their properties), mdat
196198
const profile = subsampled || gray ? 'MA1B' : 'MA1A';
197199
const ftyp = box('ftyp', ascii('avif'), u32(0), ascii('avif'), ascii('mif1'), ascii('miaf'), ascii(profile));
198-
const hdlr = fullBox('hdlr', 0, 0, u32(0), ascii('pict'), u32(0), u32(0), u32(0), ascii('ImageScript'), [0]);
200+
const hdlr = fullBox('hdlr', 0, 0, u32(0), ascii('pict'), u32(0), u32(0), u32(0), [0]);
199201
const pitm = fullBox('pitm', 0, 0, u16(1));
200-
const infe = (id: number, name: string): number[] => fullBox('infe', 2, 0, u16(id), u16(0), ascii('av01'), ascii(name), [0]);
201-
const iinf = fullBox('iinf', 0, 0, u16(alpha ? 2 : 1), infe(1, 'Color'), alpha ? infe(2, 'Alpha') : []);
202-
const iref = alpha ? fullBox('iref', 0, 0, box('auxl', u16(2), u16(1), u16(1))) : [];
202+
// the items: the color image (1), the alpha image, the Exif data (its TIFF header's offset, 0, then the TIFF data)
203+
const items: { type: string, name: string, data: ArrayLike<number>[] }[] = [{ type: 'av01', name: 'Color', data: [color] }];
204+
if (alpha) items.push({ type: 'av01', name: 'Alpha', data: [alpha] });
205+
if (exif?.length) items.push({ type: 'Exif', name: 'Exif', data: [u32(0), exif] });
206+
const sizes = items.map(item => item.data.reduce((n, d) => n + d.length, 0));
207+
const infe = (id: number, type: string, name: string): number[] => fullBox('infe', 2, 0, u16(id), u16(0), ascii(type), ascii(name), [0]);
208+
const iinf = fullBox('iinf', 0, 0, u16(items.length), ...items.map((item, i) => infe(i + 1, item.type, item.name)));
209+
// (the alpha image is auxiliary to the color image, the Exif data describes it)
210+
const exifId = exif?.length ? items.length : 0;
211+
const iref = alpha || exifId ? fullBox('iref', 0, 0, alpha ? box('auxl', u16(2), u16(1), u16(1)) : [], exifId ? box('cdsc', u16(exifId), u16(1), u16(1)) : []) : [];
203212
const ispe = fullBox('ispe', 0, 0, u32(width), u32(height));
204213
const colr = box('colr', ascii('nclx'), u16(cicp[0]), u16(cicp[1]), u16(cicp[2]), [0x80]);
205214
const pixi = (channels: number): number[] => fullBox('pixi', 0, 0, [channels], new Array<number>(channels).fill(8));
@@ -208,18 +217,24 @@ export function encode(rgba: Uint8Array, width: number, height: number, { qualit
208217
const ipco = box('ipco', ispe, av1C(color), colr, pixi(gray ? 1 : 3), alpha ? av1C(alpha) : [], alpha ? pixi(1) : [], alpha ? auxC : []);
209218
const ipma = fullBox('ipma', 0, 0, u32(alpha ? 2 : 1), u16(1), [4, 1, 0x82, 3, 4], alpha ? [...u16(2), 4, 1, 0x85, 6, 7] : []);
210219
const iprp = box('iprp', ipco, ipma);
211-
// the locations (absolute offsets into mdat, filled in once the size of meta is known)
212-
const ilocFor = (offset: number): number[] => fullBox('iloc', 0, 0, [0x44, 0x00], u16(alpha ? 2 : 1),
213-
u16(1), u16(0), u16(1), u32(offset), u32(color.length),
214-
alpha ? [...u16(2), ...u16(0), ...u16(1), ...u32(offset + color.length), ...u32(alpha.length)] : []);
220+
// the locations (absolute offsets into mdat, one after the other, filled in once the size of meta is known)
221+
const ilocFor = (offset: number): number[] => fullBox('iloc', 0, 0, [0x44, 0x00], u16(items.length), ...items.map((_, i) => {
222+
const at = offset + sizes.slice(0, i).reduce((n, size) => n + size, 0);
223+
return [...u16(i + 1), ...u16(0), ...u16(1), ...u32(at), ...u32(sizes[i])];
224+
}));
215225
const metaFor = (offset: number): number[] => fullBox('meta', 0, 0, hdlr, pitm, ilocFor(offset), iinf, iref, iprp);
216226
const headerSize = ftyp.length + metaFor(0).length + 8;
217227
const head = [...ftyp, ...metaFor(headerSize)];
218-
const mdatSize = 8 + color.length + (alpha ? alpha.length : 0);
228+
const mdatSize = 8 + sizes.reduce((n, size) => n + size, 0);
219229
const out = new Uint8Array(head.length + mdatSize);
220230
out.set(head);
221231
out.set([...u32(mdatSize), ...ascii('mdat')], head.length);
222-
out.set(color, head.length + 8);
223-
if (alpha) out.set(alpha, head.length + 8 + color.length);
232+
let at = head.length + 8;
233+
for (const item of items) {
234+
for (const d of item.data) {
235+
out.set(d, at);
236+
at += d.length;
237+
}
238+
}
224239
return out;
225240
}

‎src/codecs/jxl.ts‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*!
22
* JPEG XL (ISO/IEC 18181): bare codestreams and the container (ISO/IEC 18181-2: jxlc, jxlp boxes), the image header,
33
* the frames up to the first one shown (with its orientation); see ./jxl/ for the decoder, ported from jxl-oxide and
4-
* libjxl (see THIRD_PARTY_NOTICES.md)
4+
* libjxl (see THIRD_PARTY_NOTICES.md). Encoded images with Exif get the container (signature, ftyp, Exif, jxlc boxes)
55
*/
66
import { MAX_PIXELS, checkPixels } from '../util/limits.ts';
77
import { BitReader } from './jxl/bits.ts';
@@ -10,7 +10,8 @@ import { frameHeader, toc } from './jxl/frame-header.ts';
1010
import { decodeFrame, DecoderState } from './jxl/frame.ts';
1111
import { ColorOutput } from './jxl/color.ts';
1212
import { ToneMapping } from './hdr.ts';
13-
export { encodeRGBA as encode } from './jxl/fast-lossless.ts';
13+
import { encodeRGBA } from './jxl/fast-lossless.ts';
14+
import { concat } from '../util/mem.ts';
1415

1516
const CONTAINER = [0, 0, 0, 12, 0x4a, 0x58, 0x4c, 0x20, 0x0d, 0x0a, 0x87, 0x0a];
1617

@@ -20,6 +21,21 @@ export function isJXL(d: Uint8Array): boolean {
2021
return d.length >= 12 && CONTAINER.every((v, i) => d[i] === v);
2122
}
2223

24+
/** Encodes RGBA pixels as a lossless JPEG XL: the codestream, in the container if there is Exif data (a TIFF header and IFDs) */
25+
export function encode(rgba: Uint8Array, width: number, height: number, exif?: Uint8Array): Uint8Array {
26+
const stream = encodeRGBA(rgba, width, height);
27+
if (!exif?.length) return stream;
28+
// (ftyp: brand jxl, minor version 0, compatible jxl; Exif: the offset of the TIFF header, 0, then the TIFF data)
29+
const jxl = [0x6a, 0x78, 0x6c, 0x20];
30+
return concat([CONTAINER, ...box('ftyp', jxl, [0, 0, 0, 0], jxl), ...box('Exif', [0, 0, 0, 0], exif), ...box('jxlc', stream)]);
31+
}
32+
33+
// a box: its header and content
34+
const box = (type: string, ...content: ArrayLike<number>[]): ArrayLike<number>[] => {
35+
const size = 8 + content.reduce((n, c) => n + c.length, 0);
36+
return [[size >>> 24, size >> 16 & 255, size >> 8 & 255, size & 255, ...Array.from(type, c => c.charCodeAt(0))], ...content];
37+
};
38+
2339
const fourcc = (d: Uint8Array, o: number): string => String.fromCharCode(d[o], d[o + 1], d[o + 2], d[o + 3]);
2440

2541
/** The codestream of a file: itself, or the container's jxlc box or jxlp boxes joined */

‎src/image.ts‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export interface WebPEncodeOptions {
119119
metadata?: Metadata;
120120
}
121121

122-
/** Options of {@link Image.encodeTGA} */
122+
/** Options of {@link Image.encodeTGA} and {@link Image.encodeJXL} */
123123
export interface EncodeOptions {
124124
/** Metadata to include, see {@link Metadata} */
125125
metadata?: Metadata;
@@ -185,6 +185,8 @@ export interface AVIFEncodeOptions {
185185
subsampling?: '4:4:4' | '4:2:0';
186186
/** How hard the encoder searches for the best modes: 0-2, faster to smaller. Default: `1` */
187187
effort?: number;
188+
/** Metadata to include, see {@link Metadata} */
189+
metadata?: Metadata;
188190
}
189191

190192
/** Text style options of {@link Image.renderText} */
@@ -2080,17 +2082,20 @@ export class Image {
20802082
* Encodes the image as AVIF (AV1, with an alpha image if the image has transparency)
20812083
* @param options See {@link AVIFEncodeOptions}
20822084
*/
2083-
async encodeAVIF({ quality = 60, alphaQuality = 100, subsampling = '4:4:4', effort = 1 }: AVIFEncodeOptions = {}): Promise<Uint8Array> {
2084-
return avifEnc.encode(this.__bytes__(), this.width, this.height, { quality: finite(quality, 'quality'), alphaQuality: finite(alphaQuality, 'alphaQuality'), subsampling, effort });
2085+
async encodeAVIF({ quality = 60, alphaQuality = 100, subsampling = '4:4:4', effort = 1, metadata = {} }: AVIFEncodeOptions = {}): Promise<Uint8Array> {
2086+
const exif = exifData(resolveMetadata(metadata));
2087+
return avifEnc.encode(this.__bytes__(), this.width, this.height, { quality: finite(quality, 'quality'), alphaQuality: finite(alphaQuality, 'alphaQuality'), subsampling, effort, exif });
20852088
}
20862089

20872090
/**
2088-
* Encodes the image as lossless JPEG XL, byte for byte like libjxl's `cjxl -e 1` on CPUs with AVX2 or NEON (its
2089-
* fast lossless mode: the gradient predictor, a palette if the image has at most 511 colors besides black, else
2090-
* YCoCg); greyscale if the image is, with alpha if it has transparency
2091+
* Encodes the image as lossless JPEG XL, the codestream byte for byte like libjxl's `cjxl -e 1` on CPUs with AVX2
2092+
* or NEON (its fast lossless mode: the gradient predictor, a palette if the image has at most 511 colors besides
2093+
* black, else YCoCg); greyscale if the image is, with alpha if it has transparency. The codestream is in the
2094+
* container, with the metadata as Exif
2095+
* @param options See {@link EncodeOptions}
20912096
*/
2092-
async encodeJXL(): Promise<Uint8Array> {
2093-
return jxl.encode(this.__bytes__(), this.width, this.height);
2097+
async encodeJXL({ metadata = {} }: EncodeOptions = {}): Promise<Uint8Array> {
2098+
return jxl.encode(this.__bytes__(), this.width, this.height, exifData(resolveMetadata(metadata)));
20942099
}
20952100

20962101
/**

‎src/metadata.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import * as exif from './codecs/exif.ts';
99
* |---|---|
1010
* | PNG, APNG | all (as text chunks) |
1111
* | JPEG | author, description, copyright, creation time, software (Exif); comment (comment marker) |
12-
* | WebP | author, description, copyright, creation time, software (Exif) |
12+
* | WebP, AVIF, JPEG XL | author, description, copyright, creation time, software (Exif) |
1313
* | GIF | software, comment (comment extensions) |
1414
* | TGA | author, comment, creation time, software (extension area) |
1515
*
@@ -94,7 +94,7 @@ export function pngText(m: ResolvedMetadata): Record<string, string | undefined>
9494
}
9595

9696
/**
97-
* @internal Exif data (a TIFF header and IFDs) for JPEG and WebP
97+
* @internal Exif data (a TIFF header and IFDs) for JPEG, WebP, AVIF and JPEG XL
9898
* @ignore
9999
*/
100100
export function exifData(m: ResolvedMetadata): Uint8Array {

‎tests/jxl-encode.js‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ const samples = image => {
4141
return [px, channels.length];
4242
};
4343

44+
// the codestream of a container: the content of its last box, jxlc
45+
const codestream = jxl => {
46+
let o = 0;
47+
while (o + Buffer.from(jxl).readUInt32BE(o) < jxl.length) o += Buffer.from(jxl).readUInt32BE(o);
48+
return jxl.subarray(o + 8);
49+
};
50+
4451
(async () => {
4552
const {encode} = await import('../dist/codecs/jxl/fast-lossless.js');
4653
for (const [name, grey] of [['external.png'], ['circle.png'], ['readme.png'], ['potato.png'], ['external.png', true]]) {
@@ -49,7 +56,7 @@ const samples = image => {
4956
// (grey: the red channel)
5057
if (grey) for (let i = 0; i < image.bitmap.length; i += 4) image.bitmap[i + 1] = image.bitmap[i + 2] = image.bitmap[i];
5158
const jxl = await image.encodeJXL();
52-
if (sha256(jxl) !== references[16][key]) panic(`${key}: not what cjxl -e 1 writes`);
59+
if (sha256(codestream(jxl)) !== references[16][key]) panic(`${key}: not what cjxl -e 1 writes`);
5360
const [px, channels] = samples(image);
5461
if (sha256(encode(px, image.width, image.height, channels, 8)) !== references[8][key]) panic(`${key}: not what cjxl -e 1 writes without SIMD`);
5562
const decoded = await Image.decode(jxl);

‎tests/metadata.js‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ const latin1 = (data, text) => Buffer.from(data).includes(Buffer.from(text, 'lat
1919
jpeg: m => image.encodeJPEG({quality: 90, metadata: m}),
2020
webp: m => image.encodeWebP({metadata: m}),
2121
tga: m => image.encodeTGA({metadata: m}),
22+
avif: m => image.encodeAVIF({effort: 0, metadata: m}),
23+
jxl: m => image.encodeJXL({metadata: m}),
2224
gif: m => animation.encodeGIF({metadata: m}),
2325
apng: m => animation.encodeAPNG({metadata: m}),
2426
'animated webp': m => animation.encodeWebP({metadata: m})
@@ -46,14 +48,21 @@ const latin1 = (data, text) => Buffer.from(data).includes(Buffer.from(text, 'lat
4648
if (!has(png, 'Thu, 15 Oct 2026 12:34:56 GMT')) panic('png: creation time missing');
4749
}
4850

49-
// JPEG and WebP: Exif (IFD0 strings, UTC date), JPEG also a comment marker
50-
for (const format of ['jpeg', 'webp']) {
51+
// JPEG, WebP, AVIF and JPEG XL: Exif (IFD0 strings, UTC date; AVIF and JPEG XL: the TIFF header's offset first),
52+
// JPEG also a comment marker
53+
for (const format of ['jpeg', 'webp', 'avif', 'jxl']) {
5154
const file = await encoders[format](metadata);
52-
for (const text of ['Exif'.slice(format === 'webp' ? 4 : 0), 'MM\0*', 'Jörg', 'a description', 'Ⓒ someone', '2026:10:15 12:34:56\0', '+00:00'])
55+
const header = {jpeg: 'Exif\0\0MM\0*', webp: 'EXIF', avif: '\0\0\0\0MM\0*', jxl: 'Exif\0\0\0\0MM\0*'}[format];
56+
for (const text of [header, 'MM\0*', 'Jörg', 'a description', 'Ⓒ someone', '2026:10:15 12:34:56\0', '+00:00'])
5357
if (!has(file, text)) panic(`${format}: ${JSON.stringify(text)} missing from the Exif data`);
5458
if (format === 'jpeg' && !has(file, Buffer.from([0xff, 0xfe, 0, 11, ...Buffer.from('a comment')]))) panic('jpeg: comment marker missing');
5559
}
5660

61+
// AVIF: the Exif item (3, after the alpha image) describes the image (cdsc); JPEG XL: the container's boxes
62+
if (!has(await encoders.avif(metadata), Buffer.from([...Buffer.from('cdsc'), 0, 3, 0, 1, 0, 1]))) panic('avif: Exif item reference missing');
63+
if (Buffer.from(await encoders.jxl(metadata)).subarray(0, 48).toString('latin1').replace(/[^ -~]/g, '.') !== '....JXL ........ftypjxl ....jxl ....Exif....MM.*')
64+
panic('jxl: container boxes wrong');
65+
5766
// GIF: comment extensions
5867
if (!has(await animation.encodeGIF({metadata}), Buffer.from([0x21, 0xfe, 9, ...Buffer.from('a comment'), 0]))) panic('gif: comment extension missing');
5968

0 commit comments

Comments
 (0)