@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.
- package/package.json +24 -7
- package/scripts/generate-lut-reference.py +0 -168
- package/scripts/test-fitTextFontSize-browser.ts +0 -135
- package/src/cdp-headless-experimental.d.ts +0 -54
- package/src/config.test.ts +0 -213
- package/src/config.ts +0 -417
- package/src/index.ts +0 -273
- package/src/services/audioMixer.test.ts +0 -326
- package/src/services/audioMixer.ts +0 -604
- package/src/services/audioMixer.types.ts +0 -35
- package/src/services/audioVolumeEnvelope.test.ts +0 -176
- package/src/services/audioVolumeEnvelope.ts +0 -138
- package/src/services/browserManager.test.ts +0 -330
- package/src/services/browserManager.ts +0 -670
- package/src/services/chunkEncoder.test.ts +0 -1415
- package/src/services/chunkEncoder.ts +0 -831
- package/src/services/chunkEncoder.types.ts +0 -60
- package/src/services/extractionCache.test.ts +0 -199
- package/src/services/extractionCache.ts +0 -216
- package/src/services/fileServer.ts +0 -110
- package/src/services/frameCapture-discardWarmup.test.ts +0 -183
- package/src/services/frameCapture-namePolyfill.test.ts +0 -78
- package/src/services/frameCapture-pollImagesReady.test.ts +0 -153
- package/src/services/frameCapture-staticDedupIndex.test.ts +0 -76
- package/src/services/frameCapture-warmupTicks.test.ts +0 -174
- package/src/services/frameCapture.test.ts +0 -192
- package/src/services/frameCapture.ts +0 -1934
- package/src/services/hdrCapture.test.ts +0 -159
- package/src/services/hdrCapture.ts +0 -315
- package/src/services/parallelCoordinator.test.ts +0 -139
- package/src/services/parallelCoordinator.ts +0 -437
- package/src/services/screenshotService.test.ts +0 -510
- package/src/services/screenshotService.ts +0 -615
- package/src/services/streamingEncoder.test.ts +0 -832
- package/src/services/streamingEncoder.ts +0 -594
- package/src/services/systemMemory.test.ts +0 -324
- package/src/services/systemMemory.ts +0 -180
- package/src/services/videoFrameExtractor.test.ts +0 -1062
- package/src/services/videoFrameExtractor.ts +0 -1139
- package/src/services/videoFrameInjector.test.ts +0 -300
- package/src/services/videoFrameInjector.ts +0 -687
- package/src/services/vp9Options.ts +0 -13
- package/src/types.ts +0 -191
- package/src/utils/alphaBlit.test.ts +0 -1349
- package/src/utils/alphaBlit.ts +0 -1015
- package/src/utils/assertSwiftShader.test.ts +0 -130
- package/src/utils/assertSwiftShader.ts +0 -126
- package/src/utils/ffmpegBinaries.test.ts +0 -43
- package/src/utils/ffmpegBinaries.ts +0 -63
- package/src/utils/ffprobe.test.ts +0 -342
- package/src/utils/ffprobe.ts +0 -457
- package/src/utils/gpuEncoder.test.ts +0 -140
- package/src/utils/gpuEncoder.ts +0 -268
- package/src/utils/hdr.test.ts +0 -191
- package/src/utils/hdr.ts +0 -137
- package/src/utils/hdrCompositing.test.ts +0 -130
- package/src/utils/htmlTemplate.test.ts +0 -42
- package/src/utils/htmlTemplate.ts +0 -42
- package/src/utils/layerCompositor.test.ts +0 -150
- package/src/utils/layerCompositor.ts +0 -58
- package/src/utils/parityContract.ts +0 -1
- package/src/utils/processTracker.test.ts +0 -74
- package/src/utils/processTracker.ts +0 -41
- package/src/utils/readWebGlVendorInfoFromCanvas.ts +0 -52
- package/src/utils/runFfmpeg.test.ts +0 -102
- package/src/utils/runFfmpeg.ts +0 -136
- package/src/utils/shaderTransitions.test.ts +0 -738
- package/src/utils/shaderTransitions.ts +0 -1130
- package/src/utils/uint16-alignment-audit.test.ts +0 -125
- package/src/utils/urlDownloader.test.ts +0 -65
- package/src/utils/urlDownloader.ts +0 -143
- package/tsconfig.json +0 -19
- 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
|
-
}
|