@ultimat3/core 5.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,463 +0,0 @@
1
- // Single responsibility: writing a raster as a baseline sequential JPEG — SOF0, Huffman-coded,
2
- // 4:2:0, Annex K tables. The subsampling is the whole reason to emit a JPEG instead of a PNG:
3
- // two of the three planes shrink 4x on data the eye cannot resolve, and PNG can never give
4
- // that back. Reads `jpeg-tables.ts` so the encoder and the decoder cannot drift apart.
5
-
6
- import {
7
- AAN_SCALE,
8
- DEFAULT_JPEG_QUALITY,
9
- rgbToYcbcr,
10
- STD_AC_CHROMINANCE_BITS,
11
- STD_AC_CHROMINANCE_VALUES,
12
- STD_AC_LUMINANCE_BITS,
13
- STD_AC_LUMINANCE_VALUES,
14
- STD_CHROMINANCE_QUANT,
15
- STD_DC_CHROMINANCE_BITS,
16
- STD_DC_CHROMINANCE_VALUES,
17
- STD_DC_LUMINANCE_BITS,
18
- STD_DC_LUMINANCE_VALUES,
19
- STD_LUMINANCE_QUANT,
20
- scaleQuantTable,
21
- ZIGZAG,
22
- } from './jpeg-tables';
23
- import type { Raster } from './raster';
24
-
25
- const MARKER = {
26
- soi: 0xd8,
27
- eoi: 0xd9,
28
- sof0: 0xc0,
29
- dht: 0xc4,
30
- sos: 0xda,
31
- dqt: 0xdb,
32
- app0: 0xe0,
33
- } as const;
34
-
35
- /** A 4:2:0 MCU is 16x16 source pixels: four luma blocks over one Cb and one Cr block. */
36
- const MCU_SIZE = 16;
37
-
38
- /**
39
- * The standard tables have no symbol for an 11-bit AC magnitude or a 12-bit DC difference, so a
40
- * coefficient past this is unencodable rather than merely unusual — reachable only by a
41
- * synthetic ±128 checkerboard at quality 100. libjpeg fails the encode there; costing one unit
42
- * on that block keeps every stream we emit decodable.
43
- */
44
- const MAX_COEFFICIENT = 1023;
45
-
46
- /** Symbol -> code, the mirror of the decoder's MINCODE/MAXCODE form in `jpeg-huffman.ts`. */
47
- interface HuffmanEncoder {
48
- readonly codes: Int32Array;
49
- readonly lengths: Int32Array;
50
- }
51
-
52
- /** T.81 Annex C: codes are assigned shortest-first, in ascending order within each length. */
53
- function buildEncoder(bits: readonly number[], values: readonly number[]): HuffmanEncoder {
54
- const codes = new Int32Array(256);
55
- const lengths = new Int32Array(256);
56
- let code = 0;
57
- let k = 0;
58
- for (let length = 1; length <= 16; length += 1) {
59
- for (let n = bits[length - 1] ?? 0; n > 0; n -= 1) {
60
- const symbol = values[k] ?? 0;
61
- codes[symbol] = code;
62
- lengths[symbol] = length;
63
- code += 1;
64
- k += 1;
65
- }
66
- code <<= 1;
67
- }
68
- return { codes, lengths };
69
- }
70
-
71
- const DC_LUMA = buildEncoder(STD_DC_LUMINANCE_BITS, STD_DC_LUMINANCE_VALUES);
72
- const AC_LUMA = buildEncoder(STD_AC_LUMINANCE_BITS, STD_AC_LUMINANCE_VALUES);
73
- const DC_CHROMA = buildEncoder(STD_DC_CHROMINANCE_BITS, STD_DC_CHROMINANCE_VALUES);
74
- const AC_CHROMA = buildEncoder(STD_AC_CHROMINANCE_BITS, STD_AC_CHROMINANCE_VALUES);
75
-
76
- const C4 = Math.SQRT1_2;
77
- const C6 = 0.382683433;
78
- const C2_SUB_C6 = 0.5411961;
79
- const C2_ADD_C6 = 1.306562965;
80
-
81
- /** Folds AAN's leftover scaling into the quantiser, so quantising stays one multiply. */
82
- function buildDivisors(quant: Uint8Array): Float64Array {
83
- const divisors = new Float64Array(64);
84
- for (let row = 0; row < 8; row += 1) {
85
- for (let col = 0; col < 8; col += 1) {
86
- const i = row * 8 + col;
87
- divisors[i] = 1 / ((quant[i] ?? 1) * (AAN_SCALE[row] ?? 1) * (AAN_SCALE[col] ?? 1) * 8);
88
- }
89
- }
90
- return divisors;
91
- }
92
-
93
- /**
94
- * One strided 8-point AAN butterfly (Arai/Agui/Nakajima): 5 multiplies instead of the 64 a
95
- * literal cosine sum costs, and rows and columns share it by varying `step`.
96
- */
97
- function fdct1d(data: Float64Array, base: number, step: number): void {
98
- const s0 = data[base] ?? 0;
99
- const s1 = data[base + step] ?? 0;
100
- const s2 = data[base + step * 2] ?? 0;
101
- const s3 = data[base + step * 3] ?? 0;
102
- const s4 = data[base + step * 4] ?? 0;
103
- const s5 = data[base + step * 5] ?? 0;
104
- const s6 = data[base + step * 6] ?? 0;
105
- const s7 = data[base + step * 7] ?? 0;
106
-
107
- const t0 = s0 + s7;
108
- const t7 = s0 - s7;
109
- const t1 = s1 + s6;
110
- const t6 = s1 - s6;
111
- const t2 = s2 + s5;
112
- const t5 = s2 - s5;
113
- const t3 = s3 + s4;
114
- const t4 = s3 - s4;
115
-
116
- const e0 = t0 + t3;
117
- const e3 = t0 - t3;
118
- const e1 = t1 + t2;
119
- const e2 = t1 - t2;
120
- const z1 = (e2 + e3) * C4;
121
- data[base] = e0 + e1;
122
- data[base + step * 4] = e0 - e1;
123
- data[base + step * 2] = e3 + z1;
124
- data[base + step * 6] = e3 - z1;
125
-
126
- const o0 = t4 + t5;
127
- const o1 = t5 + t6;
128
- const o2 = t6 + t7;
129
- const z5 = (o0 - o2) * C6;
130
- const z2 = C2_SUB_C6 * o0 + z5;
131
- const z4 = C2_ADD_C6 * o2 + z5;
132
- const z3 = o1 * C4;
133
- data[base + step * 5] = t7 - z3 + z2;
134
- data[base + step * 3] = t7 - z3 - z2;
135
- data[base + step] = t7 + z3 + z4;
136
- data[base + step * 7] = t7 + z3 - z4;
137
- }
138
-
139
- function forwardDct(data: Float64Array): void {
140
- for (let row = 0; row < 8; row += 1) fdct1d(data, row * 8, 1);
141
- for (let col = 0; col < 8; col += 1) fdct1d(data, col, 8);
142
- }
143
-
144
- class JpegSink {
145
- private buffer: Uint8Array;
146
- private length = 0;
147
- private bits = 0;
148
- private bitCount = 0;
149
-
150
- constructor(capacity: number) {
151
- this.buffer = new Uint8Array(Math.max(1024, capacity));
152
- }
153
-
154
- byte(value: number): void {
155
- if (this.length === this.buffer.length) {
156
- const grown = new Uint8Array(this.buffer.length * 2);
157
- grown.set(this.buffer);
158
- this.buffer = grown;
159
- }
160
- this.buffer[this.length] = value;
161
- this.length += 1;
162
- }
163
-
164
- bytes(values: readonly number[]): void {
165
- for (const value of values) this.byte(value);
166
- }
167
-
168
- word(value: number): void {
169
- this.byte((value >> 8) & 0xff);
170
- this.byte(value & 0xff);
171
- }
172
-
173
- marker(code: number): void {
174
- this.byte(0xff);
175
- this.byte(code);
176
- }
177
-
178
- /** A raw 0xFF in the scan would read as a marker, so T.81 stuffs a 0x00 behind every one. */
179
- writeBits(code: number, length: number): void {
180
- this.bits = (this.bits << length) | (code & ((1 << length) - 1));
181
- this.bitCount += length;
182
- while (this.bitCount >= 8) {
183
- this.bitCount -= 8;
184
- const value = (this.bits >>> this.bitCount) & 0xff;
185
- this.byte(value);
186
- if (value === 0xff) this.byte(0x00);
187
- }
188
- this.bits &= (1 << this.bitCount) - 1;
189
- }
190
-
191
- /** Pad with 1-bits: a 0-pad can spell a real Huffman code and grow the last block a symbol. */
192
- flushBits(): void {
193
- if (this.bitCount > 0) this.writeBits(0xff, 8 - this.bitCount);
194
- }
195
-
196
- finish(): Uint8Array {
197
- return this.buffer.slice(0, this.length);
198
- }
199
- }
200
-
201
- type Plane = Uint8ClampedArray | Float32Array;
202
-
203
- interface Planes {
204
- readonly luma: Uint8ClampedArray;
205
- readonly cb: Float32Array;
206
- readonly cr: Float32Array;
207
- readonly lumaWidth: number;
208
- readonly chromaWidth: number;
209
- }
210
-
211
- /**
212
- * JPEG carries no alpha, so a transparent pixel still has to become some colour. Compositing
213
- * over opaque white (`out = src*a + 255*(1-a)`) is why a transparent logo arrives white-backed
214
- * instead of as the black box that dropping the alpha channel outright would produce.
215
- * Padding replicates the last real row/column: zero-fill would put a hard step to black inside
216
- * the edge MCU, and the DCT spreads that step back across visible pixels as a dark rim.
217
- */
218
- function buildPlanes(raster: Raster, mcusX: number, mcusY: number): Planes {
219
- const { width, height, pixels } = raster;
220
- const lumaWidth = mcusX * MCU_SIZE;
221
- const lumaHeight = mcusY * MCU_SIZE;
222
- const chromaWidth = mcusX * 8;
223
- const luma = new Uint8ClampedArray(lumaWidth * lumaHeight);
224
- const cb = new Float32Array(chromaWidth * mcusY * 8);
225
- const cr = new Float32Array(chromaWidth * mcusY * 8);
226
- for (let y = 0; y < lumaHeight; y += 1) {
227
- const sourceRow = (y < height ? y : height - 1) * width;
228
- const chromaRow = (y >> 1) * chromaWidth;
229
- const lumaRow = y * lumaWidth;
230
- for (let x = 0; x < lumaWidth; x += 1) {
231
- const p = (sourceRow + (x < width ? x : width - 1)) * 4;
232
- const alpha = (pixels[p + 3] ?? 255) / 255;
233
- const over = 255 * (1 - alpha);
234
- const [yy, cbValue, crValue] = rgbToYcbcr(
235
- (pixels[p] ?? 0) * alpha + over,
236
- (pixels[p + 1] ?? 0) * alpha + over,
237
- (pixels[p + 2] ?? 0) * alpha + over,
238
- );
239
- luma[lumaRow + x] = yy;
240
- const ci = chromaRow + (x >> 1);
241
- cb[ci] = (cb[ci] ?? 0) + cbValue;
242
- cr[ci] = (cr[ci] ?? 0) + crValue;
243
- }
244
- }
245
- // Box-average, not point-sample: every chroma sample sees all four pixels it stands in for.
246
- for (let i = 0; i < cb.length; i += 1) {
247
- cb[i] = (cb[i] ?? 0) / 4;
248
- cr[i] = (cr[i] ?? 0) / 4;
249
- }
250
- return { luma, cb, cr, lumaWidth, chromaWidth };
251
- }
252
-
253
- /** Copies an 8x8 block out of a plane, level-shifted to the DCT's signed range. */
254
- function extractBlock(
255
- plane: Plane,
256
- planeWidth: number,
257
- blockX: number,
258
- blockY: number,
259
- out: Float64Array,
260
- ): void {
261
- for (let row = 0; row < 8; row += 1) {
262
- const source = (blockY * 8 + row) * planeWidth + blockX * 8;
263
- for (let col = 0; col < 8; col += 1) {
264
- out[row * 8 + col] = (plane[source + col] ?? 0) - 128;
265
- }
266
- }
267
- }
268
-
269
- function quantise(block: Float64Array, divisors: Float64Array, out: Int32Array): void {
270
- for (let i = 0; i < 64; i += 1) {
271
- const value = Math.round((block[i] ?? 0) * (divisors[i] ?? 0));
272
- out[i] = Math.max(-MAX_COEFFICIENT, Math.min(MAX_COEFFICIENT, value));
273
- }
274
- }
275
-
276
- function magnitude(value: number): number {
277
- let bits = 0;
278
- let rest = value < 0 ? -value : value;
279
- while (rest > 0) {
280
- bits += 1;
281
- rest >>= 1;
282
- }
283
- return bits;
284
- }
285
-
286
- /** T.81 F.1.2.1: a negative value travels as the low `size` bits of `value - 1`. */
287
- function writeValue(sink: JpegSink, value: number, size: number): void {
288
- sink.writeBits(value < 0 ? value + (1 << size) - 1 : value, size);
289
- }
290
-
291
- /** Returns this block's DC, which is the predictor for the next block of the same component. */
292
- function writeBlock(
293
- sink: JpegSink,
294
- coefficients: Int32Array,
295
- dc: HuffmanEncoder,
296
- ac: HuffmanEncoder,
297
- previousDc: number,
298
- ): number {
299
- const dcValue = coefficients[0] ?? 0;
300
- const diff = dcValue - previousDc;
301
- const dcSize = magnitude(diff);
302
- sink.writeBits(dc.codes[dcSize] ?? 0, dc.lengths[dcSize] ?? 0);
303
- writeValue(sink, diff, dcSize);
304
-
305
- let last = 0;
306
- for (let k = 63; k >= 1; k -= 1) {
307
- if ((coefficients[ZIGZAG[k] ?? 0] ?? 0) !== 0) {
308
- last = k;
309
- break;
310
- }
311
- }
312
- let run = 0;
313
- for (let k = 1; k <= last; k += 1) {
314
- const value = coefficients[ZIGZAG[k] ?? 0] ?? 0;
315
- if (value === 0) {
316
- run += 1;
317
- continue;
318
- }
319
- while (run >= 16) {
320
- sink.writeBits(ac.codes[0xf0] ?? 0, ac.lengths[0xf0] ?? 0);
321
- run -= 16;
322
- }
323
- const size = magnitude(value);
324
- const symbol = (run << 4) | size;
325
- sink.writeBits(ac.codes[symbol] ?? 0, ac.lengths[symbol] ?? 0);
326
- writeValue(sink, value, size);
327
- run = 0;
328
- }
329
- if (last < 63) sink.writeBits(ac.codes[0] ?? 0, ac.lengths[0] ?? 0);
330
- return dcValue;
331
- }
332
-
333
- interface QuantTables {
334
- readonly luma: Uint8Array;
335
- readonly chroma: Uint8Array;
336
- }
337
-
338
- function writeQuantTable(sink: JpegSink, id: number, table: Uint8Array): void {
339
- sink.marker(MARKER.dqt);
340
- sink.word(67);
341
- sink.byte(id); // High nibble 0 == 8-bit precision.
342
- for (let k = 0; k < 64; k += 1) sink.byte(table[ZIGZAG[k] ?? 0] ?? 1);
343
- }
344
-
345
- function writeHuffmanTable(
346
- sink: JpegSink,
347
- id: number,
348
- bits: readonly number[],
349
- values: readonly number[],
350
- ): void {
351
- sink.marker(MARKER.dht);
352
- sink.word(19 + values.length);
353
- sink.byte(id);
354
- for (let i = 0; i < 16; i += 1) sink.byte(bits[i] ?? 0);
355
- for (const value of values) sink.byte(value);
356
- }
357
-
358
- function writeHeaders(sink: JpegSink, raster: Raster, quant: QuantTables): void {
359
- sink.marker(MARKER.soi);
360
- sink.marker(MARKER.app0);
361
- sink.word(16);
362
- sink.bytes([0x4a, 0x46, 0x49, 0x46, 0x00, 1, 1, 0]); // 'JFIF\0', version 1.1, no density unit
363
- sink.bytes([0, 1, 0, 1, 0, 0]); // 1:1 pixel aspect, no thumbnail
364
- writeQuantTable(sink, 0, quant.luma);
365
- writeQuantTable(sink, 1, quant.chroma);
366
- sink.marker(MARKER.sof0);
367
- sink.word(17);
368
- sink.byte(8);
369
- sink.word(raster.height);
370
- sink.word(raster.width);
371
- sink.byte(3);
372
- sink.bytes([1, 0x22, 0]); // Y: h=2, v=2 — the 4:2:0 that halves both chroma planes
373
- sink.bytes([2, 0x11, 1]);
374
- sink.bytes([3, 0x11, 1]);
375
- writeHuffmanTable(sink, 0x00, STD_DC_LUMINANCE_BITS, STD_DC_LUMINANCE_VALUES);
376
- writeHuffmanTable(sink, 0x10, STD_AC_LUMINANCE_BITS, STD_AC_LUMINANCE_VALUES);
377
- writeHuffmanTable(sink, 0x01, STD_DC_CHROMINANCE_BITS, STD_DC_CHROMINANCE_VALUES);
378
- writeHuffmanTable(sink, 0x11, STD_AC_CHROMINANCE_BITS, STD_AC_CHROMINANCE_VALUES);
379
- sink.marker(MARKER.sos);
380
- sink.word(12);
381
- sink.byte(3);
382
- sink.bytes([1, 0x00, 2, 0x11, 3, 0x11]);
383
- sink.bytes([0, 63, 0]); // Baseline: the whole spectral band, no successive approximation
384
- }
385
-
386
- interface Component {
387
- readonly plane: Plane;
388
- readonly planeWidth: number;
389
- readonly divisors: Float64Array;
390
- readonly dc: HuffmanEncoder;
391
- readonly ac: HuffmanEncoder;
392
- /** Index into the DC predictor table — DC is differential per component across the scan. */
393
- readonly slot: number;
394
- }
395
-
396
- function writeScan(
397
- sink: JpegSink,
398
- planes: Planes,
399
- quant: QuantTables,
400
- mcusX: number,
401
- mcusY: number,
402
- ): void {
403
- const shared = { planeWidth: planes.chromaWidth, dc: DC_CHROMA, ac: AC_CHROMA } as const;
404
- const chromaDivisors = buildDivisors(quant.chroma);
405
- const y: Component = {
406
- plane: planes.luma,
407
- planeWidth: planes.lumaWidth,
408
- divisors: buildDivisors(quant.luma),
409
- dc: DC_LUMA,
410
- ac: AC_LUMA,
411
- slot: 0,
412
- };
413
- const cb: Component = { plane: planes.cb, divisors: chromaDivisors, slot: 1, ...shared };
414
- const cr: Component = { plane: planes.cr, divisors: chromaDivisors, slot: 2, ...shared };
415
-
416
- const block = new Float64Array(64);
417
- const coefficients = new Int32Array(64);
418
- const predictors = new Int32Array(3);
419
- const encodeOne = (
420
- { plane, planeWidth, divisors, dc, ac, slot }: Component,
421
- bx: number,
422
- by: number,
423
- ): void => {
424
- extractBlock(plane, planeWidth, bx, by, block);
425
- forwardDct(block);
426
- quantise(block, divisors, coefficients);
427
- predictors[slot] = writeBlock(sink, coefficients, dc, ac, predictors[slot] ?? 0);
428
- };
429
- for (let my = 0; my < mcusY; my += 1) {
430
- for (let mx = 0; mx < mcusX; mx += 1) {
431
- // Interleaved, in MCU order: four Y blocks, then the single Cb and Cr they share.
432
- for (let b = 0; b < 4; b += 1) encodeOne(y, mx * 2 + (b & 1), my * 2 + (b >> 1));
433
- encodeOne(cb, mx, my);
434
- encodeOne(cr, mx, my);
435
- }
436
- }
437
- sink.flushBits();
438
- }
439
-
440
- /** 1-100. A non-finite quality is a caller bug we absorb rather than a reason to fail an encode. */
441
- function clampQuality(quality: number): number {
442
- if (!Number.isFinite(quality)) return DEFAULT_JPEG_QUALITY;
443
- return Math.min(100, Math.max(1, Math.round(quality)));
444
- }
445
-
446
- /**
447
- * The raster as baseline JPEG bytes. Deterministic: same raster and quality, same bytes, always
448
- * — which is what lets a build cache a derived image by hashing its inputs.
449
- */
450
- export function encodeJpeg(raster: Raster, quality: number = DEFAULT_JPEG_QUALITY): Uint8Array {
451
- const scaled = clampQuality(quality);
452
- const mcusX = Math.ceil(raster.width / MCU_SIZE);
453
- const mcusY = Math.ceil(raster.height / MCU_SIZE);
454
- const quant: QuantTables = {
455
- luma: scaleQuantTable(STD_LUMINANCE_QUANT, scaled),
456
- chroma: scaleQuantTable(STD_CHROMINANCE_QUANT, scaled),
457
- };
458
- const sink = new JpegSink(Math.min(1 << 22, raster.width * raster.height) + 1024);
459
- writeHeaders(sink, raster, quant);
460
- writeScan(sink, buildPlanes(raster, mcusX, mcusY), quant, mcusX, mcusY);
461
- sink.marker(MARKER.eoi);
462
- return sink.finish();
463
- }