@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,354 @@
1
+ /// <reference types="@webgpu/types" />
2
+ /**
3
+ * HDR Capture Service
4
+ *
5
+ * Captures HDR video frames via WebGPU float16 readback.
6
+ *
7
+ * The pipeline:
8
+ * 1. FFmpeg extracts raw HDR pixels (rgba64le) from video sources
9
+ * 2. Node converts HLG/PQ signal → linear light → float16
10
+ * 3. writeTexture uploads float16 data to WebGPU rgba16float texture
11
+ * 4. (Optional) WebGPU shader applies GSAP CSS transform
12
+ * 5. readback extracts float16 RGBA via base64 transfer
13
+ * 6. Node converts linear float16 → PQ signal → pipe to FFmpeg H.265
14
+ *
15
+ * Requirements:
16
+ * - Headed Chrome (not headless) — WebGPU unavailable in headless mode
17
+ * - GPU access (Metal on macOS, Vulkan+NVIDIA on Linux)
18
+ *
19
+ * Performance: ~6 fps at 1080x1920 via base64 transfer.
20
+ */
21
+
22
+ import type { Page, Browser, PuppeteerNode } from "puppeteer-core";
23
+ import { existsSync, readdirSync } from "fs";
24
+ import { join } from "path";
25
+ import { homedir } from "os";
26
+
27
+ // ── PQ (SMPTE 2084) OETF ─────────────────────────────────────────────────────
28
+
29
+ const PQ_M1 = 0.1593017578125;
30
+ const PQ_M2 = 78.84375;
31
+ const PQ_C1 = 0.8359375;
32
+ const PQ_C2 = 18.8515625;
33
+ const PQ_C3 = 18.6875;
34
+ const PQ_MAX_NITS = 10000.0;
35
+ const SDR_NITS = 203.0;
36
+
37
+ function linearToPQ(L: number): number {
38
+ const Lp = Math.max(0, (L * SDR_NITS) / PQ_MAX_NITS);
39
+ const Lm1 = Math.pow(Lp, PQ_M1);
40
+ return Math.pow((PQ_C1 + PQ_C2 * Lm1) / (1.0 + PQ_C3 * Lm1), PQ_M2);
41
+ }
42
+
43
+ function float16Decode(h: number): number {
44
+ const sign = (h >> 15) & 1;
45
+ const exp = (h >> 10) & 0x1f;
46
+ const frac = h & 0x3ff;
47
+ if (exp === 0) return (sign ? -1 : 1) * Math.pow(2, -14) * (frac / 1024);
48
+ if (exp === 31) return frac ? NaN : sign ? -Infinity : Infinity;
49
+ return (sign ? -1 : 1) * Math.pow(2, exp - 15) * (1 + frac / 1024);
50
+ }
51
+
52
+ // ── Browser-side interface ────────────────────────────────────────────────────
53
+
54
+ interface HdrCaptureRuntime {
55
+ uploadAndReadback(float16Base64: string): Promise<{ base64: string; bytesPerRow: number }>;
56
+ }
57
+
58
+ // ── Initialization ────────────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * Inject the WebGPU HDR readback runtime into the page.
62
+ *
63
+ * Creates an rgba16float render texture that accepts writeTexture uploads
64
+ * and provides readback via base64 transfer.
65
+ */
66
+ export async function initHdrReadback(page: Page, width: number, height: number): Promise<boolean> {
67
+ return page.evaluate(
68
+ async (w: number, h: number): Promise<boolean> => {
69
+ if (!navigator.gpu) return false;
70
+
71
+ const adapter = await navigator.gpu.requestAdapter();
72
+ if (!adapter) return false;
73
+
74
+ const device = await adapter.requestDevice();
75
+
76
+ const bytesPerPixel = 8; // rgba16float = 4 channels × 2 bytes
77
+ const bytesPerRow = Math.ceil((w * bytesPerPixel) / 256) * 256;
78
+
79
+ // Render texture — includes COPY_DST for writeTexture uploads
80
+ const renderTexture = device.createTexture({
81
+ size: [w, h],
82
+ format: "rgba16float",
83
+ usage:
84
+ GPUTextureUsage.RENDER_ATTACHMENT |
85
+ GPUTextureUsage.COPY_SRC |
86
+ GPUTextureUsage.COPY_DST |
87
+ GPUTextureUsage.TEXTURE_BINDING,
88
+ });
89
+
90
+ const readBuffer = device.createBuffer({
91
+ size: bytesPerRow * h,
92
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
93
+ });
94
+
95
+ const captureRuntime = {
96
+ device,
97
+ renderTexture,
98
+ readBuffer,
99
+ bytesPerRow,
100
+ width: w,
101
+ height: h,
102
+
103
+ /**
104
+ * Upload pre-converted float16 RGBA data and read it back.
105
+ * The float16 data must be row-aligned to bytesPerRow.
106
+ *
107
+ * Input: base64-encoded Uint16Array (float16 RGBA, row-padded)
108
+ * Output: base64-encoded readback of the same texture
109
+ */
110
+ async uploadAndReadback(
111
+ float16Base64: string,
112
+ ): Promise<{ base64: string; bytesPerRow: number }> {
113
+ // Decode base64 → Uint8Array
114
+ const binary = atob(float16Base64);
115
+ const bytes = new Uint8Array(binary.length);
116
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
117
+
118
+ // Upload to texture
119
+ device.queue.writeTexture(
120
+ { texture: renderTexture },
121
+ bytes.buffer,
122
+ { bytesPerRow, rowsPerImage: h },
123
+ [w, h],
124
+ );
125
+
126
+ // Readback
127
+ const encoder = device.createCommandEncoder();
128
+ encoder.copyTextureToBuffer(
129
+ { texture: renderTexture },
130
+ { buffer: readBuffer, bytesPerRow },
131
+ [w, h],
132
+ );
133
+ device.queue.submit([encoder.finish()]);
134
+
135
+ await readBuffer.mapAsync(GPUMapMode.READ);
136
+ const readBytes = new Uint8Array(readBuffer.getMappedRange().slice(0));
137
+ readBuffer.unmap();
138
+
139
+ // Base64 encode in chunks
140
+ let b64 = "";
141
+ const chunkSize = 32768;
142
+ for (let i = 0; i < readBytes.length; i += chunkSize) {
143
+ const slice = readBytes.subarray(i, Math.min(i + chunkSize, readBytes.length));
144
+ b64 += String.fromCharCode(...slice);
145
+ }
146
+
147
+ return { base64: btoa(b64), bytesPerRow };
148
+ },
149
+ };
150
+
151
+ (window as unknown as Record<string, unknown>).__hfHdrCapture = captureRuntime;
152
+ return true;
153
+ },
154
+ width,
155
+ height,
156
+ );
157
+ }
158
+
159
+ // ── HDR frame conversion ──────────────────────────────────────────────────────
160
+
161
+ /**
162
+ * Convert raw rgba64le pixels (from FFmpeg) to a base64 string for FFmpeg encoding.
163
+ *
164
+ * For HLG sources: the pixel values are already HLG-encoded. We pass them through
165
+ * as-is (normalized to 16-bit) and tag the output as HLG. No OETF conversion needed —
166
+ * the HLG signal values ARE the correct encoding. Converting to linear and back to
167
+ * PQ produces worse results because every viewer's PQ→display tone-mapping differs
168
+ * from its HLG→display tone-mapping.
169
+ *
170
+ * The WebGPU round-trip is skipped for pass-through — the pixels go directly from
171
+ * FFmpeg extraction to FFmpeg encoding. WebGPU is only needed when transforms
172
+ * (scale, rotate, opacity from GSAP) must be applied to the HDR pixels.
173
+ */
174
+ export function convertHdrFrameToRgb48le(
175
+ rawRgba64le: Buffer,
176
+ width: number,
177
+ height: number,
178
+ ): Buffer {
179
+ const input = new Uint16Array(
180
+ rawRgba64le.buffer,
181
+ rawRgba64le.byteOffset,
182
+ rawRgba64le.byteLength / 2,
183
+ );
184
+
185
+ // Convert RGBA → RGB (drop alpha) for rgb48le output
186
+ const output = Buffer.alloc(width * height * 6);
187
+
188
+ for (let y = 0; y < height; y++) {
189
+ for (let x = 0; x < width; x++) {
190
+ const srcIdx = (y * width + x) * 4;
191
+ const dstIdx = (y * width + x) * 6;
192
+ output.writeUInt16LE(input[srcIdx] ?? 0, dstIdx);
193
+ output.writeUInt16LE(input[srcIdx + 1] ?? 0, dstIdx + 2);
194
+ output.writeUInt16LE(input[srcIdx + 2] ?? 0, dstIdx + 4);
195
+ }
196
+ }
197
+
198
+ return output;
199
+ }
200
+
201
+ // ── Frame upload + readback ───────────────────────────────────────────────────
202
+
203
+ /**
204
+ * Upload a float16 frame to WebGPU and read it back.
205
+ * Call after converting with convertHdrFrameToFloat16Base64.
206
+ */
207
+ export async function uploadAndReadbackHdrFrame(
208
+ page: Page,
209
+ float16Base64: string,
210
+ ): Promise<{ rawBuffer: Buffer; bytesPerRow: number }> {
211
+ const result = await page.evaluate(
212
+ async (b64: string): Promise<{ base64: string; bytesPerRow: number }> => {
213
+ const hdr = (window as unknown as Record<string, unknown>).__hfHdrCapture as
214
+ | HdrCaptureRuntime
215
+ | undefined;
216
+ if (!hdr) throw new Error("HDR capture not initialized");
217
+ return hdr.uploadAndReadback(b64);
218
+ },
219
+ float16Base64,
220
+ );
221
+
222
+ return {
223
+ rawBuffer: Buffer.from(result.base64, "base64"),
224
+ bytesPerRow: result.bytesPerRow,
225
+ };
226
+ }
227
+
228
+ // ── PQ conversion ─────────────────────────────────────────────────────────────
229
+
230
+ /**
231
+ * Convert float16 RGBA readback to PQ-encoded rgb48le for FFmpeg.
232
+ */
233
+ export function float16ToPqRgb(
234
+ rawBuffer: Buffer,
235
+ bytesPerRow: number,
236
+ width: number,
237
+ height: number,
238
+ ): Buffer {
239
+ const data = new Uint16Array(rawBuffer.buffer, rawBuffer.byteOffset, rawBuffer.byteLength / 2);
240
+ const channelsPerRow = bytesPerRow / 2;
241
+ const output = Buffer.alloc(width * height * 6);
242
+
243
+ for (let y = 0; y < height; y++) {
244
+ for (let x = 0; x < width; x++) {
245
+ const srcIdx = y * channelsPerRow + x * 4;
246
+ const r = float16Decode(data[srcIdx] ?? 0);
247
+ const g = float16Decode(data[srcIdx + 1] ?? 0);
248
+ const b = float16Decode(data[srcIdx + 2] ?? 0);
249
+
250
+ const dstIdx = (y * width + x) * 6;
251
+ output.writeUInt16LE(Math.round(Math.min(1.0, linearToPQ(r)) * 65535), dstIdx);
252
+ output.writeUInt16LE(Math.round(Math.min(1.0, linearToPQ(g)) * 65535), dstIdx + 2);
253
+ output.writeUInt16LE(Math.round(Math.min(1.0, linearToPQ(b)) * 65535), dstIdx + 4);
254
+ }
255
+ }
256
+
257
+ return output;
258
+ }
259
+
260
+ // ── Chrome launch ─────────────────────────────────────────────────────────────
261
+
262
+ function resolveHeadedChromePath(): string | undefined {
263
+ const baseDir = join(homedir(), ".cache", "puppeteer", "chrome");
264
+ if (!existsSync(baseDir)) return undefined;
265
+ const versions = readdirSync(baseDir).sort().reverse();
266
+ for (const version of versions) {
267
+ const candidates = [
268
+ join(
269
+ baseDir,
270
+ version,
271
+ "chrome-mac-arm64",
272
+ "Google Chrome for Testing.app",
273
+ "Contents",
274
+ "MacOS",
275
+ "Google Chrome for Testing",
276
+ ),
277
+ join(
278
+ baseDir,
279
+ version,
280
+ "chrome-mac-x64",
281
+ "Google Chrome for Testing.app",
282
+ "Contents",
283
+ "MacOS",
284
+ "Google Chrome for Testing",
285
+ ),
286
+ join(baseDir, version, "chrome-linux64", "chrome"),
287
+ join(baseDir, version, "chrome-win64", "chrome.exe"),
288
+ ];
289
+ for (const binary of candidates) {
290
+ if (existsSync(binary)) return binary;
291
+ }
292
+ }
293
+ return undefined;
294
+ }
295
+
296
+ /**
297
+ * Launch a headed Chrome browser with WebGPU enabled.
298
+ */
299
+ export async function launchHdrBrowser(
300
+ width: number,
301
+ height: number,
302
+ ): Promise<{ browser: Browser; page: Page }> {
303
+ let ppt: PuppeteerNode | undefined;
304
+ try {
305
+ const mod = await import("puppeteer" as string);
306
+ ppt = mod.default;
307
+ } catch (err) {
308
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
309
+ if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND") {
310
+ throw err;
311
+ }
312
+ const mod = await import("puppeteer-core");
313
+ ppt = mod.default;
314
+ }
315
+ if (!ppt) throw new Error("Neither puppeteer nor puppeteer-core found");
316
+
317
+ const chromePath = resolveHeadedChromePath();
318
+ if (!chromePath) {
319
+ throw new Error(
320
+ "[HDR] No Chrome binary found. Install: npx @puppeteer/browsers install chrome@stable",
321
+ );
322
+ }
323
+
324
+ const browser = await ppt.launch({
325
+ headless: false,
326
+ executablePath: chromePath,
327
+ args: buildHdrChromeArgs(width, height),
328
+ });
329
+
330
+ const page = await browser.newPage();
331
+ await page.setViewport({ width, height });
332
+
333
+ return { browser, page };
334
+ }
335
+
336
+ export function buildHdrChromeArgs(width: number, height: number): string[] {
337
+ return [
338
+ "--enable-unsafe-webgpu",
339
+ "--no-sandbox",
340
+ "--disable-setuid-sandbox",
341
+ "--window-position=-10000,-10000",
342
+ `--window-size=${width},${height}`,
343
+ "--disable-background-timer-throttling",
344
+ "--disable-backgrounding-occluded-windows",
345
+ "--disable-renderer-backgrounding",
346
+ "--disable-background-media-suspend",
347
+ "--disable-extensions",
348
+ "--disable-component-update",
349
+ "--disable-default-apps",
350
+ "--disable-sync",
351
+ "--no-zygote",
352
+ "--force-gpu-mem-available-mb=4096",
353
+ ];
354
+ }
@@ -133,6 +133,233 @@ export async function pageScreenshotCapture(page: Page, options: CaptureOptions)
133
133
  return Buffer.from(result.data, "base64");
134
134
  }
135
135
 
136
+ /**
137
+ * Capture a screenshot with transparent background (PNG + alpha channel).
138
+ *
139
+ * Used in the two-pass HDR compositing pipeline — captures DOM content
140
+ * (text, graphics, SDR overlays) with transparency where the background shows,
141
+ * so it can be overlaid on top of native HDR video frames in FFmpeg.
142
+ *
143
+ * Sets and restores the background color override on every call. For sessions
144
+ * that capture many frames, prefer calling initTransparentBackground() once
145
+ * at session init, then captureAlphaPng() per frame to avoid the 2× CDP
146
+ * round-trip overhead.
147
+ */
148
+ export async function captureScreenshotWithAlpha(
149
+ page: Page,
150
+ width: number,
151
+ height: number,
152
+ ): Promise<Buffer> {
153
+ const client = await getCdpSession(page);
154
+ // Force transparent background so the screenshot has a real alpha channel
155
+ await client.send("Emulation.setDefaultBackgroundColorOverride", {
156
+ color: { r: 0, g: 0, b: 0, a: 0 },
157
+ });
158
+ try {
159
+ const result = await client.send("Page.captureScreenshot", {
160
+ format: "png",
161
+ fromSurface: true,
162
+ captureBeyondViewport: false,
163
+ optimizeForSpeed: false, // `true` uses a zero-alpha-aware fast path that crushes real alpha values — observed empirically, CDP docs don't spell it out
164
+ clip: { x: 0, y: 0, width, height, scale: 1 },
165
+ });
166
+ return Buffer.from(result.data, "base64");
167
+ } finally {
168
+ // Restore opaque background even if captureScreenshot throws, otherwise
169
+ // subsequent opaque captures keep a transparent background.
170
+ await client.send("Emulation.setDefaultBackgroundColorOverride", {}).catch(() => {});
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Set the page background to transparent once for a dedicated HDR DOM session.
176
+ *
177
+ * Call this once after session initialization. Then use captureAlphaPng() per
178
+ * frame instead of captureScreenshotWithAlpha() to skip the per-frame CDP
179
+ * background override round-trips.
180
+ *
181
+ * Only use on sessions that are exclusively dedicated to transparent capture
182
+ * (e.g., the HDR two-pass DOM layer session) — the background will stay
183
+ * transparent for the lifetime of the session.
184
+ *
185
+ * NOTE on the injected stylesheet: `Emulation.setDefaultBackgroundColorOverride`
186
+ * only replaces the *default* page background. Compositions almost always set
187
+ * `body { background: ... }` and `#root { background: ... }`, which paint over
188
+ * the override and ruin alpha capture for layered HDR compositing — the
189
+ * composition root's full-frame background paints across the entire viewport
190
+ * and wipes out HDR content captured beneath it.
191
+ *
192
+ * We force `html`, `body`, and any element marked as a composition root
193
+ * (`[data-composition-id]`) to transparent. In HDR layered compositing the HDR
194
+ * video itself is the backdrop, so DOM layers must only contribute their
195
+ * foreground UI pixels — never a page-spanning solid backdrop.
196
+ */
197
+ export const TRANSPARENT_BG_STYLE_ID = "__hf_transparent_bg__";
198
+
199
+ export async function initTransparentBackground(page: Page): Promise<void> {
200
+ const client = await getCdpSession(page);
201
+ await client.send("Emulation.setDefaultBackgroundColorOverride", {
202
+ color: { r: 0, g: 0, b: 0, a: 0 },
203
+ });
204
+ await page.evaluate((styleId: string) => {
205
+ if (document.getElementById(styleId)) return;
206
+ const style = document.createElement("style");
207
+ style.id = styleId;
208
+ style.textContent =
209
+ "html,body,[data-composition-id]{background:transparent !important;background-color:transparent !important;background-image:none !important;}";
210
+ document.head.appendChild(style);
211
+ }, TRANSPARENT_BG_STYLE_ID);
212
+ }
213
+
214
+ /**
215
+ * Capture a transparent-background PNG screenshot without setting the
216
+ * background color override. Requires initTransparentBackground() to have
217
+ * been called once on this session.
218
+ *
219
+ * Faster than captureScreenshotWithAlpha() for per-frame use in the HDR
220
+ * two-pass compositing loop.
221
+ */
222
+ export async function captureAlphaPng(page: Page, width: number, height: number): Promise<Buffer> {
223
+ const client = await getCdpSession(page);
224
+ const result = await client.send("Page.captureScreenshot", {
225
+ format: "png",
226
+ fromSurface: true,
227
+ captureBeyondViewport: false,
228
+ optimizeForSpeed: false, // must be false to preserve alpha
229
+ clip: { x: 0, y: 0, width, height, scale: 1 },
230
+ });
231
+ return Buffer.from(result.data, "base64");
232
+ }
233
+
234
+ /**
235
+ * Stylesheet ID used by applyDomLayerMask / removeDomLayerMask. Exposed so
236
+ * tests can assert presence/absence of the mask between captures.
237
+ */
238
+ export const DOM_LAYER_MASK_STYLE_ID = "__hf_dom_layer_mask__";
239
+
240
+ /**
241
+ * Mask the DOM so a single layer screenshot captures ONLY the layer's pixels.
242
+ *
243
+ * The HDR layered compositor walks z-ordered layers and blits each one over a
244
+ * shared canvas. DOM layers are full-page screenshots — a naive screenshot
245
+ * captures every painted pixel on the page, which means root background +
246
+ * static overlays + sibling-scene content all overwrite previously composited
247
+ * HDR content beneath. The mask narrows each screenshot to the elements that
248
+ * actually belong to this layer.
249
+ *
250
+ * Strategy:
251
+ *
252
+ * 1. Inject a stylesheet that hides every body descendant
253
+ * (`body * { visibility: hidden !important }`) and re-shows the layer's
254
+ * elements (and their descendants and their injected `__render_frame_*`
255
+ * siblings) via `visibility: visible !important`. CSS `visibility: visible`
256
+ * on a descendant overrides an ancestor's `visibility: hidden`, so deep
257
+ * layer elements remain visible even though intermediate parents are
258
+ * hidden by the mass-hide rule.
259
+ * 2. Inline-hide each `extraHideId` (and its `__render_frame_*` sibling) with
260
+ * `visibility: hidden !important`. Inline `!important` beats stylesheet
261
+ * `!important`, so this overrides the show rule for elements that fall
262
+ * under a show selector but should NOT paint — typically other-layer
263
+ * elements that are descendants of a container layer (for example HDR
264
+ * videos and other-layer SDR videos are descendants of `#root` when we
265
+ * capture the root DOM layer).
266
+ *
267
+ * Only `visibility` is set on extraHideIds — never `opacity`. CSS opacity is
268
+ * multiplicative through the descendant chain and a descendant cannot escape
269
+ * an ancestor's `opacity: 0`. If `#root` is in `extraHideIds` and we set
270
+ * `opacity: 0` on it, every descendant — including `#vid-5-b` and its
271
+ * `__render_frame_vid-5-b__` IMG — becomes invisible even with
272
+ * `visibility: visible !important`. `visibility` does NOT have this problem:
273
+ * a descendant with `visibility: visible` overrides an ancestor's
274
+ * `visibility: hidden`.
275
+ *
276
+ * Layout is preserved (visibility doesn't trigger reflow), so border-radius
277
+ * clipping, overflow:hidden, and absolute positioning continue to apply to
278
+ * the visible layer elements. Opacity is also preserved — an ancestor at
279
+ * `opacity: 0` (e.g. an inactive scene during a transition) still
280
+ * propagates to its descendants, which is the desired behavior during
281
+ * cross-scene blends.
282
+ *
283
+ * Idempotent across calls: an existing mask stylesheet is removed before a
284
+ * new one is installed, so consecutive `applyDomLayerMask` invocations leave
285
+ * exactly one stylesheet attached.
286
+ */
287
+ export async function applyDomLayerMask(
288
+ page: Page,
289
+ showIds: string[],
290
+ extraHideIds: string[],
291
+ ): Promise<void> {
292
+ await page.evaluate(
293
+ (args: { show: string[]; hide: string[]; styleId: string }) => {
294
+ const existing = document.getElementById(args.styleId);
295
+ if (existing) existing.remove();
296
+
297
+ const showSelectors: string[] = [];
298
+ for (const id of args.show) {
299
+ const escaped = CSS.escape(id);
300
+ showSelectors.push(`#${escaped}`, `#${escaped} *`);
301
+ const renderEscaped = CSS.escape(`__render_frame_${id}__`);
302
+ showSelectors.push(`#${renderEscaped}`, `#${renderEscaped} *`);
303
+ }
304
+
305
+ const massHideRule = "body *{visibility:hidden !important;}";
306
+ const showRule =
307
+ showSelectors.length === 0
308
+ ? ""
309
+ : `${showSelectors.join(",")}{visibility:visible !important;}`;
310
+
311
+ const style = document.createElement("style");
312
+ style.id = args.styleId;
313
+ style.textContent = `${massHideRule}\n${showRule}`;
314
+ document.head.appendChild(style);
315
+
316
+ for (const id of args.hide) {
317
+ const el = document.getElementById(id);
318
+ if (el) {
319
+ el.style.setProperty("visibility", "hidden", "important");
320
+ }
321
+ const img = document.getElementById(`__render_frame_${id}__`);
322
+ if (img) {
323
+ img.style.setProperty("visibility", "hidden", "important");
324
+ }
325
+ }
326
+ },
327
+ { show: showIds, hide: extraHideIds, styleId: DOM_LAYER_MASK_STYLE_ID },
328
+ );
329
+ }
330
+
331
+ /**
332
+ * Tear down the mask installed by applyDomLayerMask.
333
+ *
334
+ * Removes the mask stylesheet and clears the inline `visibility` properties
335
+ * set on `extraHideIds` (and their `__render_frame_*` siblings).
336
+ *
337
+ * IMPORTANT: We do NOT strip inline `opacity` here. applyDomLayerMask only
338
+ * ever sets `visibility` (never `opacity`), so any inline opacity present on
339
+ * a wrapper was put there by user animation code (typically GSAP) and must
340
+ * survive across per-layer captures. GSAP's seek with suppress-events does
341
+ * not re-apply tweens when the timeline is already at the target time, so if
342
+ * we strip opacity here and then seek to the same time for the next layer,
343
+ * GSAP won't put it back and the wrapper will render fully opaque.
344
+ */
345
+ export async function removeDomLayerMask(page: Page, extraHideIds: string[]): Promise<void> {
346
+ await page.evaluate(
347
+ (args: { hide: string[]; styleId: string }) => {
348
+ const style = document.getElementById(args.styleId);
349
+ if (style) style.remove();
350
+ for (const id of args.hide) {
351
+ const el = document.getElementById(id);
352
+ if (el) {
353
+ el.style.removeProperty("visibility");
354
+ }
355
+ const img = document.getElementById(`__render_frame_${id}__`);
356
+ if (img) img.style.removeProperty("visibility");
357
+ }
358
+ },
359
+ { hide: extraHideIds, styleId: DOM_LAYER_MASK_STYLE_ID },
360
+ );
361
+ }
362
+
136
363
  export async function injectVideoFramesBatch(
137
364
  page: Page,
138
365
  updates: Array<{ videoId: string; dataUri: string }>,
@@ -148,6 +375,16 @@ export async function injectVideoFramesBatch(
148
375
  let img = video.nextElementSibling as HTMLImageElement | null;
149
376
  const isNewImage = !img || !img.classList.contains("__render_frame__");
150
377
  const computedStyle = window.getComputedStyle(video);
378
+ // GSAP seeks re-apply tween values during an active tween, but do not
379
+ // re-apply tweens that have already completed. After an opacity fade-in
380
+ // finishes, GSAP's last set value is overwritten on subsequent frames
381
+ // by the `opacity: 0 !important` we apply at the bottom of this
382
+ // function to hide the native <video>. That leaves `computedOpacity`
383
+ // stuck at 0 even though the user's intent is opacity 1 (the tween's
384
+ // end state). The `|| 1` fallback treats computedOpacity === 0 as a
385
+ // hidden-native-video artifact and recovers opacity 1, matching the
386
+ // final on-screen state for the vast majority of compositions.
387
+ // For active tweens in the [0,1] exclusive range this is a no-op.
151
388
  const computedOpacity = parseFloat(computedStyle.opacity) || 1;
152
389
  const sourceIsStatic = !computedStyle.position || computedStyle.position === "static";
153
390
 
@@ -160,16 +397,11 @@ export async function injectVideoFramesBatch(
160
397
  }
161
398
  if (!img) continue;
162
399
 
163
- if (!sourceIsStatic) {
164
- img.style.position = computedStyle.position;
165
- img.style.width = computedStyle.width;
166
- img.style.height = computedStyle.height;
167
- img.style.top = computedStyle.top;
168
- img.style.left = computedStyle.left;
169
- img.style.right = computedStyle.right;
170
- img.style.bottom = computedStyle.bottom;
171
- img.style.inset = computedStyle.inset;
172
- } else {
400
+ // Always use absolute positioning so the <img> overlays the <video>
401
+ // instead of flowing below it. With position:relative, both elements
402
+ // stack vertically — the <img> lands below the video and gets clipped
403
+ // by any overflow:hidden ancestor (e.g., border-radius wrappers).
404
+ {
173
405
  const videoRect = video.getBoundingClientRect();
174
406
  const offsetLeft = Number.isFinite(video.offsetLeft) ? video.offsetLeft : 0;
175
407
  const offsetTop = Number.isFinite(video.offsetTop) ? video.offsetTop : 0;
@@ -189,6 +421,14 @@ export async function injectVideoFramesBatch(
189
421
  img.style.zIndex = computedStyle.zIndex;
190
422
 
191
423
  for (const property of visualProperties) {
424
+ // Opacity is handled explicitly via `computedOpacity` below — copying
425
+ // via the generic loop would race against the opacity:0 hide applied
426
+ // to the <video> at the end of this function. GSAP may animate
427
+ // opacity either on a wrapper (the <img> inherits via the stacking
428
+ // context) or directly on the <video> (we must copy it to the <img>
429
+ // since they are siblings). Reading computedStyle.opacity before
430
+ // hiding the <video> handles both cases correctly.
431
+ if (property === "opacity") continue;
192
432
  if (
193
433
  sourceIsStatic &&
194
434
  (property === "top" ||
@@ -235,14 +475,28 @@ export async function syncVideoFrameVisibility(
235
475
  const active = new Set(ids);
236
476
  const videos = Array.from(document.querySelectorAll("video[data-start]")) as HTMLVideoElement[];
237
477
  for (const video of videos) {
238
- if (active.has(video.id)) continue;
239
- video.style.removeProperty("display");
240
- video.style.setProperty("visibility", "hidden", "important");
241
- video.style.setProperty("opacity", "0", "important");
242
- video.style.setProperty("pointer-events", "none", "important");
243
478
  const img = video.nextElementSibling as HTMLElement | null;
244
- if (img && img.classList.contains("__render_frame__")) {
245
- img.style.visibility = "hidden";
479
+ const hasImg = img && img.classList.contains("__render_frame__");
480
+ if (active.has(video.id)) {
481
+ // Active video: show injected <img>, hide native <video>.
482
+ // Do NOT clobber inline opacity here — GSAP-controlled opacity must
483
+ // survive until injectVideoFramesBatch reads it via getComputedStyle.
484
+ // visibility:hidden alone hides the native element without affecting
485
+ // its computed opacity.
486
+ video.style.setProperty("visibility", "hidden", "important");
487
+ video.style.setProperty("pointer-events", "none", "important");
488
+ if (hasImg) {
489
+ img.style.visibility = "visible";
490
+ }
491
+ } else {
492
+ // Inactive video: hide both
493
+ video.style.removeProperty("display");
494
+ video.style.setProperty("visibility", "hidden", "important");
495
+ video.style.setProperty("opacity", "0", "important");
496
+ video.style.setProperty("pointer-events", "none", "important");
497
+ if (hasImg) {
498
+ img.style.visibility = "hidden";
499
+ }
246
500
  }
247
501
  }
248
502
  }, activeVideoIds);