@hyperframes/engine 0.6.119 → 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,615 +0,0 @@
1
- /**
2
- * Screenshot Service
3
- *
4
- * BeginFrame-based deterministic screenshot capture and video frame injection.
5
- */
6
-
7
- import { type Page } from "puppeteer-core";
8
- import { type CaptureOptions } from "../types.js";
9
- import { MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes/core";
10
-
11
- export const cdpSessionCache = new WeakMap<Page, import("puppeteer-core").CDPSession>();
12
-
13
- export async function getCdpSession(page: Page): Promise<import("puppeteer-core").CDPSession> {
14
- let client = cdpSessionCache.get(page);
15
- if (!client) {
16
- client = await page.createCDPSession();
17
- cdpSessionCache.set(page, client);
18
- }
19
- return client;
20
- }
21
-
22
- /**
23
- * BeginFrame result with screenshot data and damage detection.
24
- */
25
- export interface BeginFrameResult {
26
- buffer: Buffer;
27
- hasDamage: boolean;
28
- }
29
-
30
- /**
31
- * Capture a frame using HeadlessExperimental.beginFrame.
32
- *
33
- * This is an atomic operation: one CDP call runs a single layout-paint-composite
34
- * cycle and returns the screenshot + hasDamage boolean. Replaces the separate
35
- * settle → screenshot pipeline with a single deterministic render cycle.
36
- *
37
- * Requires chrome-headless-shell with --enable-begin-frame-control and
38
- * --deterministic-mode flags.
39
- */
40
- // Cache the last valid screenshot buffer per page for hasDamage=false frames.
41
- // When Chrome reports no visual change, we reuse the previous frame rather than
42
- // attempting Page.captureScreenshot (which times out in beginFrame mode since
43
- // the compositor is paused).
44
- const lastFrameCache = new WeakMap<Page, Buffer>();
45
-
46
- const PENDING_FRAME_RETRIES = 5;
47
-
48
- async function sendBeginFrame(
49
- client: import("puppeteer-core").CDPSession,
50
- params: Parameters<typeof client.send<"HeadlessExperimental.beginFrame">>[1],
51
- ) {
52
- for (let attempt = 0; ; attempt++) {
53
- try {
54
- return await client.send("HeadlessExperimental.beginFrame", params);
55
- } catch (err: unknown) {
56
- const msg = err instanceof Error ? err.message : String(err);
57
- const isPending = msg.includes("Another frame is pending");
58
- if (isPending && attempt < PENDING_FRAME_RETRIES) {
59
- await new Promise((r) => setTimeout(r, 50 * 2 ** attempt));
60
- continue;
61
- }
62
- if (isPending) {
63
- throw new Error(
64
- `[BeginFrame] Frame still pending after ${PENDING_FRAME_RETRIES} retries — CPU overloaded by parallel renders. ` +
65
- `Reduce concurrent renders or use --docker for isolation.`,
66
- );
67
- }
68
- throw err;
69
- }
70
- }
71
- }
72
-
73
- export async function beginFrameCapture(
74
- page: Page,
75
- options: CaptureOptions,
76
- frameTimeTicks: number,
77
- interval: number,
78
- ): Promise<BeginFrameResult> {
79
- const client = await getCdpSession(page);
80
-
81
- const isPng = options.format === "png";
82
- const screenshot = {
83
- format: isPng ? "png" : "jpeg",
84
- quality: isPng ? undefined : (options.quality ?? 80),
85
- optimizeForSpeed: true,
86
- } as const;
87
-
88
- const result = await sendBeginFrame(client, { frameTimeTicks, interval, screenshot });
89
-
90
- let buffer: Buffer;
91
- if (result.screenshotData) {
92
- buffer = Buffer.from(result.screenshotData, "base64");
93
- lastFrameCache.set(page, buffer);
94
- } else {
95
- const cached = lastFrameCache.get(page);
96
- if (cached) {
97
- buffer = cached;
98
- } else {
99
- // Frame 0 always has damage, so this path is near-unreachable.
100
- // Force a composite with a tiny time advance.
101
- const fallback = await sendBeginFrame(client, {
102
- frameTimeTicks: frameTimeTicks + 0.001,
103
- interval,
104
- screenshot,
105
- });
106
- buffer = fallback.screenshotData
107
- ? Buffer.from(fallback.screenshotData, "base64")
108
- : Buffer.alloc(0);
109
- if (buffer.length > 0) lastFrameCache.set(page, buffer);
110
- }
111
- }
112
-
113
- return {
114
- buffer,
115
- hasDamage: result.hasDamage,
116
- };
117
- }
118
-
119
- /**
120
- * Capture a screenshot using standard Page.captureScreenshot CDP call.
121
- * Fallback for environments where BeginFrame is unavailable (macOS, Windows).
122
- *
123
- * For `format: "png"` captures we disable Chrome's `optimizeForSpeed` fast
124
- * path. The fast path uses a zero-alpha-aware codec that crushes real alpha
125
- * values to 0 or 255 (verified empirically; CDP docs don't document this) —
126
- * exactly the same caveat called out on `captureScreenshotWithAlpha` /
127
- * `captureAlphaPng`. Keeping the fast path for opaque jpeg captures is fine.
128
- */
129
- export async function pageScreenshotCapture(page: Page, options: CaptureOptions): Promise<Buffer> {
130
- const client = await getCdpSession(page);
131
- const isPng = options.format === "png";
132
- const dpr = options.deviceScaleFactor ?? 1;
133
- const clip = { x: 0, y: 0, width: options.width, height: options.height, scale: dpr };
134
- const result = await client.send("Page.captureScreenshot", {
135
- format: isPng ? "png" : "jpeg",
136
- quality: isPng ? undefined : (options.quality ?? 80),
137
- fromSurface: true,
138
- // The explicit clip rect constrains output to exact composition
139
- // dimensions. The viewport-boundary pre-clip from captureBeyondViewport:
140
- // false is redundant, and Chrome's compositor rounds it inward under
141
- // multi-tab load — clipping the bottom/right edge of tall viewports.
142
- captureBeyondViewport: true,
143
- optimizeForSpeed: !isPng,
144
- clip,
145
- });
146
- return Buffer.from(result.data, "base64");
147
- }
148
-
149
- /**
150
- * Capture a screenshot with transparent background (PNG + alpha channel).
151
- *
152
- * Used in the two-pass HDR compositing pipeline — captures DOM content
153
- * (text, graphics, SDR overlays) with transparency where the background shows,
154
- * so it can be overlaid on top of native HDR video frames in FFmpeg.
155
- *
156
- * Sets and restores the background color override on every call. For sessions
157
- * that capture many frames, prefer calling initTransparentBackground() once
158
- * at session init, then captureAlphaPng() per frame to avoid the 2× CDP
159
- * round-trip overhead.
160
- */
161
- export async function captureScreenshotWithAlpha(
162
- page: Page,
163
- width: number,
164
- height: number,
165
- ): Promise<Buffer> {
166
- const client = await getCdpSession(page);
167
- // Force transparent background so the screenshot has a real alpha channel
168
- await client.send("Emulation.setDefaultBackgroundColorOverride", {
169
- color: { r: 0, g: 0, b: 0, a: 0 },
170
- });
171
- try {
172
- const result = await client.send("Page.captureScreenshot", {
173
- format: "png",
174
- fromSurface: true,
175
- captureBeyondViewport: true, // see pageScreenshotCapture for rationale
176
- optimizeForSpeed: false, // `true` uses a zero-alpha-aware fast path that crushes real alpha values — observed empirically, CDP docs don't spell it out
177
- clip: { x: 0, y: 0, width, height, scale: 1 },
178
- });
179
- return Buffer.from(result.data, "base64");
180
- } finally {
181
- // Restore opaque background even if captureScreenshot throws, otherwise
182
- // subsequent opaque captures keep a transparent background.
183
- await client.send("Emulation.setDefaultBackgroundColorOverride", {}).catch(() => {});
184
- }
185
- }
186
-
187
- /**
188
- * Set the page background to transparent once for a dedicated HDR DOM session.
189
- *
190
- * Call this once after session initialization. Then use captureAlphaPng() per
191
- * frame instead of captureScreenshotWithAlpha() to skip the per-frame CDP
192
- * background override round-trips.
193
- *
194
- * Only use on sessions that are exclusively dedicated to transparent capture
195
- * (e.g., the HDR two-pass DOM layer session) — the background will stay
196
- * transparent for the lifetime of the session.
197
- *
198
- * NOTE on the injected stylesheet: `Emulation.setDefaultBackgroundColorOverride`
199
- * only replaces the *default* page background. Compositions almost always set
200
- * `body { background: ... }` and `#root { background: ... }`, which paint over
201
- * the override and ruin alpha capture for layered HDR compositing — the
202
- * composition root's full-frame background paints across the entire viewport
203
- * and wipes out HDR content captured beneath it.
204
- *
205
- * We force `html`, `body`, and any element marked as a composition root
206
- * (`[data-composition-id]`) to transparent. In HDR layered compositing the HDR
207
- * video itself is the backdrop, so DOM layers must only contribute their
208
- * foreground UI pixels — never a page-spanning solid backdrop.
209
- */
210
- const TRANSPARENT_BG_STYLE_ID = "__hf_transparent_bg__";
211
-
212
- export async function initTransparentBackground(page: Page): Promise<void> {
213
- const client = await getCdpSession(page);
214
- await client.send("Emulation.setDefaultBackgroundColorOverride", {
215
- color: { r: 0, g: 0, b: 0, a: 0 },
216
- });
217
- await page.evaluate((styleId: string) => {
218
- if (document.getElementById(styleId)) return;
219
- const style = document.createElement("style");
220
- style.id = styleId;
221
- style.textContent =
222
- "html,body,[data-composition-id]{background:transparent !important;background-color:transparent !important;background-image:none !important;}";
223
- document.head.appendChild(style);
224
- }, TRANSPARENT_BG_STYLE_ID);
225
- }
226
-
227
- /**
228
- * Capture a transparent-background PNG screenshot without setting the
229
- * background color override. Requires initTransparentBackground() to have
230
- * been called once on this session.
231
- *
232
- * Faster than captureScreenshotWithAlpha() for per-frame use in the HDR
233
- * two-pass compositing loop.
234
- */
235
- export async function captureAlphaPng(page: Page, width: number, height: number): Promise<Buffer> {
236
- const client = await getCdpSession(page);
237
- const result = await client.send("Page.captureScreenshot", {
238
- format: "png",
239
- fromSurface: true,
240
- captureBeyondViewport: true, // see pageScreenshotCapture for rationale
241
- optimizeForSpeed: false, // must be false to preserve alpha
242
- clip: { x: 0, y: 0, width, height, scale: 1 },
243
- });
244
- return Buffer.from(result.data, "base64");
245
- }
246
-
247
- /**
248
- * Stylesheet ID used by applyDomLayerMask / removeDomLayerMask. Exposed so
249
- * tests can assert presence/absence of the mask between captures.
250
- */
251
- export const DOM_LAYER_MASK_STYLE_ID = "__hf_dom_layer_mask__";
252
-
253
- /**
254
- * Mask the DOM so a single layer screenshot captures ONLY the layer's pixels.
255
- *
256
- * The HDR layered compositor walks z-ordered layers and blits each one over a
257
- * shared canvas. DOM layers are full-page screenshots — a naive screenshot
258
- * captures every painted pixel on the page, which means root background +
259
- * static overlays + sibling-scene content all overwrite previously composited
260
- * HDR content beneath. The mask narrows each screenshot to the elements that
261
- * actually belong to this layer.
262
- *
263
- * Strategy:
264
- *
265
- * 1. Inject a stylesheet that hides every body descendant
266
- * (`body * { visibility: hidden !important }`) and re-shows the layer's
267
- * elements (and their descendants and their injected `__render_frame_*`
268
- * siblings) via `visibility: visible !important`. CSS `visibility: visible`
269
- * on a descendant overrides an ancestor's `visibility: hidden`, so deep
270
- * layer elements remain visible even though intermediate parents are
271
- * hidden by the mass-hide rule.
272
- * 2. Inline-hide each `extraHideId` (and its `__render_frame_*` sibling) with
273
- * `visibility: hidden !important`. Inline `!important` beats stylesheet
274
- * `!important`, so this overrides the show rule for elements that fall
275
- * under a show selector but should NOT paint — typically other-layer
276
- * elements that are descendants of a container layer (for example HDR
277
- * videos and other-layer SDR videos are descendants of `#root` when we
278
- * capture the root DOM layer).
279
- *
280
- * Only `visibility` is set on extraHideIds — never `opacity`. CSS opacity is
281
- * multiplicative through the descendant chain and a descendant cannot escape
282
- * an ancestor's `opacity: 0`. If `#root` is in `extraHideIds` and we set
283
- * `opacity: 0` on it, every descendant — including `#vid-5-b` and its
284
- * `__render_frame_vid-5-b__` IMG — becomes invisible even with
285
- * `visibility: visible !important`. `visibility` does NOT have this problem:
286
- * a descendant with `visibility: visible` overrides an ancestor's
287
- * `visibility: hidden`.
288
- *
289
- * Layout is preserved (visibility doesn't trigger reflow), so border-radius
290
- * clipping, overflow:hidden, and absolute positioning continue to apply to
291
- * the visible layer elements. Opacity is also preserved — an ancestor at
292
- * `opacity: 0` (e.g. an inactive scene during a transition) still
293
- * propagates to its descendants, which is the desired behavior during
294
- * cross-scene blends.
295
- *
296
- * Idempotent across calls: an existing mask stylesheet is removed before a
297
- * new one is installed, so consecutive `applyDomLayerMask` invocations leave
298
- * exactly one stylesheet attached.
299
- */
300
- export async function applyDomLayerMask(
301
- page: Page,
302
- showIds: string[],
303
- extraHideIds: string[],
304
- ): Promise<void> {
305
- await page.evaluate(
306
- (args: { show: string[]; hide: string[]; styleId: string }) => {
307
- const existing = document.getElementById(args.styleId);
308
- if (existing) existing.remove();
309
-
310
- const showSelectors: string[] = [];
311
- for (const id of args.show) {
312
- const escaped = CSS.escape(id);
313
- showSelectors.push(`#${escaped}`, `#${escaped} *`);
314
- const renderEscaped = CSS.escape(`__render_frame_${id}__`);
315
- showSelectors.push(`#${renderEscaped}`, `#${renderEscaped} *`);
316
- }
317
-
318
- const massHideRule = "body *{visibility:hidden !important;}";
319
- const showRule =
320
- showSelectors.length === 0
321
- ? ""
322
- : `${showSelectors.join(",")}{visibility:visible !important;}`;
323
-
324
- const style = document.createElement("style");
325
- style.id = args.styleId;
326
- style.textContent = `${massHideRule}\n${showRule}`;
327
- document.head.appendChild(style);
328
-
329
- for (const id of args.hide) {
330
- const el = document.getElementById(id);
331
- if (el) {
332
- el.style.setProperty("visibility", "hidden", "important");
333
- }
334
- const img = document.getElementById(`__render_frame_${id}__`);
335
- if (img) {
336
- img.style.setProperty("visibility", "hidden", "important");
337
- }
338
- }
339
- },
340
- { show: showIds, hide: extraHideIds, styleId: DOM_LAYER_MASK_STYLE_ID },
341
- );
342
- }
343
-
344
- /**
345
- * Tear down the mask installed by applyDomLayerMask.
346
- *
347
- * Removes the mask stylesheet and clears the inline `visibility` properties
348
- * set on `extraHideIds` (and their `__render_frame_*` siblings).
349
- *
350
- * IMPORTANT: We do NOT strip inline `opacity` here. applyDomLayerMask only
351
- * ever sets `visibility` (never `opacity`), so any inline opacity present on
352
- * a wrapper was put there by user animation code (typically GSAP) and must
353
- * survive across per-layer captures. GSAP's seek with suppress-events does
354
- * not re-apply tweens when the timeline is already at the target time, so if
355
- * we strip opacity here and then seek to the same time for the next layer,
356
- * GSAP won't put it back and the wrapper will render fully opaque.
357
- */
358
- export async function removeDomLayerMask(page: Page, extraHideIds: string[]): Promise<void> {
359
- await page.evaluate(
360
- (args: { hide: string[]; styleId: string }) => {
361
- const style = document.getElementById(args.styleId);
362
- if (style) style.remove();
363
- for (const id of args.hide) {
364
- const el = document.getElementById(id);
365
- if (el) {
366
- el.style.removeProperty("visibility");
367
- }
368
- const img = document.getElementById(`__render_frame_${id}__`);
369
- if (img) img.style.removeProperty("visibility");
370
- }
371
- },
372
- { hide: extraHideIds, styleId: DOM_LAYER_MASK_STYLE_ID },
373
- );
374
- }
375
-
376
- /**
377
- * Returns the subset of `updates.videoId`s that were actually painted in
378
- * this call. Videos skipped because of a hidden visual ancestor are NOT
379
- * included — the caller relies on this to avoid recording a `lastInjected`
380
- * cache entry for a frame that never reached the page, which would otherwise
381
- * short-circuit the next inject at the same frameIndex and leave the host's
382
- * first visible frame blank.
383
- */
384
- export async function injectVideoFramesBatch(
385
- page: Page,
386
- updates: Array<{ videoId: string; dataUri: string }>,
387
- ): Promise<string[]> {
388
- if (updates.length === 0) return [];
389
- return await page.evaluate(
390
- async (items: Array<{ videoId: string; dataUri: string }>, visualProperties: string[]) => {
391
- const injectedIds: string[] = [];
392
- const pendingDecodes: Array<Promise<void>> = [];
393
- const replacementLayoutProperties = new Set([
394
- "width",
395
- "height",
396
- "top",
397
- "left",
398
- "right",
399
- "bottom",
400
- "inset",
401
- ]);
402
- // Walk ancestors looking for a host that the page has hidden. The
403
- // runtime hides `[data-composition-src]` and `[data-start]` hosts that
404
- // fall outside their time window; a nested `<video data-start>` inside
405
- // such a host still appears "active" in the raw time-window check (its
406
- // own `data-start`/`data-end` cover the whole clip), so without this
407
- // guard we would paint a full-bleed replacement frame over a sibling
408
- // host that *is* visible.
409
- //
410
- // `display: none` is always a skip signal — a `display: none` ancestor
411
- // takes its whole subtree out of layout, and a child `<img>` cannot
412
- // escape that. `visibility: hidden`, by contrast, is escapable: a
413
- // descendant with `visibility: visible` overrides an ancestor's
414
- // `visibility: hidden` per the CSS spec, and the replacement `<img>`
415
- // intentionally sets `visibility: visible`. We therefore only treat
416
- // `visibility: hidden` as a skip signal on sub-composition hosts
417
- // (`[data-composition-src]` / `[data-composition-file]`), which is the
418
- // scenario this guard exists for. Plain `[data-start]` containers may
419
- // be hidden with `visibility: hidden` while still wanting their inner
420
- // video's final-state frame to paint through (e.g. a GSAP timeline
421
- // shorter than the host's authored data-duration, where the runtime
422
- // truncates visibility but the replacement <img> must hold its last
423
- // frame) — those must NOT be skipped here.
424
- const isVisualAncestorHidden = (el: HTMLElement): boolean => {
425
- let parent = el.parentElement;
426
- while (parent !== null && parent !== document.documentElement) {
427
- const computed = window.getComputedStyle(parent);
428
- if (computed.display === "none") return true;
429
- if (
430
- computed.visibility === "hidden" &&
431
- (parent.hasAttribute("data-composition-src") ||
432
- parent.hasAttribute("data-composition-file"))
433
- ) {
434
- return true;
435
- }
436
- parent = parent.parentElement;
437
- }
438
- return false;
439
- };
440
- for (const item of items) {
441
- const video = document.getElementById(item.videoId) as HTMLVideoElement | null;
442
- if (!video) continue;
443
-
444
- let img = video.nextElementSibling as HTMLImageElement | null;
445
- const hasImg = img !== null && img.classList.contains("__render_frame__");
446
-
447
- if (isVisualAncestorHidden(video)) {
448
- // Don't paint a frame over a hidden host — if an existing replacement
449
- // <img> is still around from when the host was visible, hide it so it
450
- // doesn't bleed through a sibling host that *is* visible on this seek.
451
- //
452
- // Use `!important` so the inline hide survives `applyDomLayerMask`'s
453
- // stylesheet `#${showId} *{visibility:visible !important}` when the
454
- // sub-comp host happens to land in the active layer's `show` set —
455
- // important stylesheet beats non-important inline, but important
456
- // inline beats important stylesheet.
457
- if (hasImg && img) img.style.setProperty("visibility", "hidden", "important");
458
- continue;
459
- }
460
-
461
- const isNewImage = !hasImg;
462
- const computedStyle = window.getComputedStyle(video);
463
- // Read the GSAP-controlled opacity directly from the native <video>.
464
- // We hide the <video> below with `visibility: hidden` only (never
465
- // `opacity: 0`), so its computed opacity is preserved across seeks
466
- // and accurately reflects the user's intent on every frame.
467
- const opacityParsed = parseFloat(computedStyle.opacity);
468
- const computedOpacity = Number.isNaN(opacityParsed) ? 1 : opacityParsed;
469
-
470
- if (isNewImage) {
471
- img = document.createElement("img");
472
- img.classList.add("__render_frame__");
473
- img.id = `__render_frame_${item.videoId}__`;
474
- img.style.pointerEvents = "none";
475
- video.parentNode?.insertBefore(img, video.nextSibling);
476
- }
477
- if (!img) continue;
478
-
479
- for (const property of visualProperties) {
480
- // Opacity is handled explicitly via `computedOpacity` below — copying
481
- // via the generic loop would race against the opacity:0 hide applied
482
- // to the <video> at the end of this function. GSAP may animate
483
- // opacity either on a wrapper (the <img> inherits via the stacking
484
- // context) or directly on the <video> (we must copy it to the <img>
485
- // since they are siblings). Reading computedStyle.opacity before
486
- // hiding the <video> handles both cases correctly.
487
- if (property === "opacity") continue;
488
- // Layout is set from the video's used box below. Copying authored
489
- // opposing constraints such as `inset: 0` / `right: 0` onto the
490
- // replacement <img> can overconstrain replaced-image sizing and make
491
- // some Chrome capture paths resample the frame anisotropically.
492
- if (replacementLayoutProperties.has(property)) {
493
- continue;
494
- }
495
- const value = computedStyle.getPropertyValue(property);
496
- if (value) {
497
- img.style.setProperty(property, value);
498
- }
499
- }
500
-
501
- // Always use absolute positioning so the <img> overlays the <video>
502
- // instead of flowing below it. With position:relative, both elements
503
- // stack vertically — the <img> lands below the video and gets clipped
504
- // by any overflow:hidden ancestor (e.g., border-radius wrappers).
505
- //
506
- // Apply this after visual style copying so the measured used box is
507
- // the final authority for replacement frame geometry.
508
- {
509
- const videoRect = video.getBoundingClientRect();
510
- const offsetLeft = Number.isFinite(video.offsetLeft) ? video.offsetLeft : 0;
511
- const offsetTop = Number.isFinite(video.offsetTop) ? video.offsetTop : 0;
512
- const offsetWidth = video.offsetWidth > 0 ? video.offsetWidth : videoRect.width;
513
- const offsetHeight = video.offsetHeight > 0 ? video.offsetHeight : videoRect.height;
514
- img.style.position = "absolute";
515
- img.style.inset = "auto";
516
- img.style.left = `${offsetLeft}px`;
517
- img.style.top = `${offsetTop}px`;
518
- img.style.right = "auto";
519
- img.style.bottom = "auto";
520
- img.style.width = `${offsetWidth}px`;
521
- img.style.height = `${offsetHeight}px`;
522
- }
523
- img.style.objectFit = computedStyle.objectFit;
524
- img.style.objectPosition = computedStyle.objectPosition;
525
- img.style.zIndex = computedStyle.zIndex;
526
-
527
- img.decoding = "sync";
528
- if (img.getAttribute("src") !== item.dataUri) {
529
- img.src = item.dataUri;
530
- pendingDecodes.push(
531
- img
532
- .decode()
533
- .catch(() => undefined)
534
- .then(() => undefined),
535
- );
536
- }
537
- img.style.opacity = String(computedOpacity);
538
- img.style.visibility = "visible";
539
- // Hide the native <video> with visibility only — never clobber inline
540
- // opacity, so subsequent reads (and queryElementStacking) see the real
541
- // GSAP-controlled value.
542
- video.style.setProperty("visibility", "hidden", "important");
543
- video.style.setProperty("pointer-events", "none", "important");
544
- injectedIds.push(item.videoId);
545
- }
546
- if (pendingDecodes.length > 0) {
547
- await Promise.all(pendingDecodes);
548
- }
549
- return injectedIds;
550
- },
551
- updates,
552
- [...MEDIA_VISUAL_STYLE_PROPERTIES],
553
- );
554
- }
555
-
556
- export async function syncVideoFrameVisibility(
557
- page: Page,
558
- activeVideoIds: string[],
559
- ): Promise<void> {
560
- await page.evaluate((ids: string[]) => {
561
- // Mirror the ancestor-visibility guard from `injectVideoFramesBatch`.
562
- // See that copy for the full rationale on why `visibility: hidden` is
563
- // narrowed to sub-composition hosts only — keep these two functions in
564
- // sync so the inactive-arm decision matches the inject-time decision.
565
- const isVisualAncestorHidden = (el: HTMLElement): boolean => {
566
- let parent = el.parentElement;
567
- while (parent !== null && parent !== document.documentElement) {
568
- const computed = window.getComputedStyle(parent);
569
- if (computed.display === "none") return true;
570
- if (
571
- computed.visibility === "hidden" &&
572
- (parent.hasAttribute("data-composition-src") ||
573
- parent.hasAttribute("data-composition-file"))
574
- ) {
575
- return true;
576
- }
577
- parent = parent.parentElement;
578
- }
579
- return false;
580
- };
581
- const active = new Set(ids);
582
- const videos = Array.from(document.querySelectorAll("video[data-start]")) as HTMLVideoElement[];
583
- for (const video of videos) {
584
- const img = video.nextElementSibling as HTMLElement | null;
585
- const hasImg = img && img.classList.contains("__render_frame__");
586
- const ancestorHidden = isVisualAncestorHidden(video);
587
- if (active.has(video.id) && !ancestorHidden) {
588
- // Active video: show injected <img>, hide native <video>.
589
- // Do NOT clobber inline opacity here — GSAP-controlled opacity must
590
- // survive until injectVideoFramesBatch reads it via getComputedStyle.
591
- // visibility:hidden alone hides the native element without affecting
592
- // its computed opacity.
593
- video.style.setProperty("visibility", "hidden", "important");
594
- video.style.setProperty("pointer-events", "none", "important");
595
- if (hasImg) {
596
- img.style.visibility = "visible";
597
- }
598
- } else {
599
- // Inactive (or ancestor-hidden) video: hide both. Use visibility only
600
- // (never opacity) so we never clobber GSAP-controlled inline opacity.
601
- // Use `!important` on the <img> hide so `applyDomLayerMask`'s
602
- // important stylesheet rule (`#${showId} *{visibility:visible !important}`)
603
- // cannot revive a stale frame when the sub-comp host lands in the
604
- // active layer's `show` set — same mask-defense reasoning as the
605
- // `isVisualAncestorHidden` branch in `injectVideoFramesBatch`.
606
- video.style.removeProperty("display");
607
- video.style.setProperty("visibility", "hidden", "important");
608
- video.style.setProperty("pointer-events", "none", "important");
609
- if (hasImg) {
610
- img.style.setProperty("visibility", "hidden", "important");
611
- }
612
- }
613
- }
614
- }, activeVideoIds);
615
- }