Skip to content

Commit d0642f4

Browse files
matmenclaude
andcommitted
AVIF: encode in worker threads
Superblock rows are searched in a wavefront by worker threads (each superblock after the one above right of it, the reconstruction and above contexts in SharedArrayBuffers), like libaom's row-mt, and the loop filter and CDEF trials run in parallel: the same bitstream as on one thread. 1920x1080 photo, 8 threads: effort 0 2.1 -> 1.1 s, effort 1 4.9 -> 1.7 s, effort 2 14.2 -> 3.4 s. src/util/threads.ts starts Web Workers (Deno, Bun, browsers; written so bundlers pick up the worker) or node:worker_threads (Node). Small images, pages without cross-origin isolation, runtimes without workers and workers that fail encode on the calling thread. New option AVIFEncodeOptions.threads (default: available processors, at most 8). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
1 parent 12015be commit d0642f4

8 files changed

Lines changed: 456 additions & 52 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# Changelog
22

3+
## 3.10.0
4+
5+
### Performance
6+
7+
- `encodeAVIF()` encodes in worker threads (`AVIFEncodeOptions.threads`, default: the processors available, at most
8+
8; `1` for none), writing the same files: superblock rows are searched in a wavefront (each superblock after the one
9+
above right of it), like libaom's row-based multithreading, so there is no loss in compression, and the loop filter
10+
and CDEF trials run in parallel. A 1920x1080 photo takes 1.1 s instead of 2.1 s at effort 0, 1.7 s instead of 4.9 s
11+
at effort 1 and 3.4 s instead of 14.2 s at effort 2 (8 threads, Ryzen 7 5800X). Works in Node (worker_threads),
12+
Deno, Bun and browsers (Web Workers; bundlers like Vite pick up the worker's module). Small images (under about
13+
640x640 pixels at effort 0, 448x448 at 1, 320x320 at 2), browser pages without cross-origin isolation (no
14+
SharedArrayBuffer), runtimes without workers, and workers that fail to start encode on the calling thread as before.
15+
316
## 3.9.0
417

518
### Features

‎scripts/build.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,11 @@ const prune = dir => {
4646
};
4747
prune(path.join(root, 'dist'));
4848

49+
// the worker threads' module: worker.js in the build (written as a literal for bundlers, see threads.ts)
50+
const threads = path.join(root, 'dist/util/threads.js');
51+
const threadsCode = fs.readFileSync(threads, 'utf8');
52+
if (!threadsCode.includes("new URL('./worker.ts', import.meta.url)")) throw new Error('build: the worker URL in threads.js not found');
53+
fs.writeFileSync(threads, threadsCode.replace("new URL('./worker.ts', import.meta.url)", "new URL('./worker.js', import.meta.url)"));
54+
4955
// the package itself has no "type", so mark the build as ES modules
5056
fs.writeFileSync(path.join(root, 'dist/package.json'), '{"type":"module"}\n');

‎src/codecs/av1/encode.ts‎

Lines changed: 207 additions & 42 deletions
Large diffs are not rendered by default.

‎src/codecs/avif-enc.ts‎

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
* 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
*/
7-
import { encodeAV1 } from './av1/encode.ts';
7+
import { encodeAV1, encodeAV1Threaded, type AV1Image, type AV1Params } from './av1/encode.ts';
8+
import { startThreads } from '../util/threads.ts';
89

910
/** Options of the AVIF encoder */
1011
export interface AVIFOptions {
@@ -18,8 +19,13 @@ export interface AVIFOptions {
1819
effort: number;
1920
/** Exif data (a TIFF header and IFDs) */
2021
exif?: Uint8Array;
22+
/** at most this many worker threads (1: none) */
23+
threads: number;
2124
}
2225

26+
// (images below this many pixels encode faster without threads, which take about 0.1 s to start: by effort)
27+
const MIN_THREADED_PIXELS = [640 * 640, 448 * 448, 320 * 320];
28+
2329
// libaom's quantizer_to_qindex (avifenc's quality: (100 - quality) * 63 / 100, rounded)
2430
const QINDEX = [
2531
0, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 68, 72, 76, 80, 84, 88, 92, 96, 100, 104, 108, 112,
@@ -170,7 +176,7 @@ function seqMonochrome(d: Uint8Array, o: number): boolean {
170176
}
171177

172178
/** Encodes RGBA pixels as an AVIF (with an alpha image if the image has transparency) */
173-
export function encode(rgba: Uint8Array, width: number, height: number, { quality, alphaQuality, subsampling, effort, exif }: AVIFOptions): Uint8Array {
179+
export async function encode(rgba: Uint8Array, width: number, height: number, { quality, alphaQuality, subsampling, effort, exif, threads: maxThreads }: AVIFOptions): Promise<Uint8Array> {
174180
if (width < 1 || height < 1) throw new RangeError('AVIF: dimensions must be at least 1');
175181
if (width > 65536 || height > 65536) throw new RangeError('AVIF: dimensions must be at most 65536');
176182
let opaque = true;
@@ -186,12 +192,35 @@ export function encode(rgba: Uint8Array, width: number, height: number, { qualit
186192
const cicp = lossless ? [1, 13, 0] : CICP;
187193
const planes = gray ? [Uint8Array.from({ length: width * height }, (_, i) => rgba[4 * i])]
188194
: lossless ? [1, 2, 0].map(c => Uint8Array.from({ length: width * height }, (_, i) => rgba[4 * i + c])) : toYUV(rgba, width, height, subsampled);
189-
const color = encodeAV1({ planes, width, height, layout: gray ? 0 : subsampled ? 1 : 3, cicp }, { qidx: qindex(quality), effort });
190-
let alpha: Uint8Array | null = null;
191-
if (!opaque) {
192-
const a = new Uint8Array(width * height);
193-
for (let i = 0; i < a.length; i++) a[i] = original[4 * i + 3];
194-
alpha = encodeAV1({ planes: [a], width, height, layout: 0, cicp: [2, 2, 2] }, { qidx: qindex(alphaQuality), effort });
195+
// threads searching superblock rows in a wavefront (each two superblocks behind the one above, so at most half
196+
// the superblocks of a row at once) and running filter trials: the same files. Without them (or if they fail, like
197+
// workers whose module can't be loaded), the encoder runs here
198+
const n = Math.min(maxThreads, height + 63 >> 6, Math.ceil((width + 63 >> 6) / 2));
199+
let threads = n > 1 && width * height >= MIN_THREADED_PIXELS[effort] ? startThreads(n) : null;
200+
const stop = (): void => {
201+
if (threads) for (const t of threads) t.terminate();
202+
threads = null;
203+
};
204+
const av1 = async (image: AV1Image, params: AV1Params): Promise<Uint8Array> => {
205+
if (threads) {
206+
try {
207+
return await encodeAV1Threaded(image, params, threads);
208+
} catch {
209+
stop();
210+
}
211+
}
212+
return encodeAV1(image, params);
213+
};
214+
let color: Uint8Array, alpha: Uint8Array | null = null;
215+
try {
216+
color = await av1({ planes, width, height, layout: gray ? 0 : subsampled ? 1 : 3, cicp }, { qidx: qindex(quality), effort });
217+
if (!opaque) {
218+
const a = new Uint8Array(width * height);
219+
for (let i = 0; i < a.length; i++) a[i] = original[4 * i + 3];
220+
alpha = await av1({ planes: [a], width, height, layout: 0, cicp: [2, 2, 2] }, { qidx: qindex(alphaQuality), effort });
221+
}
222+
} finally {
223+
stop();
195224
}
196225

197226
// the boxes: ftyp, meta (the items, their locations in mdat, their properties), mdat

‎src/image.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import * as netpbm from './codecs/netpbm.ts';
1111
import * as hdr from './codecs/hdr.ts';
1212
import * as avif from './codecs/avif.ts';
1313
import * as avifEnc from './codecs/avif-enc.ts';
14+
import { availableThreads } from './util/threads.ts';
1415
import * as jxl from './codecs/jxl.ts';
1516
import type { ToneMapping } from './codecs/hdr.ts';
1617
import * as quantize from './codecs/quantize.ts';
@@ -234,6 +235,13 @@ export interface AVIFEncodeOptions {
234235
subsampling?: '4:4:4' | '4:2:0';
235236
/** How hard the encoder searches for the best modes: 0-2, faster to smaller. Default: `1` */
236237
effort?: number;
238+
/**
239+
* At most this many worker threads encode in parallel (the same file, faster: 2.9 times with 8 on a 1920x1080
240+
* photo at effort 1), `1` for none. Small images (under about 640x640 pixels at effort 0, 448x448 at 1, 320x320
241+
* at 2), and runtimes whose workers can't share memory (browsers without cross-origin isolation), encode without
242+
* them. Default: the processors available, at most 8
243+
*/
244+
threads?: number;
237245
/** Metadata to include, see {@link Metadata} */
238246
metadata?: Metadata;
239247
}
@@ -2168,9 +2176,10 @@ export class Image {
21682176
* Encodes the image as AVIF (AV1, with an alpha image if the image has transparency)
21692177
* @param options See {@link AVIFEncodeOptions}
21702178
*/
2171-
async encodeAVIF({ quality = 60, alphaQuality = 100, subsampling = '4:4:4', effort = 1, metadata = {} }: AVIFEncodeOptions = {}): Promise<Uint8Array> {
2179+
async encodeAVIF({ quality = 60, alphaQuality = 100, subsampling = '4:4:4', effort = 1, threads = Math.max(1, Math.min(availableThreads(), 8)), metadata = {} }: AVIFEncodeOptions = {}): Promise<Uint8Array> {
2180+
if (!Number.isInteger(threads) || threads < 1) throw new RangeError(`threads must be a positive integer, not ${threads}`);
21722181
const exif = exifData(resolveMetadata(metadata));
2173-
return avifEnc.encode(this.__bytes__(), this.width, this.height, { quality: finite(quality, 'quality'), alphaQuality: finite(alphaQuality, 'alphaQuality'), subsampling, effort, exif });
2182+
return avifEnc.encode(this.__bytes__(), this.width, this.height, { quality: finite(quality, 'quality'), alphaQuality: finite(alphaQuality, 'alphaQuality'), subsampling, effort, exif, threads });
21742183
}
21752184

21762185
/**

‎src/util/threads.ts‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// worker threads: Web Workers (Deno, Bun, browsers) or node:worker_threads (Node), sharing memory through
2+
// SharedArrayBuffers. Accessed through feature detection with minimal local types, like node:zlib (see zlib.ts)
3+
4+
// (URL and import.meta.url aren't in the es2022 lib)
5+
declare const URL: new (url: string, base: string) => URL;
6+
interface URL {
7+
readonly href: string;
8+
}
9+
declare global {
10+
interface ImportMeta {
11+
readonly url: string;
12+
}
13+
}
14+
15+
interface NodeWorker {
16+
on(event: 'message' | 'error', listener: (value: unknown) => void): void;
17+
postMessage(message: unknown): void;
18+
terminate(): Promise<number>;
19+
unref(): void;
20+
}
21+
22+
interface WebWorker {
23+
onmessage: ((event: { data: unknown }) => void) | null;
24+
onerror: ((event: { message?: string, preventDefault?(): void }) => void) | null;
25+
postMessage(message: unknown): void;
26+
terminate(): void;
27+
}
28+
29+
declare const Worker: new (url: URL, options: { type: 'module' }) => WebWorker;
30+
31+
interface Globals {
32+
process?: { getBuiltinModule?: (id: string) => unknown };
33+
Worker?: unknown;
34+
navigator?: { hardwareConcurrency?: number };
35+
crossOriginIsolated?: boolean;
36+
}
37+
38+
const globals = globalThis as unknown as Globals;
39+
40+
function builtin<T>(id: string): T | null {
41+
try {
42+
return (globals.process?.getBuiltinModule?.(id) as T | undefined) ?? null;
43+
} catch {
44+
return null;
45+
}
46+
}
47+
48+
/** A worker running src/util/worker.ts */
49+
export interface Thread {
50+
/** Runs a task (one at a time), passing the messages it posts on the way to onMessage: its result */
51+
run(task: unknown, onMessage?: (message: unknown) => void): Promise<unknown>;
52+
terminate(): void;
53+
}
54+
55+
// what a worker posts: a message on the way, its task's result or the error it failed with
56+
interface Reply {
57+
message?: unknown;
58+
result?: unknown;
59+
error?: string;
60+
}
61+
62+
// a thread of a worker posting messages and errors to listeners set once
63+
function thread(post: (m: unknown) => void, listen: (message: (m: Reply) => void, error: (e: Error) => void) => void, terminate: () => void): Thread {
64+
let current: { resolve(v: unknown): void, reject(e: Error): void, onMessage?: (m: unknown) => void } | null = null;
65+
listen(m => {
66+
if (!current) return;
67+
if ('message' in m) current.onMessage?.(m.message);
68+
else {
69+
const c = current;
70+
current = null;
71+
if (m.error !== undefined) c.reject(new Error(m.error));
72+
else c.resolve(m.result);
73+
}
74+
}, e => {
75+
const c = current;
76+
current = null;
77+
c?.reject(e);
78+
});
79+
return {
80+
run: (task, onMessage) => new Promise((resolve, reject) => {
81+
current = { resolve, reject, onMessage };
82+
post(task);
83+
}),
84+
terminate,
85+
};
86+
}
87+
88+
/** The number of threads the runtime can run in parallel (0 if it can't start workers sharing memory) */
89+
export function availableThreads(): number {
90+
if (typeof SharedArrayBuffer === 'undefined' || typeof Atomics === 'undefined' || globals.crossOriginIsolated === false) return 0;
91+
if (!globals.Worker && !builtin('node:worker_threads')) return 0;
92+
const os = builtin<{ availableParallelism?(): number, cpus(): unknown[] }>('node:os');
93+
const n = os ? os.availableParallelism ? os.availableParallelism() : os.cpus().length : globals.navigator?.hardwareConcurrency ?? 1;
94+
return Math.max(1, n | 0);
95+
}
96+
97+
98+
/** Starts n workers, null if the runtime can't */
99+
export function startThreads(n: number): Thread[] | null {
100+
if (n < 1 || !availableThreads()) return null;
101+
const threads: Thread[] = [];
102+
try {
103+
for (let i = 0; i < n; i++) {
104+
if (globals.Worker) {
105+
// (written like this, bundlers find the worker's module and bundle it too; the build points it at
106+
// worker.js)
107+
const w = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
108+
threads.push(thread(m => w.postMessage(m), (message, error) => {
109+
w.onmessage = e => message(e.data as Reply);
110+
w.onerror = e => {
111+
e.preventDefault?.();
112+
error(new Error(e.message ?? 'worker failed'));
113+
};
114+
}, () => w.terminate()));
115+
} else {
116+
const { Worker } = builtin('node:worker_threads') as { Worker: new (url: URL) => NodeWorker };
117+
const w = new Worker(new URL(import.meta.url.endsWith('.ts') ? './worker.ts' : './worker.js', import.meta.url));
118+
// (an idle worker doesn't keep the process alive)
119+
w.unref();
120+
threads.push(thread(m => w.postMessage(m), (message, error) => {
121+
w.on('message', m => message(m as Reply));
122+
w.on('error', e => error(e as Error));
123+
}, () => void w.terminate()));
124+
}
125+
}
126+
return threads;
127+
} catch {
128+
for (const t of threads) t.terminate();
129+
return null;
130+
}
131+
}

‎src/util/worker.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// the module worker threads run (see threads.ts): tasks posted to it, their results posted back
2+
import { searchRows, filterTrials, type SearchTask, type FilterTask } from '../codecs/av1/encode.ts';
3+
4+
interface Port {
5+
post(message: unknown): void;
6+
}
7+
8+
interface Globals {
9+
process?: { getBuiltinModule?: (id: string) => unknown };
10+
postMessage?: (message: unknown) => void;
11+
onmessage?: ((event: { data: unknown }) => void) | null;
12+
}
13+
14+
const globals = globalThis as unknown as Globals;
15+
16+
function handle(port: Port, task: { task: string }): void {
17+
try {
18+
const message = (m: unknown): void => port.post({ message: m });
19+
let result: unknown;
20+
if (task.task === 'av1-search') result = searchRows(task as SearchTask, message);
21+
else if (task.task === 'av1-filter') result = filterTrials(task as FilterTask);
22+
else throw new Error(`unknown task ${task.task}`);
23+
port.post({ result });
24+
} catch (e) {
25+
port.post({ error: String((e as Error).stack ?? e) });
26+
}
27+
}
28+
29+
if (typeof globals.postMessage === 'function' && 'onmessage' in globals) {
30+
const port = { post: (m: unknown) => (globals.postMessage as (m: unknown) => void)(m) };
31+
globals.onmessage = e => handle(port, e.data as { task: string });
32+
} else {
33+
const { parentPort } = globals.process?.getBuiltinModule?.('node:worker_threads') as { parentPort: { on(e: 'message', l: (m: unknown) => void): void, postMessage(m: unknown): void } };
34+
const port = { post: (m: unknown) => parentPort.postMessage(m) };
35+
parentPort.on('message', m => handle(port, m as { task: string }));
36+
}

‎tests/avif-encode.js‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,21 @@ const compare = (a, b) => {
6969
if (Buffer.compare(Buffer.from(exact.bitmap), Buffer.from(screen.bitmap))) panic(`screen content, effort ${effort}: quality 100 isn't lossless`);
7070
}
7171

72+
// threads (rows searched in a wavefront, filter trials in parallel): the same files as without, with alpha and
73+
// with several tiles too
74+
{
75+
const big = photo.clone().resize(704, Image.RESIZE_AUTO), alpha = big.clone();
76+
for (let i = 3; i < alpha.bitmap.length; i += 4) alpha.bitmap[i] = (i >> 2) % alpha.width;
77+
const wide = new Image(4160, 192);
78+
for (let y = 0; y < 192; y++) for (let x = 0; x < 4160; x++) wide.setPixelAt(x, y, Image.rgbaToColor(x * 37 & 255, y * 71 & 255, (x ^ y) & 255, 255));
79+
for (const [name, image, effort] of [['photo', big, 1], ['alpha', alpha, 0], ['tiles', wide, 0]]) {
80+
const one = await image.encodeAVIF({effort, threads: 1, metadata: {creationTime: 0}});
81+
const four = await image.encodeAVIF({effort, threads: 4, metadata: {creationTime: 0}});
82+
if (Buffer.compare(Buffer.from(one), Buffer.from(four))) panic(`threads, ${name}: not the same file`);
83+
}
84+
for (const threads of [0, 1.5]) if (!await big.encodeAVIF({threads}).then(() => false, e => e instanceof RangeError)) panic(`threads ${threads} should throw`);
85+
}
86+
7287
// odd sizes, a pixel, wider than a tile (4096 pixels)
7388
for (const [w, h] of [[1, 1], [7, 3], [65, 130], [4100, 8]]) {
7489
const image = new Image(w, h);

0 commit comments

Comments
 (0)