@hyperframes/engine 0.4.6 → 0.4.8

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 (84) hide show
  1. package/dist/config.d.ts +6 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +9 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/index.d.ts +11 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +10 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/services/browserManager.d.ts.map +1 -1
  10. package/dist/services/browserManager.js +6 -3
  11. package/dist/services/browserManager.js.map +1 -1
  12. package/dist/services/chunkEncoder.d.ts +14 -7
  13. package/dist/services/chunkEncoder.d.ts.map +1 -1
  14. package/dist/services/chunkEncoder.js +25 -9
  15. package/dist/services/chunkEncoder.js.map +1 -1
  16. package/dist/services/chunkEncoder.types.d.ts +4 -0
  17. package/dist/services/chunkEncoder.types.d.ts.map +1 -1
  18. package/dist/services/hdrCapture.d.ts +62 -0
  19. package/dist/services/hdrCapture.d.ts.map +1 -0
  20. package/dist/services/hdrCapture.js +259 -0
  21. package/dist/services/hdrCapture.js.map +1 -0
  22. package/dist/services/screenshotService.d.ts +115 -0
  23. package/dist/services/screenshotService.d.ts.map +1 -1
  24. package/dist/services/screenshotService.js +252 -19
  25. package/dist/services/screenshotService.js.map +1 -1
  26. package/dist/services/streamingEncoder.d.ts +18 -3
  27. package/dist/services/streamingEncoder.d.ts.map +1 -1
  28. package/dist/services/streamingEncoder.js +104 -50
  29. package/dist/services/streamingEncoder.js.map +1 -1
  30. package/dist/services/videoFrameExtractor.d.ts.map +1 -1
  31. package/dist/services/videoFrameExtractor.js +109 -18
  32. package/dist/services/videoFrameExtractor.js.map +1 -1
  33. package/dist/services/videoFrameInjector.d.ts +59 -0
  34. package/dist/services/videoFrameInjector.d.ts.map +1 -1
  35. package/dist/services/videoFrameInjector.js +290 -0
  36. package/dist/services/videoFrameInjector.js.map +1 -1
  37. package/dist/types.d.ts +32 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/utils/alphaBlit.d.ts +105 -0
  40. package/dist/utils/alphaBlit.d.ts.map +1 -0
  41. package/dist/utils/alphaBlit.js +550 -0
  42. package/dist/utils/alphaBlit.js.map +1 -0
  43. package/dist/utils/ffprobe.d.ts +10 -0
  44. package/dist/utils/ffprobe.d.ts.map +1 -1
  45. package/dist/utils/ffprobe.js +7 -0
  46. package/dist/utils/ffprobe.js.map +1 -1
  47. package/dist/utils/hdr.d.ts +82 -0
  48. package/dist/utils/hdr.d.ts.map +1 -0
  49. package/dist/utils/hdr.js +87 -0
  50. package/dist/utils/hdr.js.map +1 -0
  51. package/dist/utils/layerCompositor.d.ts +36 -0
  52. package/dist/utils/layerCompositor.d.ts.map +1 -0
  53. package/dist/utils/layerCompositor.js +49 -0
  54. package/dist/utils/layerCompositor.js.map +1 -0
  55. package/dist/utils/shaderTransitions.d.ts +166 -0
  56. package/dist/utils/shaderTransitions.d.ts.map +1 -0
  57. package/dist/utils/shaderTransitions.js +894 -0
  58. package/dist/utils/shaderTransitions.js.map +1 -0
  59. package/package.json +3 -2
  60. package/src/config.ts +16 -0
  61. package/src/index.ts +61 -0
  62. package/src/services/browserManager.ts +6 -3
  63. package/src/services/chunkEncoder.test.ts +88 -0
  64. package/src/services/chunkEncoder.ts +35 -9
  65. package/src/services/chunkEncoder.types.ts +3 -0
  66. package/src/services/hdrCapture.test.ts +159 -0
  67. package/src/services/hdrCapture.ts +354 -0
  68. package/src/services/screenshotService.ts +271 -17
  69. package/src/services/streamingEncoder.test.ts +228 -0
  70. package/src/services/streamingEncoder.ts +153 -63
  71. package/src/services/videoFrameExtractor.ts +135 -31
  72. package/src/services/videoFrameInjector.ts +342 -0
  73. package/src/types.ts +34 -0
  74. package/src/utils/alphaBlit.test.ts +993 -0
  75. package/src/utils/alphaBlit.ts +643 -0
  76. package/src/utils/ffprobe.ts +22 -0
  77. package/src/utils/hdr.test.ts +191 -0
  78. package/src/utils/hdr.ts +137 -0
  79. package/src/utils/layerCompositor.test.ts +141 -0
  80. package/src/utils/layerCompositor.ts +58 -0
  81. package/src/utils/shaderTransitions.test.ts +674 -0
  82. package/src/utils/shaderTransitions.ts +1130 -0
  83. package/src/utils/uint16-alignment-audit.test.ts +125 -0
  84. package/tsconfig.json +2 -1
@@ -0,0 +1,643 @@
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 buildSrgbToHdrLut(transfer: "hlg" | "pq"): 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
+ // sRGB EOTF: signal → linear (range 0–1, relative to SDR white)
271
+ const v = i / 255;
272
+ const linear = v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
273
+
274
+ let signal: number;
275
+ if (transfer === "hlg") {
276
+ signal =
277
+ linear <= 1 / 12 ? Math.sqrt(3 * linear) : hlgA * Math.log(12 * linear - hlgB) + hlgC;
278
+ } else {
279
+ // PQ OETF: linear light (in SDR nits) → PQ signal
280
+ const Lp = Math.max(0, (linear * sdrNits) / pqMaxNits);
281
+ const Lm1 = Math.pow(Lp, pqM1);
282
+ signal = Math.pow((pqC1 + pqC2 * Lm1) / (1.0 + pqC3 * Lm1), pqM2);
283
+ }
284
+
285
+ lut[i] = Math.min(65535, Math.round(signal * 65535));
286
+ }
287
+
288
+ return lut;
289
+ }
290
+
291
+ const SRGB_TO_HLG = buildSrgbToHdrLut("hlg");
292
+ const SRGB_TO_PQ = buildSrgbToHdrLut("pq");
293
+
294
+ /** Select the correct sRGB→HDR LUT for the given transfer function. */
295
+ export function getSrgbToHdrLut(transfer: "hlg" | "pq"): Uint16Array {
296
+ return transfer === "pq" ? SRGB_TO_PQ : SRGB_TO_HLG;
297
+ }
298
+
299
+ // ── Alpha compositing ─────────────────────────────────────────────────────────
300
+
301
+ /**
302
+ * Alpha-composite a DOM RGBA overlay (8-bit sRGB) onto an HDR canvas
303
+ * (rgb48le) in-place.
304
+ *
305
+ * DOM pixels are converted from sRGB to the target HDR signal space (HLG or PQ)
306
+ * before blending so the composited output is uniformly encoded. Without this
307
+ * conversion, sRGB content appears orange/washed in HDR playback.
308
+ *
309
+ * @param domRgba Raw RGBA pixel data from decodePng() — width*height*4 bytes
310
+ * @param canvas HDR canvas in rgb48le format — width*height*6 bytes, mutated in-place
311
+ * @param width Canvas width in pixels
312
+ * @param height Canvas height in pixels
313
+ * @param transfer HDR transfer function — selects the correct sRGB→HDR LUT
314
+ */
315
+ export function blitRgba8OverRgb48le(
316
+ domRgba: Uint8Array,
317
+ canvas: Buffer,
318
+ width: number,
319
+ height: number,
320
+ transfer: "hlg" | "pq" = "hlg",
321
+ ): void {
322
+ const pixelCount = width * height;
323
+ const lut = getSrgbToHdrLut(transfer);
324
+
325
+ for (let i = 0; i < pixelCount; i++) {
326
+ const da = domRgba[i * 4 + 3] ?? 0;
327
+
328
+ if (da === 0) {
329
+ continue;
330
+ } else if (da === 255) {
331
+ const r16 = lut[domRgba[i * 4 + 0] ?? 0] ?? 0;
332
+ const g16 = lut[domRgba[i * 4 + 1] ?? 0] ?? 0;
333
+ const b16 = lut[domRgba[i * 4 + 2] ?? 0] ?? 0;
334
+ canvas.writeUInt16LE(r16, i * 6);
335
+ canvas.writeUInt16LE(g16, i * 6 + 2);
336
+ canvas.writeUInt16LE(b16, i * 6 + 4);
337
+ } else {
338
+ const alpha = da / 255;
339
+ const invAlpha = 1 - alpha;
340
+
341
+ const hdrR = (canvas[i * 6 + 0] ?? 0) | ((canvas[i * 6 + 1] ?? 0) << 8);
342
+ const hdrG = (canvas[i * 6 + 2] ?? 0) | ((canvas[i * 6 + 3] ?? 0) << 8);
343
+ const hdrB = (canvas[i * 6 + 4] ?? 0) | ((canvas[i * 6 + 5] ?? 0) << 8);
344
+
345
+ const domR = lut[domRgba[i * 4 + 0] ?? 0] ?? 0;
346
+ const domG = lut[domRgba[i * 4 + 1] ?? 0] ?? 0;
347
+ const domB = lut[domRgba[i * 4 + 2] ?? 0] ?? 0;
348
+
349
+ canvas.writeUInt16LE(Math.round(domR * alpha + hdrR * invAlpha), i * 6);
350
+ canvas.writeUInt16LE(Math.round(domG * alpha + hdrG * invAlpha), i * 6 + 2);
351
+ canvas.writeUInt16LE(Math.round(domB * alpha + hdrB * invAlpha), i * 6 + 4);
352
+ }
353
+ }
354
+ }
355
+
356
+ // ── Rounded-rectangle mask ───────────────────────────────────────────────────
357
+
358
+ /** Anti-aliased alpha for a point at distance `dist` from a corner circle of radius `r`. */
359
+ function cornerAlpha(px: number, py: number, cx: number, cy: number, r: number): number {
360
+ const dx = px - cx;
361
+ const dy = py - cy;
362
+ const dist = Math.sqrt(dx * dx + dy * dy);
363
+ if (dist > r + 0.5) return 0;
364
+ if (dist > r - 0.5) return r + 0.5 - dist;
365
+ return 1;
366
+ }
367
+
368
+ /**
369
+ * Compute the alpha (0.0–1.0) for a point inside a rounded rectangle.
370
+ * Returns 1.0 for interior pixels, 0.0 for exterior, and a smooth
371
+ * transition at the corner edges (1px anti-aliasing).
372
+ *
373
+ * @param px X coordinate (continuous, e.g. pixel center or subpixel)
374
+ * @param py Y coordinate
375
+ * @param w Rectangle width
376
+ * @param h Rectangle height
377
+ * @param radii Corner radii [topLeft, topRight, bottomRight, bottomLeft]
378
+ */
379
+ export function roundedRectAlpha(
380
+ px: number,
381
+ py: number,
382
+ w: number,
383
+ h: number,
384
+ radii: [number, number, number, number],
385
+ ): number {
386
+ const [tl, tr, br, bl] = radii;
387
+ if (px < tl && py < tl) return cornerAlpha(px, py, tl, tl, tl);
388
+ if (px >= w - tr && py < tr) return cornerAlpha(px, py, w - tr, tr, tr);
389
+ if (px >= w - br && py >= h - br) return cornerAlpha(px, py, w - br, h - br, br);
390
+ if (px < bl && py >= h - bl) return cornerAlpha(px, py, bl, h - bl, bl);
391
+ return 1;
392
+ }
393
+
394
+ // ── Positioned HDR region copy ────────────────────────────────────────────────
395
+
396
+ /**
397
+ * Copy a rectangular region of an rgb48le source onto an rgb48le canvas
398
+ * at position (dx, dy). Clips to canvas bounds. Optional opacity blending
399
+ * (0.0–1.0) over existing canvas content.
400
+ *
401
+ * @param canvas Destination rgb48le buffer (canvasWidth * canvasHeight * 6 bytes)
402
+ * @param source Source rgb48le buffer (sw * sh * 6 bytes)
403
+ * @param dx Destination X offset on canvas
404
+ * @param dy Destination Y offset on canvas
405
+ * @param sw Source width in pixels
406
+ * @param sh Source height in pixels
407
+ * @param canvasWidth Canvas width in pixels (needed for stride calculation)
408
+ * @param canvasHeight Canvas height in pixels (used to clip the destination region)
409
+ * @param opacity Optional opacity 0.0–1.0 (default 1.0 = fully opaque copy)
410
+ */
411
+ export function blitRgb48leRegion(
412
+ canvas: Buffer,
413
+ source: Buffer,
414
+ dx: number,
415
+ dy: number,
416
+ sw: number,
417
+ sh: number,
418
+ canvasWidth: number,
419
+ canvasHeight: number,
420
+ opacity?: number,
421
+ borderRadius?: [number, number, number, number],
422
+ ): void {
423
+ if (sw <= 0 || sh <= 0) return;
424
+
425
+ const op = opacity ?? 1.0;
426
+
427
+ const x0 = Math.max(0, dx);
428
+ const y0 = Math.max(0, dy);
429
+ const x1 = Math.min(canvasWidth, dx + sw);
430
+ const y1 = Math.min(canvasHeight, dy + sh);
431
+ if (x0 >= x1 || y0 >= y1) return;
432
+
433
+ const clippedW = x1 - x0;
434
+ const srcOffsetX = x0 - dx;
435
+ const srcOffsetY = y0 - dy;
436
+
437
+ const hasMask = borderRadius !== undefined;
438
+
439
+ if (op >= 0.999 && !hasMask) {
440
+ for (let y = 0; y < y1 - y0; y++) {
441
+ const srcRowOff = ((srcOffsetY + y) * sw + srcOffsetX) * 6;
442
+ const dstRowOff = ((y0 + y) * canvasWidth + x0) * 6;
443
+ source.copy(canvas, dstRowOff, srcRowOff, srcRowOff + clippedW * 6);
444
+ }
445
+ } else {
446
+ for (let y = 0; y < y1 - y0; y++) {
447
+ for (let x = 0; x < clippedW; x++) {
448
+ let effectiveOp = op;
449
+ if (hasMask) {
450
+ const ma = roundedRectAlpha(srcOffsetX + x, srcOffsetY + y, sw, sh, borderRadius);
451
+ if (ma <= 0) continue;
452
+ effectiveOp *= ma;
453
+ }
454
+
455
+ const srcOff = ((srcOffsetY + y) * sw + srcOffsetX + x) * 6;
456
+ const dstOff = ((y0 + y) * canvasWidth + x0 + x) * 6;
457
+
458
+ if (effectiveOp >= 0.999) {
459
+ source.copy(canvas, dstOff, srcOff, srcOff + 6);
460
+ } else {
461
+ const invEff = 1 - effectiveOp;
462
+ const sr = source.readUInt16LE(srcOff);
463
+ const sg = source.readUInt16LE(srcOff + 2);
464
+ const sb = source.readUInt16LE(srcOff + 4);
465
+ const dr = canvas.readUInt16LE(dstOff);
466
+ const dg = canvas.readUInt16LE(dstOff + 2);
467
+ const db = canvas.readUInt16LE(dstOff + 4);
468
+ canvas.writeUInt16LE(Math.round(sr * effectiveOp + dr * invEff), dstOff);
469
+ canvas.writeUInt16LE(Math.round(sg * effectiveOp + dg * invEff), dstOff + 2);
470
+ canvas.writeUInt16LE(Math.round(sb * effectiveOp + db * invEff), dstOff + 4);
471
+ }
472
+ }
473
+ }
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Apply a 2D affine transform to an rgb48le source and composite onto a canvas.
479
+ *
480
+ * For each destination pixel, the inverse transform maps back to source coordinates.
481
+ * Bilinear interpolation samples the 4 nearest source pixels for smooth scaling/rotation.
482
+ *
483
+ * @param canvas Destination rgb48le buffer, mutated in-place
484
+ * @param source Source rgb48le buffer (srcW * srcH * 6 bytes)
485
+ * @param matrix CSS transform matrix [a, b, c, d, tx, ty]
486
+ * @param srcW Source width in pixels
487
+ * @param srcH Source height in pixels
488
+ * @param canvasW Canvas width in pixels
489
+ * @param canvasH Canvas height in pixels
490
+ * @param opacity Optional opacity 0.0–1.0 (default 1.0)
491
+ */
492
+ export function blitRgb48leAffine(
493
+ canvas: Buffer,
494
+ source: Buffer,
495
+ matrix: number[],
496
+ srcW: number,
497
+ srcH: number,
498
+ canvasW: number,
499
+ canvasH: number,
500
+ opacity?: number,
501
+ borderRadius?: [number, number, number, number],
502
+ ): void {
503
+ const a = matrix[0];
504
+ const b = matrix[1];
505
+ const c = matrix[2];
506
+ const d = matrix[3];
507
+ const tx = matrix[4];
508
+ const ty = matrix[5];
509
+ if (
510
+ a === undefined ||
511
+ b === undefined ||
512
+ c === undefined ||
513
+ d === undefined ||
514
+ tx === undefined ||
515
+ ty === undefined
516
+ )
517
+ return;
518
+
519
+ // Invert the 2x2 part of the affine matrix
520
+ const det = a * d - b * c;
521
+ if (Math.abs(det) < 1e-10) return; // degenerate matrix
522
+
523
+ const invA = d / det;
524
+ const invB = -b / det;
525
+ const invC = -c / det;
526
+ const invD = a / det;
527
+ const invTx = -(invA * tx + invC * ty);
528
+ const invTy = -(invB * tx + invD * ty);
529
+
530
+ const op = opacity ?? 1.0;
531
+
532
+ const hasMask = borderRadius !== undefined;
533
+
534
+ // Compute bounding box of transformed source on canvas
535
+ const corners = [
536
+ [tx, ty],
537
+ [a * srcW + tx, b * srcW + ty],
538
+ [c * srcH + tx, d * srcH + ty],
539
+ [a * srcW + c * srcH + tx, b * srcW + d * srcH + ty],
540
+ ];
541
+ let minX = canvasW,
542
+ maxX = 0,
543
+ minY = canvasH,
544
+ maxY = 0;
545
+ for (const corner of corners) {
546
+ const cx = corner[0] ?? 0;
547
+ const cy = corner[1] ?? 0;
548
+ if (cx < minX) minX = cx;
549
+ if (cx > maxX) maxX = cx;
550
+ if (cy < minY) minY = cy;
551
+ if (cy > maxY) maxY = cy;
552
+ }
553
+ const startX = Math.max(0, Math.floor(minX));
554
+ const endX = Math.min(canvasW, Math.ceil(maxX));
555
+ const startY = Math.max(0, Math.floor(minY));
556
+ const endY = Math.min(canvasH, Math.ceil(maxY));
557
+
558
+ for (let dy = startY; dy < endY; dy++) {
559
+ for (let dx = startX; dx < endX; dx++) {
560
+ const sx = invA * dx + invC * dy + invTx;
561
+ const sy = invB * dx + invD * dy + invTy;
562
+
563
+ if (sx < 0 || sy < 0 || sx >= srcW || sy >= srcH) continue;
564
+
565
+ // Apply rounded-rect mask in source coordinates
566
+ let effectiveOp = op;
567
+ if (hasMask) {
568
+ const ma = roundedRectAlpha(sx, sy, srcW, srcH, borderRadius);
569
+ if (ma <= 0) continue;
570
+ effectiveOp *= ma;
571
+ }
572
+
573
+ const x0 = Math.floor(sx);
574
+ const y0 = Math.floor(sy);
575
+ const fx = sx - x0;
576
+ const fy = sy - y0;
577
+ const x1 = Math.min(x0 + 1, srcW - 1);
578
+ const y1 = Math.min(y0 + 1, srcH - 1);
579
+
580
+ const off00 = (y0 * srcW + x0) * 6;
581
+ const off10 = (y0 * srcW + x1) * 6;
582
+ const off01 = (y1 * srcW + x0) * 6;
583
+ const off11 = (y1 * srcW + x1) * 6;
584
+
585
+ const w00 = (1 - fx) * (1 - fy);
586
+ const w10 = fx * (1 - fy);
587
+ const w01 = (1 - fx) * fy;
588
+ const w11 = fx * fy;
589
+
590
+ const sr =
591
+ source.readUInt16LE(off00) * w00 +
592
+ source.readUInt16LE(off10) * w10 +
593
+ source.readUInt16LE(off01) * w01 +
594
+ source.readUInt16LE(off11) * w11;
595
+ const sg =
596
+ source.readUInt16LE(off00 + 2) * w00 +
597
+ source.readUInt16LE(off10 + 2) * w10 +
598
+ source.readUInt16LE(off01 + 2) * w01 +
599
+ source.readUInt16LE(off11 + 2) * w11;
600
+ const sb =
601
+ source.readUInt16LE(off00 + 4) * w00 +
602
+ source.readUInt16LE(off10 + 4) * w10 +
603
+ source.readUInt16LE(off01 + 4) * w01 +
604
+ source.readUInt16LE(off11 + 4) * w11;
605
+
606
+ const dstOff = (dy * canvasW + dx) * 6;
607
+
608
+ if (effectiveOp >= 0.999) {
609
+ canvas.writeUInt16LE(Math.round(sr), dstOff);
610
+ canvas.writeUInt16LE(Math.round(sg), dstOff + 2);
611
+ canvas.writeUInt16LE(Math.round(sb), dstOff + 4);
612
+ } else {
613
+ const invEff = 1 - effectiveOp;
614
+ const dr = canvas.readUInt16LE(dstOff);
615
+ const dg = canvas.readUInt16LE(dstOff + 2);
616
+ const db = canvas.readUInt16LE(dstOff + 4);
617
+ canvas.writeUInt16LE(Math.round(sr * effectiveOp + dr * invEff), dstOff);
618
+ canvas.writeUInt16LE(Math.round(sg * effectiveOp + dg * invEff), dstOff + 2);
619
+ canvas.writeUInt16LE(Math.round(sb * effectiveOp + db * invEff), dstOff + 4);
620
+ }
621
+ }
622
+ }
623
+ }
624
+
625
+ /**
626
+ * Parse a CSS `matrix(a,b,c,d,e,f)` string into a 6-element array.
627
+ * Returns null for "none", empty, or unsupported formats (matrix3d).
628
+ *
629
+ * The array maps to the CSS matrix: [a, b, c, d, tx, ty] where:
630
+ * | a c tx | (a=scaleX, b=skewY, c=skewX, d=scaleY, tx/ty=translate)
631
+ * | b d ty |
632
+ * | 0 0 1 |
633
+ */
634
+ export function parseTransformMatrix(css: string): number[] | null {
635
+ if (!css || css === "none") return null;
636
+ const match = css.match(
637
+ /^matrix\(\s*([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,]+),\s*([^,)]+)\s*\)$/,
638
+ );
639
+ if (!match) return null;
640
+ const values = match.slice(1, 7).map(Number);
641
+ if (!values.every(Number.isFinite)) return null;
642
+ return values;
643
+ }
@@ -42,6 +42,15 @@ function parseProbeJson(stdout: string): FFProbeOutput {
42
42
  const videoMetadataCache = new Map<string, Promise<VideoMetadata>>();
43
43
  const audioMetadataCache = new Map<string, Promise<AudioMetadata>>();
44
44
 
45
+ export interface VideoColorSpace {
46
+ /** Color transfer characteristics, e.g. "bt709", "smpte2084", "arib-std-b67" */
47
+ colorTransfer: string;
48
+ /** Color primaries, e.g. "bt709", "bt2020" */
49
+ colorPrimaries: string;
50
+ /** Color matrix/space, e.g. "bt709", "bt2020nc" */
51
+ colorSpace: string;
52
+ }
53
+
45
54
  export interface VideoMetadata {
46
55
  durationSeconds: number;
47
56
  width: number;
@@ -51,6 +60,8 @@ export interface VideoMetadata {
51
60
  hasAudio: boolean;
52
61
  /** True when r_frame_rate and avg_frame_rate differ significantly (>10%), indicating variable frame rate. */
53
62
  isVFR: boolean;
63
+ /** Color space info from the video stream. Null if ffprobe didn't report it. */
64
+ colorSpace: VideoColorSpace | null;
54
65
  }
55
66
 
56
67
  export interface AudioMetadata {
@@ -70,6 +81,9 @@ interface FFProbeStream {
70
81
  avg_frame_rate?: string;
71
82
  sample_rate?: string;
72
83
  channels?: number;
84
+ color_transfer?: string;
85
+ color_primaries?: string;
86
+ color_space?: string;
73
87
  }
74
88
 
75
89
  interface FFProbeFormat {
@@ -117,6 +131,11 @@ export async function extractVideoMetadata(filePath: string): Promise<VideoMetad
117
131
  // VFR: r_frame_rate (max/nominal) differs from avg_frame_rate (actual average) by >10%
118
132
  const isVFR = rFps > 0 && avgFps > 0 && Math.abs(rFps - avgFps) / Math.max(rFps, avgFps) > 0.1;
119
133
 
134
+ const colorTransfer = videoStream.color_transfer || "";
135
+ const colorPrimaries = videoStream.color_primaries || "";
136
+ const colorSpaceVal = videoStream.color_space || "";
137
+ const hasColorInfo = !!(colorTransfer || colorPrimaries || colorSpaceVal);
138
+
120
139
  return {
121
140
  durationSeconds: output.format.duration ? parseFloat(output.format.duration) : 0,
122
141
  width: videoStream.width || 0,
@@ -125,6 +144,9 @@ export async function extractVideoMetadata(filePath: string): Promise<VideoMetad
125
144
  videoCodec: videoStream.codec_name || "unknown",
126
145
  hasAudio: output.streams.some((s) => s.codec_type === "audio"),
127
146
  isVFR,
147
+ colorSpace: hasColorInfo
148
+ ? { colorTransfer, colorPrimaries, colorSpace: colorSpaceVal }
149
+ : null,
128
150
  };
129
151
  })();
130
152