@hyperframes/engine 0.6.119 → 0.6.121

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.
Files changed (73) hide show
  1. package/package.json +24 -7
  2. package/scripts/generate-lut-reference.py +0 -168
  3. package/scripts/test-fitTextFontSize-browser.ts +0 -135
  4. package/src/cdp-headless-experimental.d.ts +0 -54
  5. package/src/config.test.ts +0 -213
  6. package/src/config.ts +0 -417
  7. package/src/index.ts +0 -273
  8. package/src/services/audioMixer.test.ts +0 -326
  9. package/src/services/audioMixer.ts +0 -604
  10. package/src/services/audioMixer.types.ts +0 -35
  11. package/src/services/audioVolumeEnvelope.test.ts +0 -176
  12. package/src/services/audioVolumeEnvelope.ts +0 -138
  13. package/src/services/browserManager.test.ts +0 -330
  14. package/src/services/browserManager.ts +0 -670
  15. package/src/services/chunkEncoder.test.ts +0 -1415
  16. package/src/services/chunkEncoder.ts +0 -831
  17. package/src/services/chunkEncoder.types.ts +0 -60
  18. package/src/services/extractionCache.test.ts +0 -199
  19. package/src/services/extractionCache.ts +0 -216
  20. package/src/services/fileServer.ts +0 -110
  21. package/src/services/frameCapture-discardWarmup.test.ts +0 -183
  22. package/src/services/frameCapture-namePolyfill.test.ts +0 -78
  23. package/src/services/frameCapture-pollImagesReady.test.ts +0 -153
  24. package/src/services/frameCapture-staticDedupIndex.test.ts +0 -76
  25. package/src/services/frameCapture-warmupTicks.test.ts +0 -174
  26. package/src/services/frameCapture.test.ts +0 -192
  27. package/src/services/frameCapture.ts +0 -1934
  28. package/src/services/hdrCapture.test.ts +0 -159
  29. package/src/services/hdrCapture.ts +0 -315
  30. package/src/services/parallelCoordinator.test.ts +0 -139
  31. package/src/services/parallelCoordinator.ts +0 -437
  32. package/src/services/screenshotService.test.ts +0 -510
  33. package/src/services/screenshotService.ts +0 -615
  34. package/src/services/streamingEncoder.test.ts +0 -832
  35. package/src/services/streamingEncoder.ts +0 -594
  36. package/src/services/systemMemory.test.ts +0 -324
  37. package/src/services/systemMemory.ts +0 -180
  38. package/src/services/videoFrameExtractor.test.ts +0 -1062
  39. package/src/services/videoFrameExtractor.ts +0 -1139
  40. package/src/services/videoFrameInjector.test.ts +0 -300
  41. package/src/services/videoFrameInjector.ts +0 -687
  42. package/src/services/vp9Options.ts +0 -13
  43. package/src/types.ts +0 -191
  44. package/src/utils/alphaBlit.test.ts +0 -1349
  45. package/src/utils/alphaBlit.ts +0 -1015
  46. package/src/utils/assertSwiftShader.test.ts +0 -130
  47. package/src/utils/assertSwiftShader.ts +0 -126
  48. package/src/utils/ffmpegBinaries.test.ts +0 -43
  49. package/src/utils/ffmpegBinaries.ts +0 -63
  50. package/src/utils/ffprobe.test.ts +0 -342
  51. package/src/utils/ffprobe.ts +0 -457
  52. package/src/utils/gpuEncoder.test.ts +0 -140
  53. package/src/utils/gpuEncoder.ts +0 -268
  54. package/src/utils/hdr.test.ts +0 -191
  55. package/src/utils/hdr.ts +0 -137
  56. package/src/utils/hdrCompositing.test.ts +0 -130
  57. package/src/utils/htmlTemplate.test.ts +0 -42
  58. package/src/utils/htmlTemplate.ts +0 -42
  59. package/src/utils/layerCompositor.test.ts +0 -150
  60. package/src/utils/layerCompositor.ts +0 -58
  61. package/src/utils/parityContract.ts +0 -1
  62. package/src/utils/processTracker.test.ts +0 -74
  63. package/src/utils/processTracker.ts +0 -41
  64. package/src/utils/readWebGlVendorInfoFromCanvas.ts +0 -52
  65. package/src/utils/runFfmpeg.test.ts +0 -102
  66. package/src/utils/runFfmpeg.ts +0 -136
  67. package/src/utils/shaderTransitions.test.ts +0 -738
  68. package/src/utils/shaderTransitions.ts +0 -1130
  69. package/src/utils/uint16-alignment-audit.test.ts +0 -125
  70. package/src/utils/urlDownloader.test.ts +0 -65
  71. package/src/utils/urlDownloader.ts +0 -143
  72. package/tsconfig.json +0 -19
  73. package/vitest.config.ts +0 -7
@@ -1,1349 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { deflateSync } from "zlib";
3
- import {
4
- decodePng,
5
- decodePngToRgb48le,
6
- blitRgba8OverRgb48le,
7
- blitRgb48leRegion,
8
- blitRgb48leAffine,
9
- parseTransformMatrix,
10
- roundedRectAlpha,
11
- resampleRgb48leObjectFit,
12
- normalizeObjectFit,
13
- } from "./alphaBlit.js";
14
-
15
- // ── PNG construction helpers ─────────────────────────────────────────────────
16
-
17
- function uint32BE(n: number): Buffer {
18
- const b = Buffer.allocUnsafe(4);
19
- b.writeUInt32BE(n, 0);
20
- return b;
21
- }
22
-
23
- function crc32(data: Buffer): number {
24
- let crc = 0xffffffff;
25
- const table = crc32Table();
26
- for (let i = 0; i < data.length; i++) {
27
- crc = (table[(crc ^ (data[i] ?? 0)) & 0xff] ?? 0) ^ (crc >>> 8);
28
- }
29
- return (crc ^ 0xffffffff) >>> 0;
30
- }
31
-
32
- let _crcTable: Uint32Array | undefined;
33
- function crc32Table(): Uint32Array {
34
- if (_crcTable) return _crcTable;
35
- const t = new Uint32Array(256);
36
- for (let i = 0; i < 256; i++) {
37
- let c = i;
38
- for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
39
- t[i] = c;
40
- }
41
- _crcTable = t;
42
- return t;
43
- }
44
-
45
- function makeChunk(type: string, data: Buffer): Buffer {
46
- const typeBuffer = Buffer.from(type, "ascii");
47
- const crcInput = Buffer.concat([typeBuffer, data]);
48
- const crcBuf = uint32BE(crc32(crcInput));
49
- return Buffer.concat([uint32BE(data.length), typeBuffer, data, crcBuf]);
50
- }
51
-
52
- const PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
53
-
54
- /**
55
- * Build a minimal RGBA PNG for testing.
56
- * pixels: flat RGBA array (row-major, 8-bit per channel)
57
- */
58
- function makePng(width: number, height: number, pixels: number[]): Buffer {
59
- // IHDR
60
- const ihdr = Buffer.allocUnsafe(13);
61
- ihdr.writeUInt32BE(width, 0);
62
- ihdr.writeUInt32BE(height, 4);
63
- ihdr[8] = 8; // bit depth
64
- ihdr[9] = 6; // color type RGBA
65
- ihdr[10] = 0; // compression
66
- ihdr[11] = 0; // filter method
67
- ihdr[12] = 0; // interlace none
68
-
69
- // Raw scanlines with filter byte 0 (None)
70
- const scanlines: number[] = [];
71
- for (let y = 0; y < height; y++) {
72
- scanlines.push(0); // filter type None
73
- for (let x = 0; x < width; x++) {
74
- const i = (y * width + x) * 4;
75
- scanlines.push(pixels[i] ?? 0, pixels[i + 1] ?? 0, pixels[i + 2] ?? 0, pixels[i + 3] ?? 0);
76
- }
77
- }
78
-
79
- const idatData = deflateSync(Buffer.from(scanlines));
80
-
81
- return Buffer.concat([
82
- PNG_SIG,
83
- makeChunk("IHDR", ihdr),
84
- makeChunk("IDAT", idatData),
85
- makeChunk("IEND", Buffer.alloc(0)),
86
- ]);
87
- }
88
-
89
- // ── decodePng tests ──────────────────────────────────────────────────────────
90
-
91
- describe("decodePng", () => {
92
- it("decodes a 1x1 RGBA PNG correctly", () => {
93
- // RGBA: red pixel, full opacity
94
- const png = makePng(1, 1, [255, 0, 0, 255]);
95
- const { width, height, data } = decodePng(png);
96
- expect(width).toBe(1);
97
- expect(height).toBe(1);
98
- expect(data[0]).toBe(255); // R
99
- expect(data[1]).toBe(0); // G
100
- expect(data[2]).toBe(0); // B
101
- expect(data[3]).toBe(255); // A
102
- });
103
-
104
- it("decodes a 2x2 RGBA PNG with multiple pixels", () => {
105
- // TL=red, TR=green, BL=blue, BR=white (all full opacity)
106
- const pixels = [
107
- 255,
108
- 0,
109
- 0,
110
- 255, // TL red
111
- 0,
112
- 255,
113
- 0,
114
- 255, // TR green
115
- 0,
116
- 0,
117
- 255,
118
- 255, // BL blue
119
- 255,
120
- 255,
121
- 255,
122
- 255, // BR white
123
- ];
124
- const png = makePng(2, 2, pixels);
125
- const { width, height, data } = decodePng(png);
126
- expect(width).toBe(2);
127
- expect(height).toBe(2);
128
-
129
- // Top-left: red
130
- expect(data[0]).toBe(255);
131
- expect(data[1]).toBe(0);
132
- expect(data[2]).toBe(0);
133
- expect(data[3]).toBe(255);
134
-
135
- // Bottom-right: white
136
- expect(data[12]).toBe(255);
137
- expect(data[13]).toBe(255);
138
- expect(data[14]).toBe(255);
139
- expect(data[15]).toBe(255);
140
- });
141
-
142
- it("decodes a transparent pixel correctly", () => {
143
- const png = makePng(1, 1, [128, 64, 32, 0]);
144
- const { data } = decodePng(png);
145
- expect(data[3]).toBe(0); // alpha = 0
146
- });
147
-
148
- it("decodes a semi-transparent pixel correctly", () => {
149
- const png = makePng(1, 1, [100, 150, 200, 128]);
150
- const { data } = decodePng(png);
151
- expect(data[0]).toBe(100);
152
- expect(data[1]).toBe(150);
153
- expect(data[2]).toBe(200);
154
- expect(data[3]).toBe(128);
155
- });
156
-
157
- it("throws on invalid PNG signature", () => {
158
- const buf = Buffer.from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
159
- expect(() => decodePng(buf)).toThrow("not a PNG file");
160
- });
161
- });
162
-
163
- // ── PNG filter coverage ─────────────────────────────────────────────────────
164
- //
165
- // `makePng` only exercises filter type 0 (None). libpng (and Chrome) pick
166
- // other filter types heuristically; these tests build raw IDAT bytes with each
167
- // filter type so the defilter logic gets actual coverage.
168
-
169
- const paethRef = (a: number, b: number, c: number): number => {
170
- const p = a + b - c;
171
- const pa = Math.abs(p - a);
172
- const pb = Math.abs(p - b);
173
- const pc = Math.abs(p - c);
174
- if (pa <= pb && pa <= pc) return a;
175
- if (pb <= pc) return b;
176
- return c;
177
- };
178
-
179
- /**
180
- * Build a PNG with a specific filter type applied to every row. Encodes a
181
- * 3×2 RGBA image with unique per-channel values so any cross-channel mistake
182
- * in the defilter loop shows up as an assertion failure.
183
- *
184
- * @param filterType 0=None, 1=Sub, 2=Up, 3=Average, 4=Paeth
185
- */
186
- function makePngWithFilter(filterType: 0 | 1 | 2 | 3 | 4): {
187
- png: Buffer;
188
- expectedPixels: number[];
189
- } {
190
- const width = 3;
191
- const height = 2;
192
- const bpp = 4; // RGBA, 8-bit
193
- const stride = width * bpp;
194
-
195
- // Unique pixels so any defilter bug is observable
196
- const expectedPixels = [
197
- 10, 20, 30, 255, 50, 60, 70, 255, 90, 100, 110, 255, 130, 140, 150, 255, 170, 180, 190, 255,
198
- 210, 220, 230, 255,
199
- ];
200
-
201
- const filtered: number[] = [];
202
- const prev = new Uint8Array(stride);
203
- for (let y = 0; y < height; y++) {
204
- filtered.push(filterType);
205
- const rowStart = y * stride;
206
- const curr = new Uint8Array(stride);
207
- for (let x = 0; x < stride; x++) curr[x] = expectedPixels[rowStart + x] ?? 0;
208
-
209
- const out = new Uint8Array(stride);
210
- for (let x = 0; x < stride; x++) {
211
- const a = x >= bpp ? (curr[x - bpp] ?? 0) : 0;
212
- const b = prev[x] ?? 0;
213
- const c = x >= bpp ? (prev[x - bpp] ?? 0) : 0;
214
- const cv = curr[x] ?? 0;
215
- switch (filterType) {
216
- case 0:
217
- out[x] = cv;
218
- break;
219
- case 1:
220
- out[x] = (cv - a) & 0xff;
221
- break;
222
- case 2:
223
- out[x] = (cv - b) & 0xff;
224
- break;
225
- case 3:
226
- out[x] = (cv - Math.floor((a + b) / 2)) & 0xff;
227
- break;
228
- case 4:
229
- out[x] = (cv - paethRef(a, b, c)) & 0xff;
230
- break;
231
- }
232
- }
233
- for (let x = 0; x < stride; x++) filtered.push(out[x] ?? 0);
234
- prev.set(curr);
235
- }
236
-
237
- const ihdr = Buffer.allocUnsafe(13);
238
- ihdr.writeUInt32BE(width, 0);
239
- ihdr.writeUInt32BE(height, 4);
240
- ihdr[8] = 8;
241
- ihdr[9] = 6;
242
- ihdr[10] = 0;
243
- ihdr[11] = 0;
244
- ihdr[12] = 0;
245
-
246
- const idat = deflateSync(Buffer.from(filtered));
247
-
248
- return {
249
- png: Buffer.concat([
250
- PNG_SIG,
251
- makeChunk("IHDR", ihdr),
252
- makeChunk("IDAT", idat),
253
- makeChunk("IEND", Buffer.alloc(0)),
254
- ]),
255
- expectedPixels,
256
- };
257
- }
258
-
259
- describe("decodePng filter coverage", () => {
260
- it.each([
261
- [0, "None"],
262
- [1, "Sub"],
263
- [2, "Up"],
264
- [3, "Average"],
265
- [4, "Paeth"],
266
- ] as const)("round-trips a 3×2 PNG with filter type %d (%s)", (filterType) => {
267
- const { png, expectedPixels } = makePngWithFilter(filterType);
268
- const { width, height, data } = decodePng(png);
269
- expect(width).toBe(3);
270
- expect(height).toBe(2);
271
- for (let i = 0; i < expectedPixels.length; i++) {
272
- expect(data[i]).toBe(expectedPixels[i]);
273
- }
274
- });
275
-
276
- it("decodes a PNG split across multiple IDAT chunks", () => {
277
- // Build a normal single-IDAT PNG, then split its IDAT payload in half.
278
- // Chrome routinely emits multi-chunk IDATs (default ~8KB segment size).
279
- const { png: singleIdatPng, expectedPixels } = makePngWithFilter(0);
280
-
281
- // Walk chunks to find IDAT
282
- let pos = 8;
283
- let ihdrChunk: Buffer | null = null;
284
- let idatPayload: Buffer | null = null;
285
- while (pos + 12 <= singleIdatPng.length) {
286
- const len = singleIdatPng.readUInt32BE(pos);
287
- const type = singleIdatPng.toString("ascii", pos + 4, pos + 8);
288
- const data = singleIdatPng.subarray(pos + 8, pos + 8 + len);
289
- const fullChunk = singleIdatPng.subarray(pos, pos + 12 + len);
290
- if (type === "IHDR") ihdrChunk = Buffer.from(fullChunk);
291
- if (type === "IDAT") idatPayload = Buffer.from(data);
292
- if (type === "IEND") break;
293
- pos += 12 + len;
294
- }
295
- expect(ihdrChunk).not.toBeNull();
296
- expect(idatPayload).not.toBeNull();
297
- if (!ihdrChunk || !idatPayload) return;
298
-
299
- // Split the IDAT payload roughly in half across two IDAT chunks
300
- const split = Math.floor(idatPayload.length / 2);
301
- const part1 = idatPayload.subarray(0, split);
302
- const part2 = idatPayload.subarray(split);
303
-
304
- const multiIdatPng = Buffer.concat([
305
- PNG_SIG,
306
- ihdrChunk,
307
- makeChunk("IDAT", Buffer.from(part1)),
308
- makeChunk("IDAT", Buffer.from(part2)),
309
- makeChunk("IEND", Buffer.alloc(0)),
310
- ]);
311
-
312
- const { data } = decodePng(multiIdatPng);
313
- for (let i = 0; i < expectedPixels.length; i++) {
314
- expect(data[i]).toBe(expectedPixels[i]);
315
- }
316
- });
317
-
318
- it("throws on Adam7-interlaced PNGs", () => {
319
- const ihdr = Buffer.allocUnsafe(13);
320
- ihdr.writeUInt32BE(1, 0);
321
- ihdr.writeUInt32BE(1, 4);
322
- ihdr[8] = 8;
323
- ihdr[9] = 6;
324
- ihdr[10] = 0;
325
- ihdr[11] = 0;
326
- ihdr[12] = 1; // Adam7 interlace
327
- const idat = deflateSync(Buffer.from([0, 0, 0, 0, 255]));
328
- const png = Buffer.concat([
329
- PNG_SIG,
330
- makeChunk("IHDR", ihdr),
331
- makeChunk("IDAT", idat),
332
- makeChunk("IEND", Buffer.alloc(0)),
333
- ]);
334
- expect(() => decodePng(png)).toThrow("interlace");
335
- });
336
-
337
- it("throws on PNGs missing the IHDR chunk", () => {
338
- const idat = deflateSync(Buffer.from([0, 0, 0, 0, 255]));
339
- const png = Buffer.concat([
340
- PNG_SIG,
341
- makeChunk("IDAT", idat),
342
- makeChunk("IEND", Buffer.alloc(0)),
343
- ]);
344
- expect(() => decodePng(png)).toThrow("IHDR");
345
- });
346
- });
347
-
348
- // ── decodePngToRgb48le tests ────────────────────────────────────────────────
349
- //
350
- // FFmpeg emits 16-bit RGB PNGs (big-endian on the wire). The decoder swaps to
351
- // little-endian for the streaming HDR encoder. These tests cover the byte-order
352
- // swap, precision preservation, and multi-pixel row-major layout that the
353
- // 8-bit suite cannot exercise.
354
-
355
- /**
356
- * Build a 16-bit RGB PNG (colorType 2, bitDepth 16). PNG stores each 16-bit
357
- * sample as two big-endian bytes; the decoder must swap them to LE.
358
- *
359
- * @param pixels Flat array of [r16, g16, b16, r16, g16, b16, ...] values
360
- * (one entry per channel sample, 0–65535).
361
- */
362
- function makePng16(width: number, height: number, pixels: number[]): Buffer {
363
- const ihdr = Buffer.allocUnsafe(13);
364
- ihdr.writeUInt32BE(width, 0);
365
- ihdr.writeUInt32BE(height, 4);
366
- ihdr[8] = 16; // bit depth
367
- ihdr[9] = 2; // color type RGB
368
- ihdr[10] = 0;
369
- ihdr[11] = 0;
370
- ihdr[12] = 0;
371
-
372
- const stride = width * 6; // 3 channels × 2 bytes
373
- const filtered: number[] = [];
374
- for (let y = 0; y < height; y++) {
375
- filtered.push(0); // filter type None
376
- for (let x = 0; x < width; x++) {
377
- const baseSample = (y * width + x) * 3;
378
- for (let ch = 0; ch < 3; ch++) {
379
- const v = pixels[baseSample + ch] ?? 0;
380
- filtered.push((v >> 8) & 0xff); // high byte (BE on wire)
381
- filtered.push(v & 0xff); // low byte
382
- }
383
- }
384
- void stride;
385
- }
386
-
387
- const idat = deflateSync(Buffer.from(filtered));
388
- return Buffer.concat([
389
- PNG_SIG,
390
- makeChunk("IHDR", ihdr),
391
- makeChunk("IDAT", idat),
392
- makeChunk("IEND", Buffer.alloc(0)),
393
- ]);
394
- }
395
-
396
- describe("decodePngToRgb48le", () => {
397
- it("swaps PNG big-endian samples to little-endian rgb48le", () => {
398
- // Pick a value where high and low bytes differ so a missed swap is observable
399
- const v = 0x1234;
400
- const png = makePng16(1, 1, [v, v, v]);
401
- const { width, height, data } = decodePngToRgb48le(png);
402
- expect(width).toBe(1);
403
- expect(height).toBe(1);
404
- expect(data.length).toBe(6);
405
- expect(data.readUInt16LE(0)).toBe(v);
406
- expect(data.readUInt16LE(2)).toBe(v);
407
- expect(data.readUInt16LE(4)).toBe(v);
408
- // Spot-check raw byte order: low byte first, then high
409
- expect(data[0]).toBe(0x34);
410
- expect(data[1]).toBe(0x12);
411
- });
412
-
413
- it("preserves full 16-bit precision (no 8-bit truncation)", () => {
414
- // A value whose low byte alone would be misleading — proves both bytes survive
415
- const r = 0xabcd;
416
- const g = 0xfedc;
417
- const b = 0x0102;
418
- const png = makePng16(1, 1, [r, g, b]);
419
- const { data } = decodePngToRgb48le(png);
420
- expect(data.readUInt16LE(0)).toBe(r);
421
- expect(data.readUInt16LE(2)).toBe(g);
422
- expect(data.readUInt16LE(4)).toBe(b);
423
- });
424
-
425
- it("decodes a 2×2 image with row-major layout", () => {
426
- const pixels = [
427
- // row 0
428
- 1000, 2000, 3000, 4000, 5000, 6000,
429
- // row 1
430
- 7000, 8000, 9000, 10000, 11000, 12000,
431
- ];
432
- const png = makePng16(2, 2, pixels);
433
- const { width, height, data } = decodePngToRgb48le(png);
434
- expect(width).toBe(2);
435
- expect(height).toBe(2);
436
- expect(data.length).toBe(2 * 2 * 6);
437
-
438
- for (let i = 0; i < 4; i++) {
439
- expect(data.readUInt16LE(i * 6 + 0)).toBe(pixels[i * 3 + 0]);
440
- expect(data.readUInt16LE(i * 6 + 2)).toBe(pixels[i * 3 + 1]);
441
- expect(data.readUInt16LE(i * 6 + 4)).toBe(pixels[i * 3 + 2]);
442
- }
443
- });
444
-
445
- it("rejects 8-bit PNGs with a clear error", () => {
446
- const png = makePng(1, 1, [255, 0, 0, 255]); // 8-bit RGBA
447
- expect(() => decodePngToRgb48le(png)).toThrow(/bit depth/);
448
- });
449
- });
450
-
451
- // ── blitRgba8OverRgb48le tests ───────────────────────────────────────────────
452
-
453
- /** Build an rgb48le buffer with a single solid color (16-bit per channel) */
454
- function makeHdrFrame(
455
- width: number,
456
- height: number,
457
- r16: number,
458
- g16: number,
459
- b16: number,
460
- ): Buffer {
461
- const buf = Buffer.allocUnsafe(width * height * 6);
462
- for (let i = 0; i < width * height; i++) {
463
- buf.writeUInt16LE(r16, i * 6);
464
- buf.writeUInt16LE(g16, i * 6 + 2);
465
- buf.writeUInt16LE(b16, i * 6 + 4);
466
- }
467
- return buf;
468
- }
469
-
470
- /** Build a raw RGBA array (Uint8Array) with a single solid color */
471
- function makeDomRgba(
472
- width: number,
473
- height: number,
474
- r: number,
475
- g: number,
476
- b: number,
477
- a: number,
478
- ): Uint8Array {
479
- const arr = new Uint8Array(width * height * 4);
480
- for (let i = 0; i < width * height; i++) {
481
- arr[i * 4 + 0] = r;
482
- arr[i * 4 + 1] = g;
483
- arr[i * 4 + 2] = b;
484
- arr[i * 4 + 3] = a;
485
- }
486
- return arr;
487
- }
488
-
489
- describe("blitRgba8OverRgb48le", () => {
490
- it("fully transparent DOM: canvas unchanged", () => {
491
- const canvas = makeHdrFrame(1, 1, 32000, 40000, 50000);
492
- const dom = makeDomRgba(1, 1, 255, 0, 0, 0); // red but alpha=0
493
- blitRgba8OverRgb48le(dom, canvas, 1, 1);
494
-
495
- expect(canvas.readUInt16LE(0)).toBe(32000);
496
- expect(canvas.readUInt16LE(2)).toBe(40000);
497
- expect(canvas.readUInt16LE(4)).toBe(50000);
498
- });
499
-
500
- it("fully opaque DOM: sRGB→HLG converted values overwrite canvas", () => {
501
- const canvas = makeHdrFrame(1, 1, 10000, 20000, 30000);
502
- const dom = makeDomRgba(1, 1, 255, 128, 0, 255); // R=255, G=128, B=0, full opaque
503
- blitRgba8OverRgb48le(dom, canvas, 1, 1);
504
-
505
- // sRGB 255 → HLG 65535 (white maps to white)
506
- // sRGB 128 → HLG ~46484 (mid-gray maps higher due to HLG OETF)
507
- // sRGB 0 → HLG 0
508
- expect(canvas.readUInt16LE(0)).toBe(65535);
509
- expect(canvas.readUInt16LE(2)).toBeGreaterThan(40000); // HLG mid-gray > sRGB mid-gray
510
- expect(canvas.readUInt16LE(2)).toBeLessThan(50000);
511
- expect(canvas.readUInt16LE(4)).toBe(0);
512
- });
513
-
514
- it("fully opaque DOM with srgb transfer expands 8-bit channels to 16-bit SDR", () => {
515
- const canvas = makeHdrFrame(1, 1, 10000, 20000, 30000);
516
- const dom = makeDomRgba(1, 1, 255, 128, 1, 255);
517
- blitRgba8OverRgb48le(dom, canvas, 1, 1, "srgb");
518
-
519
- expect(canvas.readUInt16LE(0)).toBe(65535);
520
- expect(canvas.readUInt16LE(2)).toBe(128 * 257);
521
- expect(canvas.readUInt16LE(4)).toBe(257);
522
- });
523
-
524
- it("sRGB→HLG: black stays black, white stays white", () => {
525
- const canvasBlack = makeHdrFrame(1, 1, 0, 0, 0);
526
- const domBlack = makeDomRgba(1, 1, 0, 0, 0, 255);
527
- blitRgba8OverRgb48le(domBlack, canvasBlack, 1, 1);
528
- expect(canvasBlack.readUInt16LE(0)).toBe(0);
529
-
530
- const canvasWhite = makeHdrFrame(1, 1, 0, 0, 0);
531
- const domWhite = makeDomRgba(1, 1, 255, 255, 255, 255);
532
- blitRgba8OverRgb48le(domWhite, canvasWhite, 1, 1);
533
- expect(canvasWhite.readUInt16LE(0)).toBe(65535);
534
- });
535
-
536
- it("50% alpha: HLG-converted DOM blended with canvas", () => {
537
- // DOM: white (255, 255, 255) at alpha=128 (~50%)
538
- // Canvas: black (0, 0, 0)
539
- const canvas = makeHdrFrame(1, 1, 0, 0, 0);
540
- const dom = makeDomRgba(1, 1, 255, 255, 255, 128);
541
- blitRgba8OverRgb48le(dom, canvas, 1, 1);
542
-
543
- // sRGB 255 → HLG 65535, blended 50/50 with black
544
- const alpha = 128 / 255;
545
- const expectedR = Math.round(65535 * alpha);
546
- expect(canvas.readUInt16LE(0)).toBeCloseTo(expectedR, -1);
547
- });
548
-
549
- it("50% alpha blends with non-zero canvas", () => {
550
- // DOM: 8-bit red=200, canvas: 16-bit red=32000, alpha=128
551
- const canvas = makeHdrFrame(1, 1, 32000, 0, 0);
552
- const dom = makeDomRgba(1, 1, 200, 0, 0, 128);
553
- blitRgba8OverRgb48le(dom, canvas, 1, 1);
554
-
555
- // sRGB 200 → HLG value, blended ~50/50 with canvas red=32000
556
- // Result should be higher than 32000 (pulled up by the HLG-converted DOM value)
557
- expect(canvas.readUInt16LE(0)).toBeGreaterThan(32000);
558
- });
559
-
560
- it("α=254 still blends (no fast-path overwrite at the opaque boundary)", () => {
561
- // Reviewer feedback: confirm the alpha branch is taken for any α < 255.
562
- // α=254 should *almost* match α=255 but still leave a sliver of the canvas
563
- // value visible — proving we didn't accidentally fast-path α >= 254.
564
- const canvasOpaque = makeHdrFrame(1, 1, 0, 0, 0);
565
- const domOpaque = makeDomRgba(1, 1, 255, 255, 255, 255);
566
- blitRgba8OverRgb48le(domOpaque, canvasOpaque, 1, 1);
567
- const opaqueR = canvasOpaque.readUInt16LE(0);
568
-
569
- const canvasNear = makeHdrFrame(1, 1, 1000, 1000, 1000);
570
- const domNear = makeDomRgba(1, 1, 255, 255, 255, 254);
571
- blitRgba8OverRgb48le(domNear, canvasNear, 1, 1);
572
- const nearR = canvasNear.readUInt16LE(0);
573
-
574
- // α=255 over black gave us the pure HLG-of-white value
575
- expect(opaqueR).toBe(65535);
576
- // α=254 over (1000, 1000, 1000) must be *strictly less* than α=255 over black —
577
- // if the implementation short-circuits at α >= 254 it would also return 65535.
578
- expect(nearR).toBeLessThan(opaqueR);
579
- // …but it should still be very close (within ~1% of full white)
580
- expect(nearR).toBeGreaterThan(64000);
581
- });
582
-
583
- it("handles a 2x2 frame correctly pixel-by-pixel", () => {
584
- const canvas = makeHdrFrame(2, 2, 0, 0, 0);
585
- // First pixel: fully opaque white. Others: fully transparent.
586
- const dom = new Uint8Array(2 * 2 * 4);
587
- dom[0] = 255;
588
- dom[1] = 255;
589
- dom[2] = 255;
590
- dom[3] = 255; // pixel 0: opaque white
591
- // pixels 1-3: alpha=0 (transparent)
592
-
593
- blitRgba8OverRgb48le(dom, canvas, 2, 2);
594
-
595
- // Pixel 0: sRGB white → HLG white (65535)
596
- expect(canvas.readUInt16LE(0)).toBe(65535);
597
- expect(canvas.readUInt16LE(2)).toBe(65535);
598
- expect(canvas.readUInt16LE(4)).toBe(65535);
599
-
600
- // Pixel 1: transparent DOM → canvas black (0, 0, 0) unchanged
601
- expect(canvas.readUInt16LE(6)).toBe(0);
602
- expect(canvas.readUInt16LE(8)).toBe(0);
603
- expect(canvas.readUInt16LE(10)).toBe(0);
604
- });
605
- });
606
-
607
- describe("blitRgba8OverRgb48le with PQ transfer", () => {
608
- it("PQ: black stays black, white maps to PQ white", () => {
609
- const canvasBlack = makeHdrFrame(1, 1, 0, 0, 0);
610
- const domBlack = makeDomRgba(1, 1, 0, 0, 0, 255);
611
- blitRgba8OverRgb48le(domBlack, canvasBlack, 1, 1, "pq");
612
- expect(canvasBlack.readUInt16LE(0)).toBe(0);
613
-
614
- const canvasWhite = makeHdrFrame(1, 1, 0, 0, 0);
615
- const domWhite = makeDomRgba(1, 1, 255, 255, 255, 255);
616
- blitRgba8OverRgb48le(domWhite, canvasWhite, 1, 1, "pq");
617
- // PQ white at SDR 203 nits is NOT 65535 (that's 10000 nits)
618
- // SDR white in PQ ≈ 58% signal → ~38000
619
- const pqWhite = canvasWhite.readUInt16LE(0);
620
- expect(pqWhite).toBeGreaterThan(30000);
621
- expect(pqWhite).toBeLessThan(45000);
622
- });
623
-
624
- it("PQ mid-gray differs from HLG mid-gray", () => {
625
- const canvasHlg = makeHdrFrame(1, 1, 0, 0, 0);
626
- const canvasPq = makeHdrFrame(1, 1, 0, 0, 0);
627
- const dom = makeDomRgba(1, 1, 128, 128, 128, 255);
628
-
629
- blitRgba8OverRgb48le(dom, canvasHlg, 1, 1, "hlg");
630
- blitRgba8OverRgb48le(dom, canvasPq, 1, 1, "pq");
631
-
632
- const hlgVal = canvasHlg.readUInt16LE(0);
633
- const pqVal = canvasPq.readUInt16LE(0);
634
- // PQ and HLG encode mid-gray differently
635
- expect(hlgVal).not.toBe(pqVal);
636
- // Both should be non-zero
637
- expect(hlgVal).toBeGreaterThan(0);
638
- expect(pqVal).toBeGreaterThan(0);
639
- });
640
- });
641
-
642
- // ── sRGB → BT.2020 reference values (locks down the per-channel LUT) ─────────
643
- //
644
- // Probes computed by mirroring buildSrgbToHdrLut() (sRGB EOTF → linear → HDR
645
- // OETF → 16-bit). Values are byte-exact integers — any drift in the EOTF/OETF
646
- // math (constant changes, branch swaps, rounding-mode regressions) is caught
647
- // immediately, on the matrix-free fast path through blitRgba8OverRgb48le where
648
- // every DOM pixel goes through getSrgbToHdrLut().
649
- //
650
- // Two key invariants the table enforces:
651
- //
652
- // 1. HLG: sRGB 255 → 65535 (white maps to white in HLG signal space).
653
- //
654
- // 2. PQ: sRGB 255 → 38055 (≪ 65535). NOT a bug — SDR white is placed at
655
- // 203 nits per BT.2408, normalized against PQ's 10000-nit peak. This is
656
- // what lets HDR highlights live above SDR-reference-white in a PQ frame.
657
- // Never "fix" PQ to map sRGB 255 → 65535.
658
- //
659
- // To regenerate after an *intentional* LUT change (transfer-function constant,
660
- // BT.709→BT.2020 matrix tuning, SDR-white nit reference, OOTF), run:
661
- //
662
- // python3 packages/engine/scripts/generate-lut-reference.py --probes
663
- //
664
- // and paste the output over the SRGB_TO_HDR_REFERENCE literal below. Update
665
- // the script's mirrored OETF/EOTF constants in lockstep with alphaBlit.ts so
666
- // the generator stays the source of truth.
667
-
668
- interface SrgbHdrProbe {
669
- srgb: number;
670
- hlg: number;
671
- pq: number;
672
- }
673
-
674
- const SRGB_TO_HDR_REFERENCE: readonly SrgbHdrProbe[] = [
675
- { srgb: 0, hlg: 0, pq: 0 },
676
- { srgb: 1, hlg: 1978, pq: 3315 },
677
- { srgb: 10, hlg: 6254, pq: 8300 },
678
- { srgb: 32, hlg: 13642, pq: 13884 },
679
- { srgb: 64, hlg: 25702, pq: 19848 },
680
- { srgb: 96, hlg: 38011, pq: 24379 },
681
- { srgb: 128, hlg: 46484, pq: 28037 },
682
- { srgb: 160, hlg: 52745, pq: 31104 },
683
- { srgb: 192, hlg: 57772, pq: 33743 },
684
- { srgb: 224, hlg: 61994, pq: 36057 },
685
- { srgb: 254, hlg: 65428, pq: 37994 },
686
- { srgb: 255, hlg: 65535, pq: 38055 },
687
- ];
688
-
689
- describe("blitRgba8OverRgb48le: sRGB → BT.2020 reference values", () => {
690
- it.each(SRGB_TO_HDR_REFERENCE)(
691
- "sRGB $srgb → HLG $hlg, PQ $pq (grayscale, opaque)",
692
- ({ srgb, hlg, pq }) => {
693
- const canvasHlg = makeHdrFrame(1, 1, 0, 0, 0);
694
- const domHlg = makeDomRgba(1, 1, srgb, srgb, srgb, 255);
695
- blitRgba8OverRgb48le(domHlg, canvasHlg, 1, 1, "hlg");
696
- // All three channels should hit the same LUT slot.
697
- expect(canvasHlg.readUInt16LE(0)).toBe(hlg);
698
- expect(canvasHlg.readUInt16LE(2)).toBe(hlg);
699
- expect(canvasHlg.readUInt16LE(4)).toBe(hlg);
700
-
701
- const canvasPq = makeHdrFrame(1, 1, 0, 0, 0);
702
- const domPq = makeDomRgba(1, 1, srgb, srgb, srgb, 255);
703
- blitRgba8OverRgb48le(domPq, canvasPq, 1, 1, "pq");
704
- expect(canvasPq.readUInt16LE(0)).toBe(pq);
705
- expect(canvasPq.readUInt16LE(2)).toBe(pq);
706
- expect(canvasPq.readUInt16LE(4)).toBe(pq);
707
- },
708
- );
709
-
710
- it("HLG: asymmetric R/G/B maps each channel independently through the LUT", () => {
711
- // R=64, G=128, B=192 → independent LUT lookups per channel.
712
- const canvas = makeHdrFrame(1, 1, 0, 0, 0);
713
- const dom = makeDomRgba(1, 1, 64, 128, 192, 255);
714
- blitRgba8OverRgb48le(dom, canvas, 1, 1, "hlg");
715
- expect(canvas.readUInt16LE(0)).toBe(25702);
716
- expect(canvas.readUInt16LE(2)).toBe(46484);
717
- expect(canvas.readUInt16LE(4)).toBe(57772);
718
- });
719
-
720
- it("PQ: asymmetric R/G/B maps each channel independently through the LUT", () => {
721
- const canvas = makeHdrFrame(1, 1, 0, 0, 0);
722
- const dom = makeDomRgba(1, 1, 64, 128, 192, 255);
723
- blitRgba8OverRgb48le(dom, canvas, 1, 1, "pq");
724
- expect(canvas.readUInt16LE(0)).toBe(19848);
725
- expect(canvas.readUInt16LE(2)).toBe(28037);
726
- expect(canvas.readUInt16LE(4)).toBe(33743);
727
- });
728
-
729
- it("PQ caps SDR-reference-white well below HLG signal peak (BT.2408 invariant)", () => {
730
- // sRGB 255 (SDR white) → HLG 65535 (top of HLG signal range)
731
- // → PQ 38055 (~58% of PQ signal, ~203 nits)
732
- // The gap is what lets PQ carry HDR highlights above SDR reference level.
733
- // Locking the exact PQ value here prevents a future "fix" that would
734
- // re-scale PQ to peak-at-SDR-white (which would clip every real HDR pixel).
735
- const canvasHlg = makeHdrFrame(1, 1, 0, 0, 0);
736
- const canvasPq = makeHdrFrame(1, 1, 0, 0, 0);
737
- const dom = makeDomRgba(1, 1, 255, 255, 255, 255);
738
-
739
- blitRgba8OverRgb48le(dom, canvasHlg, 1, 1, "hlg");
740
- blitRgba8OverRgb48le(dom, canvasPq, 1, 1, "pq");
741
-
742
- expect(canvasHlg.readUInt16LE(0)).toBe(65535);
743
- expect(canvasPq.readUInt16LE(0)).toBe(38055);
744
- expect(canvasPq.readUInt16LE(0)).toBeLessThan(canvasHlg.readUInt16LE(0));
745
- });
746
- });
747
-
748
- // ── blitRgb48leRegion tests ──────────────────────────────────────────────────
749
-
750
- describe("blitRgb48leRegion", () => {
751
- it("copies a region at position (0,0) — full overlap", () => {
752
- const canvas = Buffer.alloc(4 * 4 * 6); // 4x4 black
753
- const source = makeHdrFrame(2, 2, 10000, 20000, 30000);
754
- blitRgb48leRegion(canvas, source, 0, 0, 2, 2, 4, 4);
755
- expect(canvas.readUInt16LE(0)).toBe(10000);
756
- expect(canvas.readUInt16LE(2)).toBe(20000);
757
- expect(canvas.readUInt16LE(4)).toBe(30000);
758
- expect(canvas.readUInt16LE(2 * 6)).toBe(0);
759
- });
760
-
761
- it("copies a region at offset position", () => {
762
- const canvas = Buffer.alloc(4 * 4 * 6);
763
- const source = makeHdrFrame(2, 2, 50000, 40000, 30000);
764
- blitRgb48leRegion(canvas, source, 1, 1, 2, 2, 4, 4);
765
- expect(canvas.readUInt16LE(0)).toBe(0);
766
- const off = (1 * 4 + 1) * 6;
767
- expect(canvas.readUInt16LE(off)).toBe(50000);
768
- });
769
-
770
- it("clips when region extends beyond canvas edge", () => {
771
- const canvas = Buffer.alloc(4 * 4 * 6);
772
- const source = makeHdrFrame(3, 3, 10000, 20000, 30000);
773
- blitRgb48leRegion(canvas, source, 2, 2, 3, 3, 4, 4);
774
- const off = (2 * 4 + 2) * 6;
775
- expect(canvas.readUInt16LE(off)).toBe(10000);
776
- const off2 = (3 * 4 + 3) * 6;
777
- expect(canvas.readUInt16LE(off2)).toBe(10000);
778
- expect(canvas.length).toBe(4 * 4 * 6);
779
- });
780
-
781
- it("applies opacity when provided", () => {
782
- const canvas = Buffer.alloc(1 * 1 * 6);
783
- const source = makeHdrFrame(1, 1, 40000, 40000, 40000);
784
- blitRgb48leRegion(canvas, source, 0, 0, 1, 1, 1, 1, 0.5);
785
- expect(canvas.readUInt16LE(0)).toBe(20000);
786
- });
787
-
788
- it("blends opacity over existing destination pixels", () => {
789
- const canvas = makeHdrFrame(2, 1, 10000, 20000, 30000);
790
- const source = makeHdrFrame(2, 1, 50000, 10000, 60000);
791
- blitRgb48leRegion(canvas, source, 0, 0, 2, 1, 2, 1, 0.25);
792
- expect(canvas.readUInt16LE(0)).toBe(20000);
793
- expect(canvas.readUInt16LE(2)).toBe(17500);
794
- expect(canvas.readUInt16LE(4)).toBe(37500);
795
- expect(canvas.readUInt16LE(6)).toBe(20000);
796
- });
797
-
798
- it("skips exact-zero opacity without mutating the destination", () => {
799
- const canvas = makeHdrFrame(1, 1, 10000, 20000, 30000);
800
- const source = makeHdrFrame(1, 1, 50000, 50000, 50000);
801
- blitRgb48leRegion(canvas, source, 0, 0, 1, 1, 1, 1, 0);
802
- expect(canvas.readUInt16LE(0)).toBe(10000);
803
- expect(canvas.readUInt16LE(2)).toBe(20000);
804
- expect(canvas.readUInt16LE(4)).toBe(30000);
805
- });
806
-
807
- it("no-op for zero-size region", () => {
808
- const canvas = Buffer.alloc(4 * 4 * 6);
809
- const source = makeHdrFrame(2, 2, 10000, 20000, 30000);
810
- blitRgb48leRegion(canvas, source, 0, 0, 0, 0, 4, 4);
811
- expect(canvas.readUInt16LE(0)).toBe(0);
812
- });
813
- });
814
-
815
- // ── parseTransformMatrix tests ───────────────────────────────────────────────
816
-
817
- describe("parseTransformMatrix", () => {
818
- it("returns null for 'none'", () => {
819
- expect(parseTransformMatrix("none")).toBeNull();
820
- });
821
-
822
- it("parses identity matrix", () => {
823
- const m = parseTransformMatrix("matrix(1, 0, 0, 1, 0, 0)");
824
- expect(m).toEqual([1, 0, 0, 1, 0, 0]);
825
- });
826
-
827
- it("parses scale + translate", () => {
828
- const m = parseTransformMatrix("matrix(0.85, 0, 0, 0.85, 100, 50)");
829
- expect(m).toEqual([0.85, 0, 0, 0.85, 100, 50]);
830
- });
831
-
832
- it("parses rotation (45 degrees)", () => {
833
- const cos = Math.cos(Math.PI / 4);
834
- const sin = Math.sin(Math.PI / 4);
835
- const m = parseTransformMatrix(`matrix(${cos}, ${sin}, ${-sin}, ${cos}, 0, 0)`);
836
- expect(m).not.toBeNull();
837
- if (!m) return;
838
- expect(m[0]).toBeCloseTo(cos, 10);
839
- expect(m[1]).toBeCloseTo(sin, 10);
840
- });
841
-
842
- it("parses negative values", () => {
843
- const m = parseTransformMatrix("matrix(-1, 0, 0, -1, -50, -100)");
844
- expect(m).toEqual([-1, 0, 0, -1, -50, -100]);
845
- });
846
-
847
- it("returns null for empty string", () => {
848
- expect(parseTransformMatrix("")).toBeNull();
849
- });
850
-
851
- it("parses identity matrix3d (GSAP force3D default)", () => {
852
- const m = parseTransformMatrix("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)");
853
- expect(m).toEqual([1, 0, 0, 1, 0, 0]);
854
- });
855
-
856
- it("parses translate3d matrix3d as 2D affine (drops Z translation)", () => {
857
- // translate3d(100px, 50px, 25px) — Z=25 must be dropped.
858
- const m = parseTransformMatrix("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 100, 50, 25, 1)");
859
- expect(m).toEqual([1, 0, 0, 1, 100, 50]);
860
- });
861
-
862
- it("parses scale + translate3d matrix3d (typical GSAP output)", () => {
863
- // scale(0.85) translate3d(100px, 50px, 0) emitted by GSAP with force3D: true.
864
- const m = parseTransformMatrix(
865
- "matrix3d(0.85, 0, 0, 0, 0, 0.85, 0, 0, 0, 0, 1, 0, 100, 50, 0, 1)",
866
- );
867
- expect(m).toEqual([0.85, 0, 0, 0.85, 100, 50]);
868
- });
869
-
870
- it("parses rotation matrix3d (rotateZ via force3D)", () => {
871
- // rotateZ(45deg) translate3d(0, 0, 0) — column-major.
872
- const cos = Math.cos(Math.PI / 4);
873
- const sin = Math.sin(Math.PI / 4);
874
- const m = parseTransformMatrix(
875
- `matrix3d(${cos}, ${sin}, 0, 0, ${-sin}, ${cos}, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)`,
876
- );
877
- expect(m).not.toBeNull();
878
- if (!m) return;
879
- expect(m[0]).toBeCloseTo(cos, 10);
880
- expect(m[1]).toBeCloseTo(sin, 10);
881
- expect(m[2]).toBeCloseTo(-sin, 10);
882
- expect(m[3]).toBeCloseTo(cos, 10);
883
- expect(m[4]).toBe(0);
884
- expect(m[5]).toBe(0);
885
- });
886
-
887
- it("returns null for malformed matrix3d (wrong arg count)", () => {
888
- expect(parseTransformMatrix("matrix3d(1, 0, 0, 0, 0, 1)")).toBeNull();
889
- });
890
-
891
- it("returns null for matrix3d with non-finite values", () => {
892
- expect(
893
- parseTransformMatrix("matrix3d(NaN, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)"),
894
- ).toBeNull();
895
- });
896
-
897
- it("warns once when matrix3d has Z-significant components (rotateY 45deg)", () => {
898
- // rotateY(45deg) — m31=-sin, m13=sin, m33=cos. Real 3D rotation around Y;
899
- // the engine projects to 2D and silently drops perspective. Author needs
900
- // to know the rendered output won't match the studio preview.
901
- const cos = Math.cos(Math.PI / 4);
902
- const sin = Math.sin(Math.PI / 4);
903
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
904
- const m = parseTransformMatrix(
905
- `matrix3d(${cos}, 0, ${-sin}, 0, 0, 1, 0, 0, ${sin}, 0, ${cos}, 0, 0, 0, 0, 1)`,
906
- );
907
- // Still returns the projected 2D affine — warning is non-blocking.
908
- expect(m).not.toBeNull();
909
- expect(m).toEqual([cos, 0, 0, 1, 0, 0]);
910
- // Module-level dedup means the warn either fired in this test (first
911
- // Z-significant call in the run) or earlier; either way the
912
- // user-facing observability contract holds. Assert it was called at
913
- // least once across the process.
914
- const totalCalls = warn.mock.calls.length;
915
- // Calling parseTransformMatrix again with another Z-significant matrix
916
- // must not produce additional warnings (dedup check).
917
- parseTransformMatrix("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 5, 0, 0, 0, 1)");
918
- expect(warn.mock.calls.length).toBe(totalCalls);
919
- warn.mockRestore();
920
- });
921
- });
922
-
923
- // ── blitRgb48leAffine tests ─────────────────────────────────────────────────
924
-
925
- describe("blitRgb48leAffine", () => {
926
- it("identity matrix produces same result as blitRgb48leRegion", () => {
927
- const canvas1 = Buffer.alloc(4 * 4 * 6);
928
- const canvas2 = Buffer.alloc(4 * 4 * 6);
929
- const source = makeHdrFrame(2, 2, 10000, 20000, 30000);
930
- const identity = [1, 0, 0, 1, 0, 0];
931
-
932
- blitRgb48leRegion(canvas1, source, 0, 0, 2, 2, 4, 4);
933
- blitRgb48leAffine(canvas2, source, identity, 2, 2, 4, 4);
934
-
935
- expect(Buffer.compare(canvas1, canvas2)).toBe(0);
936
- });
937
-
938
- it("translation moves pixels", () => {
939
- const canvas = Buffer.alloc(4 * 4 * 6);
940
- const source = makeHdrFrame(1, 1, 50000, 40000, 30000);
941
- const translate = [1, 0, 0, 1, 2, 1];
942
- blitRgb48leAffine(canvas, source, translate, 1, 1, 4, 4);
943
-
944
- expect(canvas.readUInt16LE(0)).toBe(0);
945
- const off = (1 * 4 + 2) * 6;
946
- expect(canvas.readUInt16LE(off)).toBe(50000);
947
- });
948
-
949
- it("scale down by 0.5 shrinks the output", () => {
950
- const canvas = Buffer.alloc(4 * 4 * 6);
951
- const source = makeHdrFrame(4, 4, 40000, 30000, 20000);
952
- const scale = [0.5, 0, 0, 0.5, 0, 0];
953
- blitRgb48leAffine(canvas, source, scale, 4, 4, 4, 4);
954
-
955
- expect(canvas.readUInt16LE(0)).toBeGreaterThan(0);
956
- expect(canvas.readUInt16LE((1 * 4 + 1) * 6)).toBeGreaterThan(0);
957
- expect(canvas.readUInt16LE(2 * 6)).toBe(0);
958
- });
959
-
960
- it("scale up by 2 enlarges the output", () => {
961
- const canvas = Buffer.alloc(4 * 4 * 6);
962
- const source = makeHdrFrame(2, 2, 40000, 30000, 20000);
963
- const scale = [2, 0, 0, 2, 0, 0];
964
- blitRgb48leAffine(canvas, source, scale, 2, 2, 4, 4);
965
-
966
- for (let i = 0; i < 16; i++) {
967
- expect(canvas.readUInt16LE(i * 6)).toBeGreaterThan(0);
968
- }
969
- });
970
-
971
- it("opacity blends with canvas", () => {
972
- const canvas = makeHdrFrame(1, 1, 20000, 20000, 20000);
973
- const source = makeHdrFrame(1, 1, 60000, 60000, 60000);
974
- const identity = [1, 0, 0, 1, 0, 0];
975
- blitRgb48leAffine(canvas, source, identity, 1, 1, 1, 1, 0.5);
976
-
977
- expect(canvas.readUInt16LE(0)).toBe(40000);
978
- });
979
-
980
- it("out-of-bounds source coordinates are clipped", () => {
981
- const canvas = Buffer.alloc(2 * 2 * 6);
982
- const source = makeHdrFrame(1, 1, 50000, 40000, 30000);
983
- const translate = [1, 0, 0, 1, 10, 10];
984
- blitRgb48leAffine(canvas, source, translate, 1, 1, 2, 2);
985
-
986
- expect(canvas.readUInt16LE(0)).toBe(0);
987
- expect(canvas.readUInt16LE(6)).toBe(0);
988
- });
989
- });
990
-
991
- // ── Round-trip test: decodePng → blitRgba8OverRgb48le ────────────────────────
992
-
993
- describe("decodePng + blitRgba8OverRgb48le integration", () => {
994
- it("transparent PNG overlay leaves canvas untouched", () => {
995
- const width = 2;
996
- const height = 2;
997
-
998
- // Build a fully transparent PNG
999
- const pixels = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; // all alpha=0
1000
- const png = makePng(width, height, pixels);
1001
- const { data: domRgba } = decodePng(png);
1002
-
1003
- // Canvas pre-filled with known HDR values
1004
- const canvas = makeHdrFrame(width, height, 10000, 20000, 30000);
1005
- blitRgba8OverRgb48le(domRgba, canvas, width, height);
1006
-
1007
- // All pixels should be unchanged
1008
- for (let i = 0; i < width * height; i++) {
1009
- expect(canvas.readUInt16LE(i * 6 + 0)).toBe(10000);
1010
- expect(canvas.readUInt16LE(i * 6 + 2)).toBe(20000);
1011
- expect(canvas.readUInt16LE(i * 6 + 4)).toBe(30000);
1012
- }
1013
- });
1014
-
1015
- it("fully opaque PNG overlay overwrites all canvas pixels (sRGB→HLG)", () => {
1016
- const width = 2;
1017
- const height = 2;
1018
-
1019
- // Build a fully opaque blue PNG (sRGB blue = 0,0,255)
1020
- const pixels = Array(width * height)
1021
- .fill(null)
1022
- .flatMap(() => [0, 0, 255, 255]);
1023
- const png = makePng(width, height, pixels);
1024
- const { data: domRgba } = decodePng(png);
1025
-
1026
- const canvas = makeHdrFrame(width, height, 50000, 40000, 30000);
1027
- blitRgba8OverRgb48le(domRgba, canvas, width, height);
1028
-
1029
- // sRGB blue (0,0,255) → HLG (0, 0, 65535) — black/white map identically
1030
- for (let i = 0; i < width * height; i++) {
1031
- expect(canvas.readUInt16LE(i * 6 + 0)).toBe(0);
1032
- expect(canvas.readUInt16LE(i * 6 + 2)).toBe(0);
1033
- expect(canvas.readUInt16LE(i * 6 + 4)).toBe(65535);
1034
- }
1035
- });
1036
- });
1037
-
1038
- // ── roundedRectAlpha tests ──────────────────────────────────────────────────
1039
-
1040
- describe("roundedRectAlpha", () => {
1041
- const uniform20: [number, number, number, number] = [20, 20, 20, 20];
1042
-
1043
- it("returns 1 for center pixel", () => {
1044
- expect(roundedRectAlpha(50, 50, 100, 100, uniform20)).toBe(1);
1045
- });
1046
-
1047
- it("returns 1 for pixel well inside edge (not in corner zone)", () => {
1048
- // On top edge but past the corner zone (x >= radius)
1049
- expect(roundedRectAlpha(50, 5, 100, 100, uniform20)).toBe(1);
1050
- });
1051
-
1052
- it("returns 0 for pixel at the extreme corner (outside rounded area)", () => {
1053
- // Top-left corner: (0, 0) is far from circle center at (20, 20)
1054
- // dist = sqrt(400 + 400) = 28.28, well beyond radius 20
1055
- expect(roundedRectAlpha(0, 0, 100, 100, uniform20)).toBe(0);
1056
- });
1057
-
1058
- it("returns 1 for pixel well inside corner circle", () => {
1059
- // Pixel at (15, 15): dist from center (20, 20) = sqrt(25+25) = 7.07 << 20
1060
- expect(roundedRectAlpha(15, 15, 100, 100, uniform20)).toBe(1);
1061
- });
1062
-
1063
- it("returns fractional alpha at corner edge (anti-aliasing)", () => {
1064
- // Find a point near the circle edge. radius = 20, center at (20, 20).
1065
- // Point on the circle: (20 - 20*cos(45°), 20 - 20*sin(45°)) ≈ (5.86, 5.86)
1066
- // Shift slightly inward for fractional alpha
1067
- const edgePx = 20 - 20 * Math.cos(Math.PI / 4); // ~5.86
1068
- const alpha = roundedRectAlpha(edgePx, edgePx, 100, 100, uniform20);
1069
- expect(alpha).toBeGreaterThan(0);
1070
- expect(alpha).toBeLessThan(1);
1071
- });
1072
-
1073
- it("handles all four corners symmetrically", () => {
1074
- // Test top-right corner (x near w, y near 0)
1075
- expect(roundedRectAlpha(100, 0, 100, 100, uniform20)).toBe(0);
1076
- // Test bottom-right corner
1077
- expect(roundedRectAlpha(100, 100, 100, 100, uniform20)).toBe(0);
1078
- // Test bottom-left corner
1079
- expect(roundedRectAlpha(0, 100, 100, 100, uniform20)).toBe(0);
1080
- });
1081
-
1082
- it("returns 1 everywhere for zero radii", () => {
1083
- const zero: [number, number, number, number] = [0, 0, 0, 0];
1084
- expect(roundedRectAlpha(0, 0, 100, 100, zero)).toBe(1);
1085
- expect(roundedRectAlpha(99, 0, 100, 100, zero)).toBe(1);
1086
- expect(roundedRectAlpha(0, 99, 100, 100, zero)).toBe(1);
1087
- expect(roundedRectAlpha(99, 99, 100, 100, zero)).toBe(1);
1088
- });
1089
-
1090
- it("supports per-corner radii", () => {
1091
- const mixed: [number, number, number, number] = [20, 0, 10, 0];
1092
- // Top-left has radius 20 — corner pixel outside
1093
- expect(roundedRectAlpha(0, 0, 100, 100, mixed)).toBe(0);
1094
- // Top-right has radius 0 — corner pixel inside
1095
- expect(roundedRectAlpha(99, 0, 100, 100, mixed)).toBe(1);
1096
- // Bottom-right has radius 10 — extreme corner outside
1097
- expect(roundedRectAlpha(100, 100, 100, 100, mixed)).toBe(0);
1098
- // Bottom-left has radius 0 — corner pixel inside
1099
- expect(roundedRectAlpha(0, 99, 100, 100, mixed)).toBe(1);
1100
- });
1101
- });
1102
-
1103
- // ── blitRgb48leRegion with borderRadius ─────────────────────────────────────
1104
-
1105
- describe("blitRgb48leRegion with borderRadius", () => {
1106
- it("clips corner pixels when borderRadius is set", () => {
1107
- // 10x10 source placed at origin on a 10x10 canvas, radius 5
1108
- const canvas = Buffer.alloc(10 * 10 * 6);
1109
- const source = makeHdrFrame(10, 10, 40000, 30000, 20000);
1110
- const br: [number, number, number, number] = [5, 5, 5, 5];
1111
- blitRgb48leRegion(canvas, source, 0, 0, 10, 10, 10, 10, undefined, br);
1112
-
1113
- // Center pixel should be written
1114
- const centerOff = (5 * 10 + 5) * 6;
1115
- expect(canvas.readUInt16LE(centerOff)).toBe(40000);
1116
-
1117
- // Corner pixel (0,0) should be clipped (remain 0)
1118
- expect(canvas.readUInt16LE(0)).toBe(0);
1119
- });
1120
-
1121
- it("no effect when borderRadius is all zeros", () => {
1122
- const canvas1 = Buffer.alloc(4 * 4 * 6);
1123
- const canvas2 = Buffer.alloc(4 * 4 * 6);
1124
- const source = makeHdrFrame(4, 4, 40000, 30000, 20000);
1125
-
1126
- blitRgb48leRegion(canvas1, source, 0, 0, 4, 4, 4, 4);
1127
- blitRgb48leRegion(canvas2, source, 0, 0, 4, 4, 4, 4, undefined, [0, 0, 0, 0]);
1128
-
1129
- expect(Buffer.compare(canvas1, canvas2)).toBe(0);
1130
- });
1131
-
1132
- it("combines opacity and borderRadius", () => {
1133
- // Canvas with known background, source with known values
1134
- const canvas = makeHdrFrame(10, 10, 20000, 20000, 20000);
1135
- const source = makeHdrFrame(10, 10, 60000, 60000, 60000);
1136
- const br: [number, number, number, number] = [3, 3, 3, 3];
1137
-
1138
- blitRgb48leRegion(canvas, source, 0, 0, 10, 10, 10, 10, 0.5, br);
1139
-
1140
- // Center pixel: opacity 0.5, mask 1.0 → effective 0.5
1141
- // Result: 60000 * 0.5 + 20000 * 0.5 = 40000
1142
- const centerOff = (5 * 10 + 5) * 6;
1143
- expect(canvas.readUInt16LE(centerOff)).toBe(40000);
1144
-
1145
- // Corner pixel (0,0): mask 0.0 → skipped, canvas unchanged
1146
- expect(canvas.readUInt16LE(0)).toBe(20000);
1147
- });
1148
- });
1149
-
1150
- // ── blitRgb48leAffine with borderRadius ─────────────────────────────────────
1151
-
1152
- describe("blitRgb48leAffine with borderRadius", () => {
1153
- it("clips corner pixels with identity transform", () => {
1154
- const canvas = Buffer.alloc(10 * 10 * 6);
1155
- const source = makeHdrFrame(10, 10, 40000, 30000, 20000);
1156
- const identity = [1, 0, 0, 1, 0, 0];
1157
- const br: [number, number, number, number] = [5, 5, 5, 5];
1158
-
1159
- blitRgb48leAffine(canvas, source, identity, 10, 10, 10, 10, undefined, br);
1160
-
1161
- // Center pixel should be written
1162
- const centerOff = (5 * 10 + 5) * 6;
1163
- expect(canvas.readUInt16LE(centerOff)).toBe(40000);
1164
-
1165
- // Corner pixel (0,0) should be clipped
1166
- expect(canvas.readUInt16LE(0)).toBe(0);
1167
- });
1168
-
1169
- it("mask follows transform (scaled output has rounded corners)", () => {
1170
- // 4x4 source scaled up 2× on an 8×8 canvas, radius 2 in source space
1171
- const canvas = Buffer.alloc(8 * 8 * 6);
1172
- const source = makeHdrFrame(4, 4, 50000, 40000, 30000);
1173
- const scale2x = [2, 0, 0, 2, 0, 0];
1174
- const br: [number, number, number, number] = [2, 2, 2, 2];
1175
-
1176
- blitRgb48leAffine(canvas, source, scale2x, 4, 4, 8, 8, undefined, br);
1177
-
1178
- // Canvas center (4,4) maps to source (2,2) — inside, should be written
1179
- const centerOff = (4 * 8 + 4) * 6;
1180
- expect(canvas.readUInt16LE(centerOff)).toBeGreaterThan(0);
1181
-
1182
- // Canvas corner (0,0) maps to source (0,0) — outside radius, should be clipped
1183
- expect(canvas.readUInt16LE(0)).toBe(0);
1184
- });
1185
-
1186
- it("no effect when borderRadius is undefined", () => {
1187
- const canvas1 = Buffer.alloc(4 * 4 * 6);
1188
- const canvas2 = Buffer.alloc(4 * 4 * 6);
1189
- const source = makeHdrFrame(4, 4, 40000, 30000, 20000);
1190
- const identity = [1, 0, 0, 1, 0, 0];
1191
-
1192
- blitRgb48leAffine(canvas1, source, identity, 4, 4, 4, 4);
1193
- blitRgb48leAffine(canvas2, source, identity, 4, 4, 4, 4, undefined, undefined);
1194
-
1195
- expect(Buffer.compare(canvas1, canvas2)).toBe(0);
1196
- });
1197
- });
1198
-
1199
- // ── normalizeObjectFit ──────────────────────────────────────────────────────
1200
-
1201
- describe("normalizeObjectFit", () => {
1202
- it("returns supported values verbatim", () => {
1203
- expect(normalizeObjectFit("fill")).toBe("fill");
1204
- expect(normalizeObjectFit("cover")).toBe("cover");
1205
- expect(normalizeObjectFit("contain")).toBe("contain");
1206
- expect(normalizeObjectFit("none")).toBe("none");
1207
- expect(normalizeObjectFit("scale-down")).toBe("scale-down");
1208
- });
1209
-
1210
- it("trims whitespace and lowercases input", () => {
1211
- expect(normalizeObjectFit(" COVER ")).toBe("cover");
1212
- });
1213
-
1214
- it("falls back to fill for unsupported values", () => {
1215
- expect(normalizeObjectFit(undefined)).toBe("fill");
1216
- expect(normalizeObjectFit("")).toBe("fill");
1217
- expect(normalizeObjectFit("inherit")).toBe("fill");
1218
- expect(normalizeObjectFit("garbage")).toBe("fill");
1219
- });
1220
- });
1221
-
1222
- // ── resampleRgb48leObjectFit ────────────────────────────────────────────────
1223
-
1224
- function readRgb16(buf: Buffer, width: number, x: number, y: number): [number, number, number] {
1225
- const off = (y * width + x) * 6;
1226
- return [buf.readUInt16LE(off), buf.readUInt16LE(off + 2), buf.readUInt16LE(off + 4)];
1227
- }
1228
-
1229
- describe("resampleRgb48leObjectFit", () => {
1230
- it("returns the same buffer unchanged for identity fill resample", () => {
1231
- const src = makeHdrFrame(4, 4, 40000, 30000, 20000);
1232
- const out = resampleRgb48leObjectFit(src, 4, 4, 4, 4, "fill");
1233
-
1234
- // Fast path returns the same Buffer reference, not a copy
1235
- expect(out).toBe(src);
1236
- });
1237
-
1238
- it("returns the source untouched on degenerate dimensions", () => {
1239
- const src = makeHdrFrame(4, 4, 1, 2, 3);
1240
- expect(resampleRgb48leObjectFit(src, 0, 4, 8, 8, "cover")).toBe(src);
1241
- expect(resampleRgb48leObjectFit(src, 4, 4, 0, 8, "cover")).toBe(src);
1242
- });
1243
-
1244
- it("fills a larger box with stretched content (fit=fill)", () => {
1245
- const src = makeHdrFrame(2, 2, 50000, 40000, 30000);
1246
- const out = resampleRgb48leObjectFit(src, 2, 2, 8, 4, "fill");
1247
-
1248
- expect(out.length).toBe(8 * 4 * 6);
1249
- // Every output pixel should be the source color (uniform input → uniform output)
1250
- for (let y = 0; y < 4; y++) {
1251
- for (let x = 0; x < 8; x++) {
1252
- const [r, g, b] = readRgb16(out, 8, x, y);
1253
- expect(r).toBe(50000);
1254
- expect(g).toBe(40000);
1255
- expect(b).toBe(30000);
1256
- }
1257
- }
1258
- });
1259
-
1260
- it("covers the destination box (cover) — fills entire box, no black bars", () => {
1261
- // 4×2 source into a 6×6 dst: cover scales by 6/2 = 3 → rendered 12×6, cropped horizontally
1262
- const src = makeHdrFrame(4, 2, 65000, 0, 0);
1263
- const out = resampleRgb48leObjectFit(src, 4, 2, 6, 6, "cover");
1264
-
1265
- // No pillarbox/letterbox black anywhere
1266
- for (let y = 0; y < 6; y++) {
1267
- for (let x = 0; x < 6; x++) {
1268
- const [r] = readRgb16(out, 6, x, y);
1269
- expect(r).toBe(65000);
1270
- }
1271
- }
1272
- });
1273
-
1274
- it("contains the source (contain) and letterboxes with opaque black", () => {
1275
- // 4×2 source into a 6×6 dst: contain scales by 6/4 = 1.5 → rendered 6×3, vertically centered
1276
- const src = makeHdrFrame(4, 2, 65000, 65000, 65000);
1277
- const out = resampleRgb48leObjectFit(src, 4, 2, 6, 6, "contain");
1278
-
1279
- // Top and bottom rows should be black (letterbox)
1280
- for (const y of [0, 5]) {
1281
- for (let x = 0; x < 6; x++) {
1282
- expect(readRgb16(out, 6, x, y)).toEqual([0, 0, 0]);
1283
- }
1284
- }
1285
- // Middle band (rows 2–3) should be the source color
1286
- for (const y of [2, 3]) {
1287
- for (let x = 0; x < 6; x++) {
1288
- const [r, g, b] = readRgb16(out, 6, x, y);
1289
- expect(r).toBe(65000);
1290
- expect(g).toBe(65000);
1291
- expect(b).toBe(65000);
1292
- }
1293
- }
1294
- });
1295
-
1296
- it("none preserves source size and centers it on a black background", () => {
1297
- // 2×2 source into a 6×6 dst with default object-position 50%/50%
1298
- const src = makeHdrFrame(2, 2, 40000, 30000, 20000);
1299
- const out = resampleRgb48leObjectFit(src, 2, 2, 6, 6, "none");
1300
-
1301
- // Center 2×2 region (rows 2–3, cols 2–3) holds the source
1302
- for (let y = 2; y < 4; y++) {
1303
- for (let x = 2; x < 4; x++) {
1304
- const [r, g, b] = readRgb16(out, 6, x, y);
1305
- expect(r).toBe(40000);
1306
- expect(g).toBe(30000);
1307
- expect(b).toBe(20000);
1308
- }
1309
- }
1310
- // Corners should be black
1311
- expect(readRgb16(out, 6, 0, 0)).toEqual([0, 0, 0]);
1312
- expect(readRgb16(out, 6, 5, 5)).toEqual([0, 0, 0]);
1313
- });
1314
-
1315
- it("respects object-position for none-fit alignment", () => {
1316
- // 2×2 source into a 6×6 dst, anchored top-left
1317
- const src = makeHdrFrame(2, 2, 40000, 30000, 20000);
1318
- const out = resampleRgb48leObjectFit(src, 2, 2, 6, 6, "none", "0% 0%");
1319
-
1320
- // Top-left 2×2 block holds the source
1321
- for (let y = 0; y < 2; y++) {
1322
- for (let x = 0; x < 2; x++) {
1323
- const [r] = readRgb16(out, 6, x, y);
1324
- expect(r).toBe(40000);
1325
- }
1326
- }
1327
- // Bottom-right corner stays black
1328
- expect(readRgb16(out, 6, 5, 5)).toEqual([0, 0, 0]);
1329
- // Just below the source band should be black
1330
- expect(readRgb16(out, 6, 0, 2)).toEqual([0, 0, 0]);
1331
- expect(readRgb16(out, 6, 2, 0)).toEqual([0, 0, 0]);
1332
- });
1333
-
1334
- it("scale-down behaves like none when source fits in dst", () => {
1335
- const src = makeHdrFrame(2, 2, 40000, 30000, 20000);
1336
- const noneOut = resampleRgb48leObjectFit(src, 2, 2, 6, 6, "none");
1337
- const sdOut = resampleRgb48leObjectFit(src, 2, 2, 6, 6, "scale-down");
1338
-
1339
- expect(Buffer.compare(noneOut, sdOut)).toBe(0);
1340
- });
1341
-
1342
- it("scale-down behaves like contain when source overflows dst", () => {
1343
- const src = makeHdrFrame(8, 4, 40000, 30000, 20000);
1344
- const containOut = resampleRgb48leObjectFit(src, 8, 4, 6, 6, "contain");
1345
- const sdOut = resampleRgb48leObjectFit(src, 8, 4, 6, 6, "scale-down");
1346
-
1347
- expect(Buffer.compare(containOut, sdOut)).toBe(0);
1348
- });
1349
- });