@hyperframes/engine 0.6.118 → 0.6.120

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,1015 +0,0 @@
1
- /**
2
- * Alpha Blit — in-memory PNG decode + alpha compositing over rgb48le HDR frames.
3
- *
4
- * Replaces per-frame FFmpeg spawns for the two-pass HDR compositing path.
5
- * Uses only Node.js built-ins (zlib) — no additional dependencies.
6
- */
7
-
8
- import { inflateSync } from "zlib";
9
-
10
- // ── PNG decoder ───────────────────────────────────────────────────────────────
11
-
12
- function paeth(a: number, b: number, c: number): number {
13
- const p = a + b - c;
14
- const pa = Math.abs(p - a);
15
- const pb = Math.abs(p - b);
16
- const pc = Math.abs(p - c);
17
- if (pa <= pb && pa <= pc) return a;
18
- if (pb <= pc) return b;
19
- return c;
20
- }
21
-
22
- /**
23
- * Shared PNG chunk parsing + filter reconstruction.
24
- *
25
- * Verifies the PNG signature, iterates chunks to collect IHDR metadata and IDAT
26
- * payloads, decompresses with zlib, and reconstructs all 5 PNG filter types.
27
- *
28
- * Returns the defiltered pixel bytes (no filter-type prefix bytes) along with
29
- * IHDR fields so callers can convert to their target pixel format.
30
- */
31
- function decodePngRaw(
32
- buf: Buffer,
33
- caller: string,
34
- ): { width: number; height: number; bitDepth: number; colorType: number; rawPixels: Buffer } {
35
- // Verify PNG signature
36
- if (
37
- buf[0] !== 137 ||
38
- buf[1] !== 80 ||
39
- buf[2] !== 78 ||
40
- buf[3] !== 71 ||
41
- buf[4] !== 13 ||
42
- buf[5] !== 10 ||
43
- buf[6] !== 26 ||
44
- buf[7] !== 10
45
- ) {
46
- throw new Error(`${caller}: not a PNG file`);
47
- }
48
-
49
- let pos = 8;
50
- let width = 0;
51
- let height = 0;
52
- let bitDepth = 0;
53
- let colorType = 0;
54
- let interlace = 0;
55
- let sawIhdr = false;
56
- const idatChunks: Buffer[] = [];
57
-
58
- while (pos + 12 <= buf.length) {
59
- const chunkLen = buf.readUInt32BE(pos);
60
- const chunkType = buf.toString("ascii", pos + 4, pos + 8);
61
- const chunkData = buf.subarray(pos + 8, pos + 8 + chunkLen);
62
-
63
- if (chunkType === "IHDR") {
64
- width = chunkData.readUInt32BE(0);
65
- height = chunkData.readUInt32BE(4);
66
- bitDepth = chunkData[8] ?? 0;
67
- colorType = chunkData[9] ?? 0;
68
- interlace = chunkData[12] ?? 0;
69
- sawIhdr = true;
70
- } else if (chunkType === "IDAT") {
71
- idatChunks.push(Buffer.from(chunkData));
72
- } else if (chunkType === "IEND") {
73
- break;
74
- }
75
-
76
- pos += 12 + chunkLen; // length(4) + type(4) + data(chunkLen) + crc(4)
77
- }
78
-
79
- if (!sawIhdr) {
80
- throw new Error(`${caller}: PNG missing IHDR chunk`);
81
- }
82
- if (colorType !== 2 && colorType !== 6) {
83
- throw new Error(`${caller}: unsupported color type ${colorType} (expected 2=RGB or 6=RGBA)`);
84
- }
85
- if (interlace !== 0) {
86
- throw new Error(
87
- `${caller}: Adam7-interlaced PNGs are not supported (interlace method ${interlace})`,
88
- );
89
- }
90
-
91
- // Bytes per pixel: channels x bytes-per-channel
92
- const channels = colorType === 6 ? 4 : 3;
93
- const bpp = channels * (bitDepth / 8);
94
- const stride = width * bpp;
95
-
96
- const compressed = Buffer.concat(idatChunks);
97
- const decompressed = inflateSync(compressed);
98
-
99
- // Reconstruct filtered rows into a flat pixel buffer (no filter bytes)
100
- const rawPixels = Buffer.allocUnsafe(height * stride);
101
- const prevRow = new Uint8Array(stride);
102
- const currRow = new Uint8Array(stride);
103
-
104
- let srcPos = 0;
105
-
106
- for (let y = 0; y < height; y++) {
107
- const filterType = decompressed[srcPos++] ?? 0;
108
- const rawRow = decompressed.subarray(srcPos, srcPos + stride);
109
- srcPos += stride;
110
-
111
- switch (filterType) {
112
- case 0: // None
113
- currRow.set(rawRow);
114
- break;
115
- case 1: // Sub
116
- for (let x = 0; x < stride; x++) {
117
- currRow[x] = ((rawRow[x] ?? 0) + (x >= bpp ? (currRow[x - bpp] ?? 0) : 0)) & 0xff;
118
- }
119
- break;
120
- case 2: // Up
121
- for (let x = 0; x < stride; x++) {
122
- currRow[x] = ((rawRow[x] ?? 0) + (prevRow[x] ?? 0)) & 0xff;
123
- }
124
- break;
125
- case 3: // Average
126
- for (let x = 0; x < stride; x++) {
127
- const left = x >= bpp ? (currRow[x - bpp] ?? 0) : 0;
128
- const up = prevRow[x] ?? 0;
129
- currRow[x] = ((rawRow[x] ?? 0) + Math.floor((left + up) / 2)) & 0xff;
130
- }
131
- break;
132
- case 4: // Paeth
133
- for (let x = 0; x < stride; x++) {
134
- const left = x >= bpp ? (currRow[x - bpp] ?? 0) : 0;
135
- const up = prevRow[x] ?? 0;
136
- const upLeft = x >= bpp ? (prevRow[x - bpp] ?? 0) : 0;
137
- currRow[x] = ((rawRow[x] ?? 0) + paeth(left, up, upLeft)) & 0xff;
138
- }
139
- break;
140
- default:
141
- throw new Error(`${caller}: unknown filter type ${filterType} at row ${y}`);
142
- }
143
-
144
- rawPixels.set(currRow, y * stride);
145
- prevRow.set(currRow);
146
- }
147
-
148
- return { width, height, bitDepth, colorType, rawPixels };
149
- }
150
-
151
- /**
152
- * Decode a PNG buffer to raw RGBA pixel data (8-bit per channel).
153
- *
154
- * Supports color type 6 (RGBA) and color type 2 (RGB) at 8-bit depth,
155
- * non-interlaced. Chrome's Page.captureScreenshot always emits this format.
156
- *
157
- * Returns a Uint8Array of width*height*4 bytes in RGBA order.
158
- */
159
- export function decodePng(buf: Buffer): { width: number; height: number; data: Uint8Array } {
160
- const { width, height, bitDepth, colorType, rawPixels } = decodePngRaw(buf, "decodePng");
161
-
162
- if (bitDepth !== 8) {
163
- throw new Error(`decodePng: unsupported bit depth ${bitDepth} (expected 8)`);
164
- }
165
-
166
- const output = new Uint8Array(width * height * 4);
167
-
168
- if (colorType === 6) {
169
- // RGBA — copy directly
170
- output.set(rawPixels);
171
- } else {
172
- // RGB → RGBA: set alpha to 255
173
- for (let i = 0; i < width * height; i++) {
174
- output[i * 4 + 0] = rawPixels[i * 3 + 0] ?? 0;
175
- output[i * 4 + 1] = rawPixels[i * 3 + 1] ?? 0;
176
- output[i * 4 + 2] = rawPixels[i * 3 + 2] ?? 0;
177
- output[i * 4 + 3] = 255;
178
- }
179
- }
180
-
181
- return { width, height, data: output };
182
- }
183
-
184
- // ── 16-bit PNG decoder ────────────────────────────────────────────────────────
185
-
186
- /**
187
- * Decode a 16-bit RGB PNG (from FFmpeg) to an rgb48le Buffer.
188
- *
189
- * FFmpeg's `-pix_fmt rgb48le -c:v png` produces 16-bit RGB PNGs.
190
- * PNG stores 16-bit values in big-endian; this function swaps to little-endian
191
- * for the streaming encoder's rgb48le input format.
192
- *
193
- * Supports colorType 2 (RGB) and 6 (RGBA) at 16-bit depth, non-interlaced.
194
- */
195
- export function decodePngToRgb48le(buf: Buffer): { width: number; height: number; data: Buffer } {
196
- const { width, height, bitDepth, colorType, rawPixels } = decodePngRaw(buf, "decodePngToRgb48le");
197
-
198
- if (bitDepth !== 16) {
199
- throw new Error(`decodePngToRgb48le: unsupported bit depth ${bitDepth} (expected 16)`);
200
- }
201
-
202
- // 16-bit: 2 bytes per channel. RGB=6 bytes/pixel, RGBA=8 bytes/pixel
203
- const bpp = colorType === 6 ? 8 : 6;
204
-
205
- // Output: rgb48le = 3 channels x 2 bytes (LE) = 6 bytes/pixel
206
- const output = Buffer.allocUnsafe(width * height * 6);
207
-
208
- for (let y = 0; y < height; y++) {
209
- const dstBase = y * width * 6;
210
- const srcRowBase = y * width * bpp;
211
- for (let x = 0; x < width; x++) {
212
- const srcBase = srcRowBase + x * bpp;
213
- // PNG stores 16-bit as big-endian: [high, low]. Swap to little-endian: [low, high].
214
- output[dstBase + x * 6 + 0] = rawPixels[srcBase + 1] ?? 0; // R low
215
- output[dstBase + x * 6 + 1] = rawPixels[srcBase + 0] ?? 0; // R high
216
- output[dstBase + x * 6 + 2] = rawPixels[srcBase + 3] ?? 0; // G low
217
- output[dstBase + x * 6 + 3] = rawPixels[srcBase + 2] ?? 0; // G high
218
- output[dstBase + x * 6 + 4] = rawPixels[srcBase + 5] ?? 0; // B low
219
- output[dstBase + x * 6 + 5] = rawPixels[srcBase + 4] ?? 0; // B high
220
- }
221
- }
222
-
223
- return { width, height, data: output };
224
- }
225
-
226
- // ── sRGB → HDR color conversion ───────────────────────────────────────────────
227
-
228
- /**
229
- * Build a 256-entry LUT: sRGB 8-bit value → HDR 16-bit signal value.
230
- *
231
- * Pipeline per channel: sRGB EOTF (decode gamma) → linear → HDR OETF → 16-bit.
232
- *
233
- * ## Convention
234
- *
235
- * "Linear" here means **scene light in [0, 1] relative to SDR reference white**
236
- * (not absolute nits). The HLG branch applies the OETF directly — no OOTF (no
237
- * gamma 1.2 scene→display conversion). This is the right choice for DOM
238
- * overlays that will be composited ON TOP of HLG video pixels (which are
239
- * already in HLG signal space); we need the overlay to sit in the same space
240
- * as what it’s blending onto. Applying the OOTF here would double-apply it
241
- * when the HDR video already carries scene-light semantics.
242
- *
243
- * For PQ, SDR white is placed at 203 nits per ITU-R BT.2408 ("SDR white"
244
- * reference level) and normalized against 10,000-nit peak. This lets SDR
245
- * content (text, UI) sit at the conventional SDR-white brightness within a
246
- * PQ frame rather than at peak brightness.
247
- *
248
- * Note: converts the transfer function but not the color primaries (bt709 →
249
- * bt2020). For neutral/near-neutral content (text, UI) the gamut difference
250
- * is negligible.
251
- */
252
- function buildSrgbToSignalLut(transfer: "hlg" | "pq" | "srgb"): Uint16Array {
253
- const lut = new Uint16Array(256);
254
-
255
- // HLG OETF constants (Rec. 2100)
256
- const hlgA = 0.17883277;
257
- const hlgB = 1 - 4 * hlgA;
258
- const hlgC = 0.5 - hlgA * Math.log(4 * hlgA);
259
-
260
- // PQ (SMPTE 2084) OETF constants
261
- const pqM1 = 0.1593017578125;
262
- const pqM2 = 78.84375;
263
- const pqC1 = 0.8359375;
264
- const pqC2 = 18.8515625;
265
- const pqC3 = 18.6875;
266
- const pqMaxNits = 10000.0;
267
- const sdrNits = 203.0;
268
-
269
- for (let i = 0; i < 256; i++) {
270
- if (transfer === "srgb") {
271
- lut[i] = i * 257;
272
- continue;
273
- }
274
-
275
- // sRGB EOTF: signal → linear (range 0–1, relative to SDR white)
276
- const v = i / 255;
277
- const linear = v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
278
-
279
- let signal: number;
280
- if (transfer === "hlg") {
281
- signal =
282
- linear <= 1 / 12 ? Math.sqrt(3 * linear) : hlgA * Math.log(12 * linear - hlgB) + hlgC;
283
- } else {
284
- // PQ OETF: linear light (in SDR nits) → PQ signal
285
- const Lp = Math.max(0, (linear * sdrNits) / pqMaxNits);
286
- const Lm1 = Math.pow(Lp, pqM1);
287
- signal = Math.pow((pqC1 + pqC2 * Lm1) / (1.0 + pqC3 * Lm1), pqM2);
288
- }
289
-
290
- lut[i] = Math.min(65535, Math.round(signal * 65535));
291
- }
292
-
293
- return lut;
294
- }
295
-
296
- const SRGB_TO_SRGB_16 = buildSrgbToSignalLut("srgb");
297
- const SRGB_TO_HLG = buildSrgbToSignalLut("hlg");
298
- const SRGB_TO_PQ = buildSrgbToSignalLut("pq");
299
-
300
- /** Select the correct sRGB→HDR LUT for the given transfer function. */
301
- function getSrgbToSignalLut(transfer: "hlg" | "pq" | "srgb"): Uint16Array {
302
- if (transfer === "pq") return SRGB_TO_PQ;
303
- if (transfer === "hlg") return SRGB_TO_HLG;
304
- return SRGB_TO_SRGB_16;
305
- }
306
-
307
- // ── Alpha compositing ─────────────────────────────────────────────────────────
308
-
309
- /**
310
- * Alpha-composite a DOM RGBA overlay (8-bit sRGB) onto an HDR canvas
311
- * (rgb48le) in-place.
312
- *
313
- * DOM pixels are converted from sRGB to the target HDR signal space (HLG or PQ)
314
- * before blending so the composited output is uniformly encoded. Without this
315
- * conversion, sRGB content appears orange/washed in HDR playback.
316
- *
317
- * @param domRgba Raw RGBA pixel data from decodePng() — width*height*4 bytes
318
- * @param canvas HDR canvas in rgb48le format — width*height*6 bytes, mutated in-place
319
- * @param width Canvas width in pixels
320
- * @param height Canvas height in pixels
321
- * @param transfer HDR transfer function — selects the correct sRGB→HDR LUT
322
- */
323
- export function blitRgba8OverRgb48le(
324
- domRgba: Uint8Array,
325
- canvas: Buffer,
326
- width: number,
327
- height: number,
328
- transfer: "hlg" | "pq" | "srgb" = "hlg",
329
- ): void {
330
- const pixelCount = width * height;
331
- const lut = getSrgbToSignalLut(transfer);
332
-
333
- for (let i = 0; i < pixelCount; i++) {
334
- const da = domRgba[i * 4 + 3] ?? 0;
335
-
336
- if (da === 0) {
337
- continue;
338
- } else if (da === 255) {
339
- const r16 = lut[domRgba[i * 4 + 0] ?? 0] ?? 0;
340
- const g16 = lut[domRgba[i * 4 + 1] ?? 0] ?? 0;
341
- const b16 = lut[domRgba[i * 4 + 2] ?? 0] ?? 0;
342
- canvas.writeUInt16LE(r16, i * 6);
343
- canvas.writeUInt16LE(g16, i * 6 + 2);
344
- canvas.writeUInt16LE(b16, i * 6 + 4);
345
- } else {
346
- const alpha = da / 255;
347
- const invAlpha = 1 - alpha;
348
-
349
- const hdrR = (canvas[i * 6 + 0] ?? 0) | ((canvas[i * 6 + 1] ?? 0) << 8);
350
- const hdrG = (canvas[i * 6 + 2] ?? 0) | ((canvas[i * 6 + 3] ?? 0) << 8);
351
- const hdrB = (canvas[i * 6 + 4] ?? 0) | ((canvas[i * 6 + 5] ?? 0) << 8);
352
-
353
- const domR = lut[domRgba[i * 4 + 0] ?? 0] ?? 0;
354
- const domG = lut[domRgba[i * 4 + 1] ?? 0] ?? 0;
355
- const domB = lut[domRgba[i * 4 + 2] ?? 0] ?? 0;
356
-
357
- canvas.writeUInt16LE(Math.round(domR * alpha + hdrR * invAlpha), i * 6);
358
- canvas.writeUInt16LE(Math.round(domG * alpha + hdrG * invAlpha), i * 6 + 2);
359
- canvas.writeUInt16LE(Math.round(domB * alpha + hdrB * invAlpha), i * 6 + 4);
360
- }
361
- }
362
- }
363
-
364
- // ── Rounded-rectangle mask ───────────────────────────────────────────────────
365
-
366
- /** Anti-aliased alpha for a point at distance `dist` from a corner circle of radius `r`. */
367
- function cornerAlpha(px: number, py: number, cx: number, cy: number, r: number): number {
368
- const dx = px - cx;
369
- const dy = py - cy;
370
- const dist = Math.sqrt(dx * dx + dy * dy);
371
- if (dist > r + 0.5) return 0;
372
- if (dist > r - 0.5) return r + 0.5 - dist;
373
- return 1;
374
- }
375
-
376
- /**
377
- * Compute the alpha (0.0–1.0) for a point inside a rounded rectangle.
378
- * Returns 1.0 for interior pixels, 0.0 for exterior, and a smooth
379
- * transition at the corner edges (1px anti-aliasing).
380
- *
381
- * @param px X coordinate (continuous, e.g. pixel center or subpixel)
382
- * @param py Y coordinate
383
- * @param w Rectangle width
384
- * @param h Rectangle height
385
- * @param radii Corner radii [topLeft, topRight, bottomRight, bottomLeft]
386
- */
387
- export function roundedRectAlpha(
388
- px: number,
389
- py: number,
390
- w: number,
391
- h: number,
392
- radii: [number, number, number, number],
393
- ): number {
394
- const [tl, tr, br, bl] = radii;
395
- if (px < tl && py < tl) return cornerAlpha(px, py, tl, tl, tl);
396
- if (px >= w - tr && py < tr) return cornerAlpha(px, py, w - tr, tr, tr);
397
- if (px >= w - br && py >= h - br) return cornerAlpha(px, py, w - br, h - br, br);
398
- if (px < bl && py >= h - bl) return cornerAlpha(px, py, bl, h - bl, bl);
399
- return 1;
400
- }
401
-
402
- // ── Positioned HDR region copy ────────────────────────────────────────────────
403
-
404
- /**
405
- * Copy a rectangular region of an rgb48le source onto an rgb48le canvas
406
- * at position (dx, dy). Clips to canvas bounds. Optional opacity blending
407
- * (0.0–1.0) over existing canvas content.
408
- *
409
- * @param canvas Destination rgb48le buffer (canvasWidth * canvasHeight * 6 bytes)
410
- * @param source Source rgb48le buffer (sw * sh * 6 bytes)
411
- * @param dx Destination X offset on canvas
412
- * @param dy Destination Y offset on canvas
413
- * @param sw Source width in pixels
414
- * @param sh Source height in pixels
415
- * @param canvasWidth Canvas width in pixels (needed for stride calculation)
416
- * @param canvasHeight Canvas height in pixels (used to clip the destination region)
417
- * @param opacity Optional opacity 0.0–1.0 (default 1.0 = fully opaque copy)
418
- */
419
- export function blitRgb48leRegion(
420
- canvas: Buffer,
421
- source: Buffer,
422
- dx: number,
423
- dy: number,
424
- sw: number,
425
- sh: number,
426
- canvasWidth: number,
427
- canvasHeight: number,
428
- opacity?: number,
429
- borderRadius?: [number, number, number, number],
430
- ): void {
431
- if (sw <= 0 || sh <= 0) return;
432
-
433
- const op = opacity ?? 1.0;
434
- if (op <= 0) return;
435
-
436
- const x0 = Math.max(0, dx);
437
- const y0 = Math.max(0, dy);
438
- const x1 = Math.min(canvasWidth, dx + sw);
439
- const y1 = Math.min(canvasHeight, dy + sh);
440
- if (x0 >= x1 || y0 >= y1) return;
441
-
442
- const clippedW = x1 - x0;
443
- const srcOffsetX = x0 - dx;
444
- const srcOffsetY = y0 - dy;
445
-
446
- const hasMask = borderRadius !== undefined;
447
-
448
- if (op >= 0.999 && !hasMask) {
449
- for (let y = 0; y < y1 - y0; y++) {
450
- const srcRowOff = ((srcOffsetY + y) * sw + srcOffsetX) * 6;
451
- const dstRowOff = ((y0 + y) * canvasWidth + x0) * 6;
452
- source.copy(canvas, dstRowOff, srcRowOff, srcRowOff + clippedW * 6);
453
- }
454
- } else if (!hasMask) {
455
- const invOp = 1 - op;
456
- for (let y = 0; y < y1 - y0; y++) {
457
- let srcOff = ((srcOffsetY + y) * sw + srcOffsetX) * 6;
458
- let dstOff = ((y0 + y) * canvasWidth + x0) * 6;
459
- for (let x = 0; x < clippedW; x++) {
460
- const sr = source[srcOff]! | (source[srcOff + 1]! << 8);
461
- const sg = source[srcOff + 2]! | (source[srcOff + 3]! << 8);
462
- const sb = source[srcOff + 4]! | (source[srcOff + 5]! << 8);
463
- const dr = canvas[dstOff]! | (canvas[dstOff + 1]! << 8);
464
- const dg = canvas[dstOff + 2]! | (canvas[dstOff + 3]! << 8);
465
- const db = canvas[dstOff + 4]! | (canvas[dstOff + 5]! << 8);
466
-
467
- const r = (sr * op + dr * invOp + 0.5) | 0;
468
- const g = (sg * op + dg * invOp + 0.5) | 0;
469
- const b = (sb * op + db * invOp + 0.5) | 0;
470
- canvas[dstOff] = r & 0xff;
471
- canvas[dstOff + 1] = r >>> 8;
472
- canvas[dstOff + 2] = g & 0xff;
473
- canvas[dstOff + 3] = g >>> 8;
474
- canvas[dstOff + 4] = b & 0xff;
475
- canvas[dstOff + 5] = b >>> 8;
476
-
477
- srcOff += 6;
478
- dstOff += 6;
479
- }
480
- }
481
- } else {
482
- for (let y = 0; y < y1 - y0; y++) {
483
- for (let x = 0; x < clippedW; x++) {
484
- let effectiveOp = op;
485
- if (hasMask) {
486
- const ma = roundedRectAlpha(srcOffsetX + x, srcOffsetY + y, sw, sh, borderRadius);
487
- if (ma <= 0) continue;
488
- effectiveOp *= ma;
489
- }
490
-
491
- const srcOff = ((srcOffsetY + y) * sw + srcOffsetX + x) * 6;
492
- const dstOff = ((y0 + y) * canvasWidth + x0 + x) * 6;
493
-
494
- if (effectiveOp >= 0.999) {
495
- source.copy(canvas, dstOff, srcOff, srcOff + 6);
496
- } else {
497
- const invEff = 1 - effectiveOp;
498
- const sr = source.readUInt16LE(srcOff);
499
- const sg = source.readUInt16LE(srcOff + 2);
500
- const sb = source.readUInt16LE(srcOff + 4);
501
- const dr = canvas.readUInt16LE(dstOff);
502
- const dg = canvas.readUInt16LE(dstOff + 2);
503
- const db = canvas.readUInt16LE(dstOff + 4);
504
- canvas.writeUInt16LE(Math.round(sr * effectiveOp + dr * invEff), dstOff);
505
- canvas.writeUInt16LE(Math.round(sg * effectiveOp + dg * invEff), dstOff + 2);
506
- canvas.writeUInt16LE(Math.round(sb * effectiveOp + db * invEff), dstOff + 4);
507
- }
508
- }
509
- }
510
- }
511
- }
512
-
513
- /**
514
- * Apply a 2D affine transform to an rgb48le source and composite onto a canvas.
515
- *
516
- * For each destination pixel, the inverse transform maps back to source coordinates.
517
- * Bilinear interpolation samples the 4 nearest source pixels for smooth scaling/rotation.
518
- *
519
- * @param canvas Destination rgb48le buffer, mutated in-place
520
- * @param source Source rgb48le buffer (srcW * srcH * 6 bytes)
521
- * @param matrix CSS transform matrix [a, b, c, d, tx, ty]
522
- * @param srcW Source width in pixels
523
- * @param srcH Source height in pixels
524
- * @param canvasW Canvas width in pixels
525
- * @param canvasH Canvas height in pixels
526
- * @param opacity Optional opacity 0.0–1.0 (default 1.0)
527
- */
528
- export function blitRgb48leAffine(
529
- canvas: Buffer,
530
- source: Buffer,
531
- matrix: number[],
532
- srcW: number,
533
- srcH: number,
534
- canvasW: number,
535
- canvasH: number,
536
- opacity?: number,
537
- borderRadius?: [number, number, number, number],
538
- ): void {
539
- const a = matrix[0];
540
- const b = matrix[1];
541
- const c = matrix[2];
542
- const d = matrix[3];
543
- const tx = matrix[4];
544
- const ty = matrix[5];
545
- if (
546
- a === undefined ||
547
- b === undefined ||
548
- c === undefined ||
549
- d === undefined ||
550
- tx === undefined ||
551
- ty === undefined
552
- )
553
- return;
554
-
555
- // Invert the 2x2 part of the affine matrix
556
- const det = a * d - b * c;
557
- if (Math.abs(det) < 1e-10) return; // degenerate matrix
558
-
559
- const invA = d / det;
560
- const invB = -b / det;
561
- const invC = -c / det;
562
- const invD = a / det;
563
- const invTx = -(invA * tx + invC * ty);
564
- const invTy = -(invB * tx + invD * ty);
565
-
566
- const op = opacity ?? 1.0;
567
- if (op <= 0) return;
568
-
569
- const hasMask = borderRadius !== undefined;
570
-
571
- // Compute bounding box of transformed source on canvas
572
- const corners = [
573
- [tx, ty],
574
- [a * srcW + tx, b * srcW + ty],
575
- [c * srcH + tx, d * srcH + ty],
576
- [a * srcW + c * srcH + tx, b * srcW + d * srcH + ty],
577
- ];
578
- let minX = canvasW,
579
- maxX = 0,
580
- minY = canvasH,
581
- maxY = 0;
582
- for (const corner of corners) {
583
- const cx = corner[0] ?? 0;
584
- const cy = corner[1] ?? 0;
585
- if (cx < minX) minX = cx;
586
- if (cx > maxX) maxX = cx;
587
- if (cy < minY) minY = cy;
588
- if (cy > maxY) maxY = cy;
589
- }
590
- const startX = Math.max(0, Math.floor(minX));
591
- const endX = Math.min(canvasW, Math.ceil(maxX));
592
- const startY = Math.max(0, Math.floor(minY));
593
- const endY = Math.min(canvasH, Math.ceil(maxY));
594
-
595
- for (let dy = startY; dy < endY; dy++) {
596
- for (let dx = startX; dx < endX; dx++) {
597
- const sx = invA * dx + invC * dy + invTx;
598
- const sy = invB * dx + invD * dy + invTy;
599
-
600
- if (sx < 0 || sy < 0 || sx >= srcW || sy >= srcH) continue;
601
-
602
- // Apply rounded-rect mask in source coordinates
603
- let effectiveOp = op;
604
- if (hasMask) {
605
- const ma = roundedRectAlpha(sx, sy, srcW, srcH, borderRadius);
606
- if (ma <= 0) continue;
607
- effectiveOp *= ma;
608
- }
609
-
610
- const x0 = Math.floor(sx);
611
- const y0 = Math.floor(sy);
612
- const fx = sx - x0;
613
- const fy = sy - y0;
614
- const x1 = Math.min(x0 + 1, srcW - 1);
615
- const y1 = Math.min(y0 + 1, srcH - 1);
616
-
617
- const off00 = (y0 * srcW + x0) * 6;
618
- const off10 = (y0 * srcW + x1) * 6;
619
- const off01 = (y1 * srcW + x0) * 6;
620
- const off11 = (y1 * srcW + x1) * 6;
621
-
622
- const w00 = (1 - fx) * (1 - fy);
623
- const w10 = fx * (1 - fy);
624
- const w01 = (1 - fx) * fy;
625
- const w11 = fx * fy;
626
-
627
- const sr =
628
- source.readUInt16LE(off00) * w00 +
629
- source.readUInt16LE(off10) * w10 +
630
- source.readUInt16LE(off01) * w01 +
631
- source.readUInt16LE(off11) * w11;
632
- const sg =
633
- source.readUInt16LE(off00 + 2) * w00 +
634
- source.readUInt16LE(off10 + 2) * w10 +
635
- source.readUInt16LE(off01 + 2) * w01 +
636
- source.readUInt16LE(off11 + 2) * w11;
637
- const sb =
638
- source.readUInt16LE(off00 + 4) * w00 +
639
- source.readUInt16LE(off10 + 4) * w10 +
640
- source.readUInt16LE(off01 + 4) * w01 +
641
- source.readUInt16LE(off11 + 4) * w11;
642
-
643
- const dstOff = (dy * canvasW + dx) * 6;
644
-
645
- if (effectiveOp >= 0.999) {
646
- canvas.writeUInt16LE(Math.round(sr), dstOff);
647
- canvas.writeUInt16LE(Math.round(sg), dstOff + 2);
648
- canvas.writeUInt16LE(Math.round(sb), dstOff + 4);
649
- } else {
650
- const invEff = 1 - effectiveOp;
651
- const dr = canvas.readUInt16LE(dstOff);
652
- const dg = canvas.readUInt16LE(dstOff + 2);
653
- const db = canvas.readUInt16LE(dstOff + 4);
654
- canvas.writeUInt16LE(Math.round(sr * effectiveOp + dr * invEff), dstOff);
655
- canvas.writeUInt16LE(Math.round(sg * effectiveOp + dg * invEff), dstOff + 2);
656
- canvas.writeUInt16LE(Math.round(sb * effectiveOp + db * invEff), dstOff + 4);
657
- }
658
- }
659
- }
660
- }
661
-
662
- /**
663
- * CSS `object-fit` values supported by the HDR image/video resampler.
664
- *
665
- * Matches the CSS spec subset that browsers actually render for replaced
666
- * elements (`<img>`, `<video>`). `scale-down` is normalized to whichever of
667
- * `none` or `contain` produces the smaller rendered size, mirroring the spec.
668
- */
669
- export type ObjectFit = "fill" | "cover" | "contain" | "none" | "scale-down";
670
-
671
- /**
672
- * Parse a single axis of a CSS `object-position` string into a fraction in
673
- * `[0, 1]` (proportion of the slack space along that axis).
674
- *
675
- * Defaults to 0.5 (centered) for unrecognized inputs to match CSS, which
676
- * resolves invalid `object-position` values to the initial value (`50% 50%`).
677
- */
678
- function parseObjectPositionAxis(value: string, axis: "x" | "y"): number {
679
- const lower = value.trim().toLowerCase();
680
- if (lower === "left" || lower === "top") return 0;
681
- if (lower === "right" || lower === "bottom") return 1;
682
- if (lower === "center" || lower === "") return 0.5;
683
- if (lower.endsWith("%")) {
684
- const pct = parseFloat(lower) / 100;
685
- return Number.isFinite(pct) ? Math.max(0, Math.min(1, pct)) : 0.5;
686
- }
687
- // Pixel values (e.g. "10px") aren't fractional; without the slack-space
688
- // numerator we can't honor them precisely. Fall back to center — this is
689
- // strictly worse than the browser but matches what we'd render today.
690
- if (axis === "x" || axis === "y") return 0.5;
691
- return 0.5;
692
- }
693
-
694
- /**
695
- * Parse a CSS `object-position` string like `"50% 50%"`, `"center top"`, or
696
- * `"25% 75%"` into normalized `[0, 1]` fractions for X and Y.
697
- *
698
- * The fractions express how the slack space (the portion of the layout box
699
- * not covered by the rendered content) should be distributed between the
700
- * leading and trailing edges. `0` aligns to the left/top, `1` to the
701
- * right/bottom, `0.5` (the default) centers the content.
702
- */
703
- function parseObjectPosition(css: string | undefined): { x: number; y: number } {
704
- if (!css || !css.trim()) return { x: 0.5, y: 0.5 };
705
- const tokens = css.trim().split(/\s+/);
706
- if (tokens.length === 1) {
707
- const single = tokens[0] ?? "";
708
- const v = parseObjectPositionAxis(single, "x");
709
- return { x: v, y: 0.5 };
710
- }
711
- return {
712
- x: parseObjectPositionAxis(tokens[0] ?? "", "x"),
713
- y: parseObjectPositionAxis(tokens[1] ?? "", "y"),
714
- };
715
- }
716
-
717
- /**
718
- * Compute the rendered rectangle for an `object-fit` value.
719
- *
720
- * Returns the destination box (`dx`, `dy`, `dw`, `dh`) where the source image
721
- * lands inside the layout box. For `cover` the rectangle extends past the
722
- * layout box on the crop axis; the resampler clamps that overflow to the
723
- * destination buffer bounds.
724
- */
725
- function computeObjectFitRect(
726
- srcW: number,
727
- srcH: number,
728
- dstW: number,
729
- dstH: number,
730
- fit: ObjectFit,
731
- pos: { x: number; y: number },
732
- ): { dx: number; dy: number; dw: number; dh: number } {
733
- let renderedW = dstW;
734
- let renderedH = dstH;
735
- if (fit === "fill") {
736
- return { dx: 0, dy: 0, dw: dstW, dh: dstH };
737
- }
738
- if (fit === "none") {
739
- renderedW = srcW;
740
- renderedH = srcH;
741
- } else if (fit === "scale-down") {
742
- // Pick the smaller of `none` and `contain` rendered sizes.
743
- const scale = Math.min(dstW / srcW, dstH / srcH, 1);
744
- renderedW = srcW * scale;
745
- renderedH = srcH * scale;
746
- } else if (fit === "cover") {
747
- const scale = Math.max(dstW / srcW, dstH / srcH);
748
- renderedW = srcW * scale;
749
- renderedH = srcH * scale;
750
- } else {
751
- // contain
752
- const scale = Math.min(dstW / srcW, dstH / srcH);
753
- renderedW = srcW * scale;
754
- renderedH = srcH * scale;
755
- }
756
- const dx = (dstW - renderedW) * pos.x;
757
- const dy = (dstH - renderedH) * pos.y;
758
- return { dx, dy, dw: renderedW, dh: renderedH };
759
- }
760
-
761
- /**
762
- * Resample an `rgb48le` image buffer into a destination box of `dstW × dstH`,
763
- * honoring CSS `object-fit` and `object-position` semantics.
764
- *
765
- * Used at HDR-image setup so the per-frame blit can treat the buffer as if it
766
- * were sized to the element's layout box, mirroring how browsers render
767
- * `<img object-fit:…>` for SDR content. Pixels that fall outside the rendered
768
- * rectangle (the letterboxed/pillarboxed area for `contain` and `none`) are
769
- * filled with opaque black, matching the default background for replaced
770
- * elements without a transparent canvas.
771
- *
772
- * Sampling is bilinear, which is what `blitRgb48leAffine` already uses for
773
- * its on-canvas affine scale, so a one-time resample here matches the visual
774
- * quality the rest of the pipeline produces.
775
- *
776
- * Returns the source buffer unchanged when `dstW === srcW && dstH === srcH`
777
- * and `fit === "fill"`, so callers can call this unconditionally without
778
- * paying for an unnecessary copy.
779
- */
780
- export function resampleRgb48leObjectFit(
781
- source: Buffer,
782
- srcW: number,
783
- srcH: number,
784
- dstW: number,
785
- dstH: number,
786
- fit: ObjectFit = "fill",
787
- objectPosition?: string,
788
- ): Buffer {
789
- if (srcW <= 0 || srcH <= 0 || dstW <= 0 || dstH <= 0) {
790
- return source;
791
- }
792
- if (fit === "fill" && srcW === dstW && srcH === dstH) {
793
- return source;
794
- }
795
-
796
- const pos = parseObjectPosition(objectPosition);
797
- const rect = computeObjectFitRect(srcW, srcH, dstW, dstH, fit, pos);
798
- const dst = Buffer.alloc(dstW * dstH * 6); // pre-zeroed → opaque black background
799
-
800
- const stride = dstW * 6;
801
- // For each destination pixel that lies inside the rendered rect, sample
802
- // the source bilinearly. Pixels outside the rect are left as the
803
- // pre-zeroed black background (letterbox/pillarbox area).
804
- const xMin = Math.max(0, Math.floor(rect.dx));
805
- const yMin = Math.max(0, Math.floor(rect.dy));
806
- const xMax = Math.min(dstW, Math.ceil(rect.dx + rect.dw));
807
- const yMax = Math.min(dstH, Math.ceil(rect.dy + rect.dh));
808
-
809
- if (rect.dw <= 0 || rect.dh <= 0) {
810
- return dst;
811
- }
812
-
813
- const invScaleX = srcW / rect.dw;
814
- const invScaleY = srcH / rect.dh;
815
-
816
- for (let dy = yMin; dy < yMax; dy++) {
817
- const rowOff = dy * stride;
818
- const sy = (dy + 0.5 - rect.dy) * invScaleY - 0.5;
819
- const syc = Math.max(0, Math.min(srcH - 1, sy));
820
- const y0 = Math.floor(syc);
821
- const y1 = Math.min(y0 + 1, srcH - 1);
822
- const fy = syc - y0;
823
- const ify = 1 - fy;
824
-
825
- for (let dx = xMin; dx < xMax; dx++) {
826
- const sx = (dx + 0.5 - rect.dx) * invScaleX - 0.5;
827
- const sxc = Math.max(0, Math.min(srcW - 1, sx));
828
- const x0 = Math.floor(sxc);
829
- const x1 = Math.min(x0 + 1, srcW - 1);
830
- const fx = sxc - x0;
831
- const ifx = 1 - fx;
832
-
833
- const off00 = (y0 * srcW + x0) * 6;
834
- const off10 = (y0 * srcW + x1) * 6;
835
- const off01 = (y1 * srcW + x0) * 6;
836
- const off11 = (y1 * srcW + x1) * 6;
837
-
838
- const w00 = ifx * ify;
839
- const w10 = fx * ify;
840
- const w01 = ifx * fy;
841
- const w11 = fx * fy;
842
-
843
- const r =
844
- source.readUInt16LE(off00) * w00 +
845
- source.readUInt16LE(off10) * w10 +
846
- source.readUInt16LE(off01) * w01 +
847
- source.readUInt16LE(off11) * w11;
848
- const g =
849
- source.readUInt16LE(off00 + 2) * w00 +
850
- source.readUInt16LE(off10 + 2) * w10 +
851
- source.readUInt16LE(off01 + 2) * w01 +
852
- source.readUInt16LE(off11 + 2) * w11;
853
- const b =
854
- source.readUInt16LE(off00 + 4) * w00 +
855
- source.readUInt16LE(off10 + 4) * w10 +
856
- source.readUInt16LE(off01 + 4) * w01 +
857
- source.readUInt16LE(off11 + 4) * w11;
858
-
859
- const dstOff = rowOff + dx * 6;
860
- dst.writeUInt16LE(Math.round(r), dstOff);
861
- dst.writeUInt16LE(Math.round(g), dstOff + 2);
862
- dst.writeUInt16LE(Math.round(b), dstOff + 4);
863
- }
864
- }
865
-
866
- return dst;
867
- }
868
-
869
- /**
870
- * Coerce a CSS `object-fit` value to the supported subset. Anything else
871
- * (including `inherit`, `initial`, the empty string, or vendor-prefixed
872
- * values) collapses to `"fill"` — the CSS default for replaced elements.
873
- */
874
- export function normalizeObjectFit(value: string | undefined): ObjectFit {
875
- switch ((value ?? "").trim().toLowerCase()) {
876
- case "cover":
877
- return "cover";
878
- case "contain":
879
- return "contain";
880
- case "none":
881
- return "none";
882
- case "scale-down":
883
- return "scale-down";
884
- default:
885
- return "fill";
886
- }
887
- }
888
-
889
- /**
890
- * Parse a CSS `matrix(a,b,c,d,e,f)` or `matrix3d(...)` string into a 6-element
891
- * 2D affine array.
892
- *
893
- * Returns null for `"none"`, empty input, or syntactically malformed values.
894
- *
895
- * The returned array maps to the CSS matrix: [a, b, c, d, tx, ty] where:
896
- * | a c tx | (a=scaleX, b=skewY, c=skewX, d=scaleY, tx/ty=translate)
897
- * | b d ty |
898
- * | 0 0 1 |
899
- *
900
- * `matrix3d` is the default output of `DOMMatrix.toString()` whenever any
901
- * ancestor in the chain has used a 3D transform — most importantly GSAP's
902
- * default `force3D: true`, which converts `translate(...)` into
903
- * `translate3d(..., 0)` and surfaces as `matrix3d(...)` even for purely 2D
904
- * animations. Without explicit handling we'd silently drop every transform
905
- * driven by GSAP. The 16 values are in column-major order:
906
- *
907
- * matrix3d(m11, m12, m13, m14, m21, m22, m23, m24, m31, m32, m33, m34,
908
- * m41, m42, m43, m44)
909
- *
910
- * The 2D affine corresponds to indices 0, 1, 4, 5, 12, 13 (m11, m12, m21,
911
- * m22, m41, m42). Z, perspective, and out-of-plane rotation components are
912
- * dropped — for true 3D transforms the resulting 2D projection is only
913
- * approximate, but for the GSAP `force3D: true` flat-matrix case it is exact.
914
- *
915
- * When a `matrix3d` arrives with Z-significant components (m13, m23, m31,
916
- * m32, m34, m43 != 0 or m33 != 1) we emit a one-time `console.warn` so
917
- * authors using real 3D transforms know the engine path is silently
918
- * flattening their scene rather than failing it.
919
- */
920
- export function parseTransformMatrix(css: string): number[] | null {
921
- if (!css || css === "none") return null;
922
-
923
- const match2d = css.match(
924
- /^matrix\(\s*([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,)]+)\s*\)$/,
925
- );
926
- if (match2d) {
927
- const values = match2d.slice(1, 7).map(Number);
928
- if (!values.every(Number.isFinite)) return null;
929
- return values;
930
- }
931
-
932
- const match3d = css.match(/^matrix3d\(\s*([^)]+)\)$/);
933
- if (match3d) {
934
- const raw = match3d[1];
935
- if (!raw) return null;
936
- const parts = raw.split(",").map((s) => Number(s.trim()));
937
- if (parts.length !== 16 || !parts.every(Number.isFinite)) return null;
938
- // 3D-significance check: a flat 2D transform expressed as matrix3d has
939
- // a3=b3=c1=c2=d1=d2=d3=0, c3=1, d4=1. Any deviation means the composition
940
- // is using real 3D (perspective, rotateX/Y) which the engine path can't
941
- // represent — we project to 2D and the visual will silently drop depth.
942
- // Warn once per process so authors don't get a misleading "looks fine in
943
- // studio, broken in render" experience without any signal. Z translation
944
- // (c4 = parts[14]) is intentionally dropped by the 2D projection below
945
- // and does NOT trigger this warning — that's the GSAP `force3D: true`
946
- // happy path.
947
- warnIfZSignificant(parts);
948
- // Extract column-major 2D affine: m11, m12, m21, m22, m41, m42.
949
- return [
950
- parts[0] as number,
951
- parts[1] as number,
952
- parts[4] as number,
953
- parts[5] as number,
954
- parts[12] as number,
955
- parts[13] as number,
956
- ];
957
- }
958
-
959
- return null;
960
- }
961
-
962
- let warnedZSignificant = false;
963
- const Z_EPSILON = 1e-6;
964
-
965
- function warnIfZSignificant(parts: number[]): void {
966
- if (warnedZSignificant) return;
967
- // CSS matrix3d() is column-major:
968
- // matrix3d(a1, b1, c1, d1, a2, b2, c2, d2, a3, b3, c3, d3, a4, b4, c4, d4)
969
- // laid out as:
970
- // | a1 a2 a3 a4 | | parts[0] parts[4] parts[8] parts[12] |
971
- // | b1 b2 b3 b4 | = | parts[1] parts[5] parts[9] parts[13] |
972
- // | c1 c2 c3 c4 | | parts[2] parts[6] parts[10] parts[14] |
973
- // | d1 d2 d3 d4 | | parts[3] parts[7] parts[11] parts[15] |
974
- //
975
- // For a flat 2D transform — the only thing this engine path can render
976
- // faithfully — we expect:
977
- // a3 = b3 = c1 = c2 = 0 (no XZ/YZ rotation coupling)
978
- // c3 = 1 (no Z scaling)
979
- // d1 = d2 = d3 = 0 (no perspective)
980
- // d4 = 1 (no homogeneous scaling)
981
- // Z translation (c4 = parts[14]) is explicitly dropped by the 2D affine
982
- // extraction below — that's the whole point of supporting GSAP's
983
- // `force3D: true` translate3d(x, y, 0) emission — so it is NOT flagged.
984
- const a3 = parts[8] ?? 0;
985
- const b3 = parts[9] ?? 0;
986
- const c1 = parts[2] ?? 0;
987
- const c2 = parts[6] ?? 0;
988
- const c3 = parts[10] ?? 1;
989
- const d1 = parts[3] ?? 0;
990
- const d2 = parts[7] ?? 0;
991
- const d3 = parts[11] ?? 0;
992
- const d4 = parts[15] ?? 1;
993
- if (
994
- Math.abs(a3) > Z_EPSILON ||
995
- Math.abs(b3) > Z_EPSILON ||
996
- Math.abs(c1) > Z_EPSILON ||
997
- Math.abs(c2) > Z_EPSILON ||
998
- Math.abs(c3 - 1) > Z_EPSILON ||
999
- Math.abs(d1) > Z_EPSILON ||
1000
- Math.abs(d2) > Z_EPSILON ||
1001
- Math.abs(d3) > Z_EPSILON ||
1002
- Math.abs(d4 - 1) > Z_EPSILON
1003
- ) {
1004
- warnedZSignificant = true;
1005
- console.warn(
1006
- `[alphaBlit] parseTransformMatrix received a matrix3d with non-trivial 3D components ` +
1007
- `(a3=${a3}, b3=${b3}, c1=${c1}, c2=${c2}, c3=${c3}, d1=${d1}, d2=${d2}, d3=${d3}, d4=${d4}). ` +
1008
- `The engine projects 3D transforms to 2D (m11, m12, m21, m22, m41, m42) and silently ` +
1009
- `discards perspective and out-of-plane rotation. If your composition uses real 3D ` +
1010
- `(rotateX/Y, perspective), the rendered output will not match the studio preview. ` +
1011
- `Z translation (translateZ) is dropped by design and does not trigger this warning. ` +
1012
- `This warning is emitted once per process.`,
1013
- );
1014
- }
1015
- }