Skip to content

Commit b8dcd7b

Browse files
matmenclaude
andcommitted
Faster JPEG, PNG, GIF and WebP decoding, JPEG encoding
Identical results, about 14% faster JPEG/PNG/GIF decoding, 8% lossy WebP decoding and 10% JPEG encoding: JPEG color conversion per transform with a clamp lookup and 32-bit stores, zigzag-ordered coefficients with block ends in the encoder, a register-based Paeth row and 32-bit RGB stores in PNG, LZW strings copied from the output in GIF, 32-bit stores in VP8 upsampling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
1 parent c6a5e58 commit b8dcd7b

5 files changed

Lines changed: 174 additions & 99 deletions

File tree

‎CHANGELOG.md‎

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

3+
## 3.8.1
4+
5+
### Performance
6+
7+
Decoding and encoding is faster, with identical results (images and files):
8+
9+
- JPEG decoding is about 14% faster (a 1920x1080 photo: 47 ms instead of 55 ms): YCbCr becomes RGB in a loop of its
10+
own, clamped by a lookup and stored a pixel at a time.
11+
- JPEG encoding is about 10% faster: coefficients are stored in zigzag order with the position of each block's last
12+
nonzero one, so writing a block stops there instead of scanning all 64 (twice).
13+
- PNG decoding is about 14% faster (a 1920x1080 photo: 36 ms instead of 41 ms): the Paeth filter keeps the neighbours
14+
of 3 and 4 byte pixels in variables, RGB rows become RGBA a pixel at a time.
15+
- GIF decoding is about 14% faster (101 frames of 300x300: 40 ms instead of 46 ms): LZW strings are copied from
16+
where they were decoded before, instead of following a chain of codes back a byte at a time.
17+
- Lossy WebP decoding is about 8% faster: upsampled pixels are stored a pixel at a time.
18+
319
## 3.8.0
420

521
### Features

‎src/codecs/gif.ts‎

Lines changed: 22 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -388,19 +388,14 @@ function lzwDecode(data: Uint8Array, minCodeSize: number, pixelCount: number): U
388388
const clear = 1 << minCodeSize;
389389
const eoi = clear + 1;
390390

391-
// each code is stored as (prefix code, suffix byte, first byte, length)
392-
const prefix = new Uint16Array(4096);
393-
const suffix = new Uint8Array(4096);
394-
const first = new Uint8Array(4096);
395-
const lengths = new Uint16Array(4096);
396-
for (let i = 0; i < clear; i++) {
397-
suffix[i] = first[i] = i;
398-
lengths[i] = 1;
399-
}
391+
// the string of a code is in the output already (where it was written, then the next code's first byte): codes
392+
// are stored as its position and length there, and decoded by copying (the codes below clear are single bytes)
393+
const positions = new Int32Array(4096);
394+
const lengths = new Uint16Array(4096).fill(1, 0, clear);
400395

401396
let codeSize = minCodeSize + 1;
402397
let next = eoi + 1;
403-
let previous = -1;
398+
let previous = -1, previousOut = 0;
404399
let buffer = 0, bufferBits = 0, position = 0, out = 0;
405400

406401
while (out < pixelCount) {
@@ -422,51 +417,34 @@ function lzwDecode(data: Uint8Array, minCodeSize: number, pixelCount: number): U
422417
}
423418

424419
if (code === eoi) break;
425-
426-
let entry;
427-
if (code < next) entry = code;
428-
else if (code === next && previous !== -1) entry = previous;
429-
else break; // invalid code
430-
420+
if (code > next || (code === next && previous === -1)) break; // invalid code
431421
if (previous === -1 && code >= clear) break;
432422

433-
// write the string for `entry`, backwards from its end
434-
const length = lengths[entry];
435-
const end = Math.min(out + length, pixelCount);
436-
let c = entry;
437-
if (end === out + length) {
438-
for (let i = end - 1; i >= out; i--) {
439-
output[i] = suffix[c];
440-
c = prefix[c];
441-
}
442-
} else {
443-
// the string runs past the image: only its start is written
444-
for (let i = out + length - 1; i >= out; i--) {
445-
if (i < end) output[i] = suffix[c];
446-
c = prefix[c];
447-
}
448-
}
449-
450-
let written = end - out;
451-
if (code === next) {
452-
// KwKwK case: the new string is previous + first byte of previous
453-
if (out + length < pixelCount) {
454-
output[out + length] = first[previous];
455-
written++;
456-
}
423+
const start = out;
424+
if (code < clear) output[out++] = code;
425+
else {
426+
// KwKwK: a code not in the table yet is the previous string and its first byte, which is the byte just
427+
// after it, about to be written (copyWithin would read it before)
428+
const kwk = code === next;
429+
const from = kwk ? previousOut : positions[code];
430+
const length = kwk ? lengths[previous] + 1 : lengths[code];
431+
// (a string running past the image: only its start is written)
432+
const count = Math.min(length, pixelCount - out) - (kwk ? 1 : 0);
433+
if (count < 16) for (let i = 0; i < count; i++) output[out + i] = output[from + i];
434+
else output.copyWithin(out, from, from + count);
435+
if (kwk && out + count < pixelCount) output[out + count] = output[from];
436+
out = Math.min(out + length, pixelCount);
457437
}
458-
out += written;
459438

460439
if (previous !== -1 && next <= MAX_CODE) {
461-
prefix[next] = previous;
462-
suffix[next] = first[code === next ? previous : code];
463-
first[next] = first[previous];
440+
positions[next] = previousOut;
464441
lengths[next] = lengths[previous] + 1;
465442
next++;
466443
if (next === 1 << codeSize && codeSize < 12) codeSize++;
467444
}
468445

469446
previous = code;
447+
previousOut = start;
470448
}
471449

472450
return output;

‎src/codecs/jpeg.ts‎

Lines changed: 55 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ByteWriter } from '../util/bytes.ts';
99
import { MAX_PIXELS, checkPixels } from '../util/limits.ts';
1010
import { orientation as exifOrientation } from './exif.ts';
1111
import { concat } from '../util/mem.ts';
12+
import { littleEndian } from '../util/alpha.ts';
1213

1314
// natural order index of the n-th zigzag coefficient
1415
const ZIGZAG = Int32Array.of(
@@ -747,6 +748,11 @@ for (let i = 0; i < 256; i++) {
747748
cbG[i] = -22554 * x + 32768;
748749
}
749750

751+
// clamping to 0-255 by a lookup: CLAMP[v + CLAMP_OFFSET] for v in -CLAMP_OFFSET..CLAMP_OFFSET + 255 (YCbCr to RGB
752+
// stays within 227 of the range)
753+
const CLAMP_OFFSET = 256, CLAMP = new Uint8Array(768);
754+
for (let i = 0; i < 768; i++) CLAMP[i] = clamp8(i - CLAMP_OFFSET);
755+
750756
function output(frame: Frame, adobe: { transform: number } | null, jfif: boolean, override: DecodeOptions['transform']): Uint8Array {
751757
const { width, height, components } = frame;
752758
const planes = components.map(component => upsample(component, frame));
@@ -765,33 +771,43 @@ function output(frame: Frame, adobe: { transform: number } | null, jfif: boolean
765771

766772
const [p0, p1, p2, p3] = planes;
767773

774+
// one loop per transform: deciding per pixel costs more than the conversion
775+
if (transform === 'grey' || transform === 'rgb' || transform === 'ycc') {
776+
const s0 = p0.samples, s1 = (p1 ?? p0).samples, s2 = (p2 ?? p0).samples;
777+
const grey = transform === 'grey', ycc = transform === 'ycc';
778+
// (YCbCr a pixel at a time as a 32-bit word, little endian)
779+
const words = littleEndian ? new Uint32Array(pixels.buffer) : null;
780+
for (let y = 0, o = 0; y < height; y++, o = y * width * 4) {
781+
const r0 = y * p0.stride, r1 = p1 ? y * p1.stride : r0, r2 = p2 ? y * p2.stride : r0;
782+
if (grey) for (let x = 0; x < width; x++, o += 4) {
783+
pixels[o] = pixels[o + 1] = pixels[o + 2] = s0[r0 + x];
784+
pixels[o + 3] = 0xff;
785+
} else if (ycc && words) for (let x = 0, w = o >> 2; x < width; x++, w++) {
786+
const a = s0[r0 + x] + CLAMP_OFFSET, b = s1[r1 + x], c = s2[r2 + x];
787+
words[w] = (CLAMP[a + crR[c]] | CLAMP[a + (cbG[b] + crG[c] >> 16)] << 8 | CLAMP[a + cbB[b]] << 16 | 0xff000000) >>> 0;
788+
} else if (ycc) for (let x = 0; x < width; x++, o += 4) {
789+
const a = s0[r0 + x] + CLAMP_OFFSET, b = s1[r1 + x], c = s2[r2 + x];
790+
pixels[o] = CLAMP[a + crR[c]];
791+
pixels[o + 1] = CLAMP[a + (cbG[b] + crG[c] >> 16)];
792+
pixels[o + 2] = CLAMP[a + cbB[b]];
793+
pixels[o + 3] = 0xff;
794+
} else for (let x = 0; x < width; x++, o += 4) {
795+
pixels[o] = s0[r0 + x];
796+
pixels[o + 1] = s1[r1 + x];
797+
pixels[o + 2] = s2[r2 + x];
798+
pixels[o + 3] = 0xff;
799+
}
800+
}
801+
return pixels;
802+
}
803+
768804
for (let y = 0, o = 0; y < height; y++) {
769-
const r0 = y * p0.stride, r1 = p1 ? y * p1.stride : 0, r2 = p2 ? y * p2.stride : 0, r3 = p3 ? y * p3.stride : 0;
805+
const r0 = y * p0.stride, r1 = y * p1.stride, r2 = y * p2.stride, r3 = y * p3.stride;
770806

771807
for (let x = 0; x < width; x++, o += 4) {
772808
pixels[o + 3] = 0xff;
773-
774-
if (transform === 'grey') {
775-
pixels[o] = pixels[o + 1] = pixels[o + 2] = p0.samples[r0 + x];
776-
continue;
777-
}
778-
779809
const a = p0.samples[r0 + x], b = p1.samples[r1 + x], c = p2.samples[r2 + x];
780810

781-
if (transform === 'rgb') {
782-
pixels[o] = a;
783-
pixels[o + 1] = b;
784-
pixels[o + 2] = c;
785-
continue;
786-
}
787-
788-
if (transform === 'ycc') {
789-
pixels[o] = clamp8(a + crR[c]);
790-
pixels[o + 1] = clamp8(a + (cbG[b] + crG[c] >> 16));
791-
pixels[o + 2] = clamp8(a + cbB[b]);
792-
continue;
793-
}
794-
795811
let cyan = a, magenta = b, yellow = c, k = p3.samples[r3 + x];
796812
if (transform === 'ycck') {
797813
cyan = 255 - clamp8(a + crR[c]);
@@ -922,11 +938,12 @@ export function encode(pixels: Uint8Array, width: number, height: number, qualit
922938
{ plane: subsample ? downsample(Cr) : Cr, stride: chromaStride, h: 1, v: 1, table: 1 }
923939
];
924940

925-
// quantized blocks in MCU order (Y blocks, then Cb, then Cr)
941+
// quantized blocks in MCU order (Y blocks, then Cb, then Cr), their coefficients in zigzag order, with the index
942+
// of each block's last nonzero coefficient
926943
const blocksPerMcu = hmax * hmax + 2;
927944
const count = mcusPerLine * mcusPerColumn * blocksPerMcu;
928945
const coefficients = new Int16Array(count * 64);
929-
const owners = new Uint8Array(count);
946+
const owners = new Uint8Array(count), ends = new Uint8Array(count);
930947
const block = new Float64Array(64);
931948
let n = 0;
932949

@@ -948,12 +965,18 @@ export function encode(pixels: Uint8Array, width: number, height: number, qualit
948965
fdct(block);
949966
const o = n * 64;
950967
// round half up like libjpeg, without Math.round
951-
for (let i = 0; i < 64; i++) coefficients[o + i] = (block[i] * d[i] + 16384.5 | 0) - 16384;
968+
let end = 0;
969+
for (let k = 0; k < 64; k++) {
970+
const i = ZIGZAG[k], value = (block[i] * d[i] + 16384.5 | 0) - 16384;
971+
coefficients[o + k] = value;
972+
if (value) end = k;
973+
}
952974
owners[n] = c;
975+
ends[n] = end;
953976
}
954977
}
955978

956-
const blocks = { coefficients, owners, count };
979+
const blocks = { coefficients, owners, ends, count };
957980

958981
// pass 1: gather symbol statistics, pass 2: write with optimal tables
959982
const frequencies = [new Int32Array(257), new Int32Array(257), new Int32Array(257), new Int32Array(257)];
@@ -1010,10 +1033,11 @@ export function encode(pixels: Uint8Array, width: number, height: number, qualit
10101033
interface Blocks {
10111034
coefficients: Int16Array;
10121035
owners: Uint8Array;
1036+
ends: Uint8Array;
10131037
count: number;
10141038
}
10151039

1016-
function encodeBlocks({ coefficients, owners, count }: Blocks, symbol: (table: number, symbol: number) => void, bits: (value: number, size: number) => void): void {
1040+
function encodeBlocks({ coefficients, owners, ends, count }: Blocks, symbol: (table: number, symbol: number) => void, bits: (value: number, size: number) => void): void {
10171041
const pred = [0, 0, 0];
10181042

10191043
for (let n = 0; n < count; n++) {
@@ -1029,8 +1053,8 @@ function encodeBlocks({ coefficients, owners, count }: Blocks, symbol: (table: n
10291053
if (size) bits(diff < 0 ? diff - 1 & (1 << size) - 1 : diff, size);
10301054

10311055
let run = 0;
1032-
for (let k = 1; k < 64; k++) {
1033-
const value = coefficients[o + ZIGZAG[k]];
1056+
for (let k = 1, end = ends[n]; k <= end; k++) {
1057+
const value = coefficients[o + k];
10341058

10351059
if (value === 0) {
10361060
run++;
@@ -1048,18 +1072,13 @@ function encodeBlocks({ coefficients, owners, count }: Blocks, symbol: (table: n
10481072
run = 0;
10491073
}
10501074

1051-
if (run) symbol(acTable, 0x00);
1075+
if (ends[n] < 63) symbol(acTable, 0x00);
10521076
}
10531077
}
10541078

1079+
// the bits a value takes
10551080
function magnitude(value: number): number {
1056-
value = Math.abs(value);
1057-
let size = 0;
1058-
while (value) {
1059-
size++;
1060-
value >>= 1;
1061-
}
1062-
return size;
1081+
return 32 - Math.clz32(Math.abs(value));
10631082
}
10641083

10651084
// libjpeg's jpeg_gen_optimal_table (ITU T.81 Annex K.2)

‎src/codecs/png.ts‎

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { crc32 } from './crc.ts';
22
import { MAX_PIXELS, checkPixels } from '../util/limits.ts';
33
import { concat } from '../util/mem.ts';
4+
import { littleEndian } from '../util/alpha.ts';
45
import { encode as utf8, decode as utf8Decode } from '../util/utf8.ts';
56
import { orientation as exifOrientation, xmpOrientation } from './exif.ts';
67
import { compressAsync, decompressAsync } from './zlib.ts';
@@ -832,7 +833,8 @@ function unfilter(data: Uint8Array, offset: number, height: number, rowBytes: nu
832833
} else {
833834
// and with no left neighbour the one above
834835
for (let i = row; i < row + first; i++) out[i] = data[src + i] + out[i - rowBytes];
835-
for (let i = row + bpp; i < row + rowBytes; i++) {
836+
if (bpp === 3 || bpp === 4) paethRow(data, src, out, row, rowBytes, bpp);
837+
else for (let i = row + bpp; i < row + rowBytes; i++) {
836838
const a = out[i - bpp], b = out[i - rowBytes], c = out[i - rowBytes - bpp];
837839
const pa = Math.abs(b - c), pb = Math.abs(a - c), pc = Math.abs(a + b - 2 * c);
838840
// without branches (photos make them unpredictable): all ones when a loses to b or c, when c beats b
@@ -848,6 +850,53 @@ function unfilter(data: Uint8Array, offset: number, height: number, rowBytes: nu
848850
return out;
849851
}
850852

853+
// the paeth filter on a row of 3 or 4 byte pixels, the left and upper left neighbours in variables: as above, with each
854+
// channel's predictor not waiting for the byte just stored
855+
function paethRow(data: Uint8Array, src: number, out: Uint8Array, row: number, rowBytes: number, bpp: number): void {
856+
const up = row - rowBytes, end = row + rowBytes;
857+
let a0 = out[row], a1 = out[row + 1], a2 = out[row + 2], a3 = bpp === 4 ? out[row + 3] : 0;
858+
let c0 = out[up], c1 = out[up + 1], c2 = out[up + 2], c3 = bpp === 4 ? out[up + 3] : 0;
859+
for (let i = row + bpp; i < end; i += bpp) {
860+
let b = out[i - rowBytes], pa = Math.abs(b - c0), pb = Math.abs(a0 - c0), pc = Math.abs(a0 + b - 2 * c0);
861+
let notA = (pb - pa | pc - pa) >> 31, takeC = (pc - pb) >> 31;
862+
a0 = data[src + i] + (a0 ^ (a0 ^ b ^ (b ^ c0) & takeC) & notA) & 255;
863+
out[i] = a0;
864+
c0 = b;
865+
866+
b = out[i + 1 - rowBytes];
867+
pa = Math.abs(b - c1);
868+
pb = Math.abs(a1 - c1);
869+
pc = Math.abs(a1 + b - 2 * c1);
870+
notA = (pb - pa | pc - pa) >> 31;
871+
takeC = (pc - pb) >> 31;
872+
a1 = data[src + i + 1] + (a1 ^ (a1 ^ b ^ (b ^ c1) & takeC) & notA) & 255;
873+
out[i + 1] = a1;
874+
c1 = b;
875+
876+
b = out[i + 2 - rowBytes];
877+
pa = Math.abs(b - c2);
878+
pb = Math.abs(a2 - c2);
879+
pc = Math.abs(a2 + b - 2 * c2);
880+
notA = (pb - pa | pc - pa) >> 31;
881+
takeC = (pc - pb) >> 31;
882+
a2 = data[src + i + 2] + (a2 ^ (a2 ^ b ^ (b ^ c2) & takeC) & notA) & 255;
883+
out[i + 2] = a2;
884+
c2 = b;
885+
886+
if (bpp === 4) {
887+
b = out[i + 3 - rowBytes];
888+
pa = Math.abs(b - c3);
889+
pb = Math.abs(a3 - c3);
890+
pc = Math.abs(a3 + b - 2 * c3);
891+
notA = (pb - pa | pc - pa) >> 31;
892+
takeC = (pc - pb) >> 31;
893+
a3 = data[src + i + 3] + (a3 ^ (a3 ^ b ^ (b ^ c3) & takeC) & notA) & 255;
894+
out[i + 3] = a3;
895+
c3 = b;
896+
}
897+
}
898+
}
899+
851900
function createConverter(colorType: number, depth: number, palette: Uint8Array | null, transparency: Uint8Array | null): Converter {
852901
const maxValue = (1 << depth) - 1;
853902

@@ -918,6 +967,14 @@ function createConverter(colorType: number, depth: number, palette: Uint8Array |
918967
if (depth === 8) {
919968
const [kr, kg, kb] = key || [-1, -1, -1];
920969
return (pixels, rowBytes, w, h, out, place) => {
970+
// without a transparent color (whole rows: a pixel at a time as a 32-bit word, little endian)
971+
if (!key && place.step === 4 && littleEndian && !(out.byteOffset & 3)) {
972+
const words = new Uint32Array(out.buffer, out.byteOffset, out.length >> 2);
973+
for (let y = 0; y < h; y++)
974+
for (let x = 0, s = y * rowBytes, o = place.row(y) >> 2; x < w; x++, s += 3, o++)
975+
words[o] = (pixels[s] | pixels[s + 1] << 8 | pixels[s + 2] << 16 | 0xff000000) >>> 0;
976+
return;
977+
}
921978
for (let y = 0; y < h; y++) {
922979
for (let x = 0, s = y * rowBytes, o = place.row(y), step = place.step; x < w; x++, s += 3, o += step) {
923980
const r = pixels[s], g = pixels[s + 1], b = pixels[s + 2];

0 commit comments

Comments
 (0)