@hyperframes/engine 0.6.119 → 0.6.121

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,687 +0,0 @@
1
- /**
2
- * Video Frame Injector
3
- *
4
- * Creates a BeforeCaptureHook that replaces native <video> elements with
5
- * pre-extracted frame images during rendering. This is the Hyperframes-specific
6
- * video handling strategy — OSS users with different video pipelines can
7
- * provide their own hook or skip video injection entirely.
8
- */
9
-
10
- import { type Page } from "puppeteer-core";
11
- import { promises as fs } from "fs";
12
- import { type FrameLookupTable } from "./videoFrameExtractor.js";
13
- import { injectVideoFramesBatch, syncVideoFrameVisibility } from "./screenshotService.js";
14
- import { type BeforeCaptureHook } from "./frameCapture.js";
15
- import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
16
-
17
- export interface VideoFrameInjectorOptions extends Partial<
18
- Pick<EngineConfig, "frameDataUriCacheLimit" | "frameDataUriCacheBytesLimitMb">
19
- > {
20
- frameSrcResolver?: (framePath: string) => string | null;
21
- }
22
-
23
- interface FrameSourceCacheStats {
24
- entries: number;
25
- bytes: number;
26
- /** Total entries evicted since cache creation. A high count vs a small
27
- * composition signals the byte budget is too tight (cache thrash). */
28
- evictions: number;
29
- /** Total inserts rejected because the entry alone exceeds bytesLimit.
30
- * Non-zero means a single frame is bigger than the configured budget —
31
- * raise `frameDataUriCacheBytesLimitMb` if it recurs in production. */
32
- oversizedRejections: number;
33
- }
34
-
35
- interface FrameSourceCache {
36
- get: (framePath: string) => Promise<string>;
37
- /** Exposed for tests + telemetry; reflects current cache occupancy. */
38
- stats: () => FrameSourceCacheStats;
39
- }
40
-
41
- /**
42
- * Two-bound LRU keyed by frame path. Either bound triggers eviction of the
43
- * oldest entry — entry count protects against pathological many-tiny-frames
44
- * cases, and the byte budget keeps memory bounded when the per-frame data
45
- * URI grows (4K PNG frames are ~33 MB once base64-encoded).
46
- *
47
- * If a single entry's data URI exceeds `bytesLimit`, we skip caching it
48
- * (returning the URI directly to the caller). Without this guard, the
49
- * post-insert eviction loop would drop the entry we just inserted and the
50
- * cache would degrade into a CPU hot path — every subsequent `get()` would
51
- * re-read from disk and re-base64 the same frame.
52
- *
53
- * **Invariant**: cached values MUST be strings whose `.length` equals the
54
- * byte count we account for at insertion. We derive size on demand via
55
- * `cache.get(key)?.length` rather than maintaining a parallel `Map<string, number>`.
56
- * If you ever wrap the value (e.g. cache a Buffer or an object), the byte
57
- * accounting silently breaks — switch to a parallel size map first.
58
- */
59
- function createFrameSourceCache(
60
- entryLimit: number,
61
- bytesLimit: number,
62
- frameSrcResolver?: (framePath: string) => string | null,
63
- ): FrameSourceCache {
64
- const cache = new Map<string, string>();
65
- const inFlight = new Map<string, Promise<string>>();
66
- let totalBytes = 0;
67
- let evictions = 0;
68
- let oversizedRejections = 0;
69
-
70
- function evictOldest(): void {
71
- const oldestKey = cache.keys().next().value;
72
- if (!oldestKey) return;
73
- // Snapshot the value before deleting so the byte-size derivation can't
74
- // accidentally read post-delete (a future reorder would silently lose
75
- // accounting and surface as `totalBytes` drifting out of sync).
76
- const dropped = cache.get(oldestKey);
77
- cache.delete(oldestKey);
78
- totalBytes = Math.max(0, totalBytes - (dropped?.length ?? 0));
79
- evictions++;
80
- }
81
-
82
- function remember(framePath: string, dataUri: string): string {
83
- // Skip caching entries that alone exceed the byte budget. Caching them
84
- // would trigger immediate self-eviction on insert and pollute LRU order
85
- // by displacing the previous entry's slot.
86
- if (dataUri.length > bytesLimit) {
87
- oversizedRejections++;
88
- // Drop any stale prior version so the caller sees consistent state.
89
- if (cache.has(framePath)) {
90
- totalBytes = Math.max(0, totalBytes - (cache.get(framePath)?.length ?? 0));
91
- cache.delete(framePath);
92
- }
93
- return dataUri;
94
- }
95
- if (cache.has(framePath)) {
96
- totalBytes = Math.max(0, totalBytes - (cache.get(framePath)?.length ?? 0));
97
- cache.delete(framePath);
98
- }
99
- cache.set(framePath, dataUri);
100
- totalBytes += dataUri.length;
101
- while ((cache.size > entryLimit || totalBytes > bytesLimit) && cache.size > 0) {
102
- evictOldest();
103
- }
104
- return dataUri;
105
- }
106
-
107
- async function get(framePath: string): Promise<string> {
108
- const servedSrc = frameSrcResolver?.(framePath);
109
- if (servedSrc) return servedSrc;
110
-
111
- const cached = cache.get(framePath);
112
- if (cached) {
113
- remember(framePath, cached);
114
- return cached;
115
- }
116
-
117
- const existing = inFlight.get(framePath);
118
- if (existing) {
119
- return existing;
120
- }
121
-
122
- const pending = fs
123
- .readFile(framePath)
124
- .then((frameData) => {
125
- const mimeType = framePath.endsWith(".png") ? "image/png" : "image/jpeg";
126
- const dataUri = `data:${mimeType};base64,${frameData.toString("base64")}`;
127
- return remember(framePath, dataUri);
128
- })
129
- .finally(() => {
130
- inFlight.delete(framePath);
131
- });
132
- inFlight.set(framePath, pending);
133
- return pending;
134
- }
135
-
136
- return {
137
- get,
138
- stats: () => ({
139
- entries: cache.size,
140
- bytes: totalBytes,
141
- evictions,
142
- oversizedRejections,
143
- }),
144
- };
145
- }
146
-
147
- export const __testing = { createFrameSourceCache };
148
-
149
- async function redrawRuntimeColorGrading(page: Page): Promise<void> {
150
- await page.evaluate(() => {
151
- const hf = (
152
- window as Window & {
153
- __hf?: {
154
- colorGrading?: { redraw?: () => unknown };
155
- };
156
- }
157
- ).__hf;
158
- const redraw = hf?.colorGrading?.redraw;
159
- if (typeof redraw !== "function") return;
160
- try {
161
- redraw();
162
- } catch {
163
- // Optional page-side shader layer.
164
- }
165
- });
166
- }
167
-
168
- /**
169
- * Creates a BeforeCaptureHook that injects pre-extracted video frames
170
- * into the page, replacing native <video> elements with frame images.
171
- */
172
- export function createVideoFrameInjector(
173
- frameLookup: FrameLookupTable | null,
174
- config?: VideoFrameInjectorOptions,
175
- ): BeforeCaptureHook | null {
176
- if (!frameLookup) return null;
177
-
178
- const entryLimit = Math.max(
179
- 32,
180
- config?.frameDataUriCacheLimit ?? DEFAULT_CONFIG.frameDataUriCacheLimit,
181
- );
182
- const bytesLimitMb = Math.max(
183
- 64,
184
- config?.frameDataUriCacheBytesLimitMb ?? DEFAULT_CONFIG.frameDataUriCacheBytesLimitMb,
185
- );
186
- const bytesLimit = bytesLimitMb * 1024 * 1024;
187
- const frameCache = createFrameSourceCache(entryLimit, bytesLimit, config?.frameSrcResolver);
188
- const lastInjectedFrameByVideo = new Map<string, number>();
189
-
190
- return async (page: Page, time: number) => {
191
- const activePayloads = frameLookup.getActiveFramePayloads(time);
192
-
193
- const updates: Array<{ videoId: string; dataUri: string; frameIndex: number }> = [];
194
- const activeIds = new Set<string>();
195
- if (activePayloads.size > 0) {
196
- const pendingReads: Array<Promise<{ videoId: string; dataUri: string; frameIndex: number }>> =
197
- [];
198
- for (const [videoId, payload] of activePayloads) {
199
- activeIds.add(videoId);
200
- const lastFrameIndex = lastInjectedFrameByVideo.get(videoId);
201
- if (lastFrameIndex === payload.frameIndex) continue;
202
- pendingReads.push(
203
- frameCache
204
- .get(payload.framePath)
205
- .then((dataUri) => ({ videoId, dataUri, frameIndex: payload.frameIndex })),
206
- );
207
- }
208
- updates.push(...(await Promise.all(pendingReads)));
209
- }
210
-
211
- for (const videoId of Array.from(lastInjectedFrameByVideo.keys())) {
212
- if (!activeIds.has(videoId)) {
213
- lastInjectedFrameByVideo.delete(videoId);
214
- }
215
- }
216
-
217
- await syncVideoFrameVisibility(page, Array.from(activeIds));
218
- if (updates.length > 0) {
219
- // Only record cache entries for videos the page actually painted.
220
- // injectVideoFramesBatch skips any video whose visual ancestor is
221
- // hidden (sub-comp host out-of-window) and returns the subset of ids
222
- // it really wrote — recording the rest would short-circuit the next
223
- // call at the same frameIndex and leave the host's first visible
224
- // frame blank.
225
- const injectedIds = new Set(
226
- await injectVideoFramesBatch(
227
- page,
228
- updates.map((u) => ({ videoId: u.videoId, dataUri: u.dataUri })),
229
- ),
230
- );
231
- for (const update of updates) {
232
- if (injectedIds.has(update.videoId)) {
233
- lastInjectedFrameByVideo.set(update.videoId, update.frameIndex);
234
- }
235
- }
236
- if (injectedIds.size > 0) {
237
- // GPU compositions (WebGL / WebGPU) that sample these videos as
238
- // textures already rendered once on the pre-injection seek, reading a
239
- // stale/black frame. Now that the decoded `__render_frame__` images are
240
- // in the DOM, re-render the GPU adapters at the same time so they
241
- // re-upload their video textures from the correct frame. No-op in
242
- // compositions without a GPU adapter.
243
- await page.evaluate((t: number) => {
244
- (window as unknown as { __hfReseekGpu?: (n: number) => void }).__hfReseekGpu?.(t);
245
- }, time);
246
- }
247
- }
248
- await redrawRuntimeColorGrading(page);
249
- };
250
- }
251
-
252
- // ── HDR compositing utilities ─────────────────────────────────────────────────
253
-
254
- /**
255
- * Bounds and transform of a video element, queried from Chrome each frame.
256
- * Used by the two-pass HDR compositing pipeline to position native HDR frames.
257
- */
258
- export interface VideoElementBounds {
259
- videoId: string;
260
- x: number;
261
- y: number;
262
- width: number;
263
- height: number;
264
- opacity: number;
265
- /** CSS transform matrix as a DOMMatrix-compatible string, e.g. "matrix(1,0,0,1,0,0)" */
266
- transform: string;
267
- zIndex: number;
268
- visible: boolean;
269
- }
270
-
271
- /**
272
- * Hide specific video elements by ID. Used in Pass 1 of the HDR pipeline so
273
- * Chrome screenshots only contain DOM content (text, overlays) with transparent
274
- * holes where the HDR videos go.
275
- */
276
- export async function hideVideoElements(page: Page, videoIds: string[]): Promise<void> {
277
- if (videoIds.length === 0) return;
278
- await page.evaluate((ids: string[]) => {
279
- for (const id of ids) {
280
- const el = document.getElementById(id) as HTMLVideoElement | null;
281
- if (el) {
282
- el.style.setProperty("visibility", "hidden", "important");
283
- const img = document.getElementById(`__render_frame_${id}__`);
284
- if (img) img.style.setProperty("visibility", "hidden", "important");
285
- }
286
- }
287
- }, videoIds);
288
- }
289
-
290
- /**
291
- * Restore visibility of video elements after a DOM screenshot.
292
- */
293
- export async function showVideoElements(page: Page, videoIds: string[]): Promise<void> {
294
- if (videoIds.length === 0) return;
295
- await page.evaluate((ids: string[]) => {
296
- for (const id of ids) {
297
- const el = document.getElementById(id) as HTMLVideoElement | null;
298
- if (el) {
299
- el.style.removeProperty("visibility");
300
- const img = document.getElementById(`__render_frame_${id}__`);
301
- if (img) img.style.removeProperty("visibility");
302
- }
303
- }
304
- }, videoIds);
305
- }
306
-
307
- /**
308
- * Query the current bounds, transform, and visibility of video elements.
309
- * Called after seeking (so GSAP has moved things) but before the screenshot.
310
- */
311
- export async function queryVideoElementBounds(
312
- page: Page,
313
- videoIds: string[],
314
- ): Promise<VideoElementBounds[]> {
315
- if (videoIds.length === 0) return [];
316
- return page.evaluate((ids: string[]): VideoElementBounds[] => {
317
- return ids.map((id) => {
318
- const el = document.getElementById(id) as HTMLVideoElement | null;
319
- if (!el) {
320
- return {
321
- videoId: id,
322
- x: 0,
323
- y: 0,
324
- width: 0,
325
- height: 0,
326
- opacity: 0,
327
- transform: "none",
328
- zIndex: 0,
329
- visible: false,
330
- };
331
- }
332
- const rect = el.getBoundingClientRect();
333
- const style = window.getComputedStyle(el);
334
- const zIndexParsed = parseInt(style.zIndex);
335
- const zIndex = Number.isNaN(zIndexParsed) ? 0 : zIndexParsed;
336
- const opacityParsed = parseFloat(style.opacity);
337
- const opacity = Number.isNaN(opacityParsed) ? 1 : opacityParsed;
338
- const transform = style.transform || "none";
339
- const visible =
340
- style.visibility !== "hidden" &&
341
- style.display !== "none" &&
342
- rect.width > 0 &&
343
- rect.height > 0;
344
- return {
345
- videoId: id,
346
- x: Math.round(rect.x),
347
- y: Math.round(rect.y),
348
- width: Math.round(rect.width),
349
- height: Math.round(rect.height),
350
- opacity,
351
- transform,
352
- zIndex,
353
- visible,
354
- };
355
- });
356
- }, videoIds);
357
- }
358
-
359
- /**
360
- * Stacking info for a single timed element, used by the z-ordered layer compositor.
361
- */
362
- export interface ElementStackingInfo {
363
- id: string;
364
- zIndex: number;
365
- x: number;
366
- y: number;
367
- width: number;
368
- height: number;
369
- /** Layout dimensions before CSS transforms (offsetWidth/offsetHeight). */
370
- layoutWidth: number;
371
- layoutHeight: number;
372
- opacity: number;
373
- visible: boolean;
374
- isHdr: boolean;
375
- transform: string; // CSS transform matrix string, e.g. "matrix(1,0,0,1,0,0)" or "none"
376
- borderRadius: [number, number, number, number]; // [tl, tr, br, bl] in CSS px from nearest clipping ancestor
377
- /**
378
- * CSS `object-fit` value for replaced elements (`<img>`, `<video>`).
379
- * One of: `fill` (default), `cover`, `contain`, `none`, `scale-down`.
380
- * The HDR compositor uses this to resample image/video buffers into the
381
- * element's layout box the same way the browser would.
382
- */
383
- objectFit: string;
384
- /**
385
- * CSS `object-position` value (e.g. `"50% 50%"`, `"center top"`).
386
- * Falls back to the CSS default `"50% 50%"` (center) when unset.
387
- */
388
- objectPosition: string;
389
- /**
390
- * Clip rect from the nearest ancestor with `overflow: hidden` (or
391
- * `clip`/`clip-path`). When set, the HDR compositor must scissor the
392
- * element's blit to this viewport-relative rectangle. `null` means no
393
- * clipping ancestor was found — render at full element bounds.
394
- */
395
- clipRect: { x: number; y: number; width: number; height: number } | null;
396
- }
397
-
398
- /**
399
- * Query Chrome for ALL timed elements' stacking context.
400
- * Returns z-index, bounds, opacity, and whether each element is a native HDR source.
401
- *
402
- * Queries every element with `data-start` (not just videos) so the layer compositor
403
- * can determine z-ordering between DOM content and HDR video/image elements.
404
- *
405
- * @param nativeHdrIds Combined set of HDR-tagged element IDs (videos AND images).
406
- */
407
- export async function queryElementStacking(
408
- page: Page,
409
- nativeHdrIds: Set<string>,
410
- ): Promise<ElementStackingInfo[]> {
411
- const hdrIds = Array.from(nativeHdrIds);
412
- return page.evaluate((hdrIdList: string[]): ElementStackingInfo[] => {
413
- const hdrSet = new Set(hdrIdList);
414
- const elements = document.querySelectorAll("[data-start]");
415
- const results: ElementStackingInfo[] = [];
416
-
417
- // Walk up the DOM to find the effective z-index from the nearest
418
- // positioned ancestor with a z-index. CSS z-index only applies to
419
- // positioned elements; video elements inside positioned wrappers
420
- // inherit the wrapper's stacking context.
421
- //
422
- // ## Supported subset
423
- //
424
- // This implementation looks for explicit `z-index` on positioned
425
- // (non-static) ancestors. It does NOT detect the CSS stacking contexts
426
- // created implicitly by other properties — including `opacity < 1`,
427
- // `transform`, `filter`, `will-change`, `isolation: isolate`, and
428
- // `mix-blend-mode`. GSAP routinely sets `transform` on wrappers, which
429
- // creates an implicit stacking context with auto z-index; an HDR video
430
- // inside such a wrapper with no explicit z-index will return the
431
- // wrapper-of-the-wrapper's z-index here, potentially reordering layers
432
- // incorrectly relative to sibling stacking contexts.
433
- //
434
- // The workaround is to set explicit `z-index` on the positioned wrapper
435
- // when you want it treated as a compositing layer root. This matches
436
- // what compositions need to do anyway for deterministic z-ordering.
437
- function getEffectiveZIndex(node: Element): number {
438
- let current: Element | null = node;
439
- while (current) {
440
- const cs = window.getComputedStyle(current);
441
- const pos = cs.position;
442
- const z = parseInt(cs.zIndex);
443
- if (!Number.isNaN(z) && pos !== "static") return z;
444
- current = current.parentElement;
445
- }
446
- return 0;
447
- }
448
-
449
- // Find border-radius that clips the element. Replaced elements like <video>
450
- // clip to their own border-radius; ancestors need overflow !== visible.
451
- function getEffectiveBorderRadius(node: Element): [number, number, number, number] {
452
- // Resolve a CSS border-radius value to pixels. Chrome's getComputedStyle
453
- // returns percentages as-is (e.g. "50%"), not resolved to px.
454
- // Uses offsetWidth/offsetHeight (layout dimensions before CSS transforms)
455
- // because CSS resolves percentages against the padding box, not the
456
- // transformed bounding box.
457
- function resolveRadius(value: string, el: Element): number {
458
- if (value.includes("%")) {
459
- const pct = parseFloat(value) / 100;
460
- const w = el instanceof HTMLElement ? el.offsetWidth : 0;
461
- const h = el instanceof HTMLElement ? el.offsetHeight : 0;
462
- return pct * Math.min(w, h);
463
- }
464
- const parsed = parseFloat(value);
465
- return Number.isNaN(parsed) ? 0 : parsed;
466
- }
467
-
468
- // Check element itself (replaced elements clip to own border-radius)
469
- const selfCs = window.getComputedStyle(node);
470
- const selfRadii: [number, number, number, number] = [
471
- resolveRadius(selfCs.borderTopLeftRadius, node),
472
- resolveRadius(selfCs.borderTopRightRadius, node),
473
- resolveRadius(selfCs.borderBottomRightRadius, node),
474
- resolveRadius(selfCs.borderBottomLeftRadius, node),
475
- ];
476
- if (selfRadii[0] > 0 || selfRadii[1] > 0 || selfRadii[2] > 0 || selfRadii[3] > 0) {
477
- return selfRadii;
478
- }
479
-
480
- // Walk ancestors looking for clipping container
481
- let current: Element | null = node.parentElement;
482
- while (current) {
483
- const cs = window.getComputedStyle(current);
484
- if (cs.overflow !== "visible") {
485
- const tl = resolveRadius(cs.borderTopLeftRadius, current);
486
- const tr = resolveRadius(cs.borderTopRightRadius, current);
487
- const brr = resolveRadius(cs.borderBottomRightRadius, current);
488
- const bl = resolveRadius(cs.borderBottomLeftRadius, current);
489
- if (tl > 0 || tr > 0 || brr > 0 || bl > 0) {
490
- return [tl, tr, brr, bl];
491
- }
492
- }
493
- current = current.parentElement;
494
- }
495
- return [0, 0, 0, 0];
496
- }
497
-
498
- // Walk ancestors to find the tightest overflow:hidden clip rect.
499
- // Returns null if no clipping ancestor exists.
500
- function getClipRect(
501
- node: Element,
502
- ): { x: number; y: number; width: number; height: number } | null {
503
- let current: Element | null = node.parentElement;
504
- let clip: { x: number; y: number; width: number; height: number } | null = null;
505
- while (current) {
506
- const cs = window.getComputedStyle(current);
507
- if (cs.overflow === "hidden" || cs.overflow === "clip") {
508
- const r = current.getBoundingClientRect();
509
- const ancestor = {
510
- x: Math.round(r.x),
511
- y: Math.round(r.y),
512
- width: Math.round(r.width),
513
- height: Math.round(r.height),
514
- };
515
- if (!clip) {
516
- clip = ancestor;
517
- } else {
518
- // Intersect with existing clip
519
- const x1 = Math.max(clip.x, ancestor.x);
520
- const y1 = Math.max(clip.y, ancestor.y);
521
- const x2 = Math.min(clip.x + clip.width, ancestor.x + ancestor.width);
522
- const y2 = Math.min(clip.y + clip.height, ancestor.y + ancestor.height);
523
- clip = { x: x1, y: y1, width: Math.max(0, x2 - x1), height: Math.max(0, y2 - y1) };
524
- }
525
- }
526
- current = current.parentElement;
527
- }
528
- return clip;
529
- }
530
-
531
- // Walk up the DOM multiplying each ancestor's opacity. GSAP animates
532
- // opacity on wrapper divs, not directly on the video element, so the
533
- // element's own opacity is often 1.0. Multiplying ancestors gives the
534
- // true effective opacity.
535
- function getEffectiveOpacity(node: Element): number {
536
- let opacity = 1;
537
- let current: Element | null = node;
538
- while (current) {
539
- const cs = window.getComputedStyle(current);
540
- const val = parseFloat(cs.opacity);
541
- // Note: `val || 1` would turn opacity:0 into 1 (0 is falsy)
542
- opacity *= Number.isNaN(val) ? 1 : val;
543
- current = current.parentElement;
544
- }
545
- return opacity;
546
- }
547
-
548
- // Compute the full CSS transform matrix from element-local coords to
549
- // viewport coords by walking the offsetParent chain and accumulating
550
- // position offsets + CSS transforms. This correctly handles GSAP
551
- // animations on wrapper divs (rotation, scale) that getBoundingClientRect
552
- // conflates into an axis-aligned bounding box.
553
- function getViewportMatrix(node: Element): string {
554
- const chain: HTMLElement[] = [];
555
- let current: Element | null = node;
556
- while (current instanceof HTMLElement) {
557
- chain.push(current);
558
- const next: Element | null =
559
- (current.offsetParent as Element | null) ?? current.parentElement;
560
- if (next === current) break;
561
- current = next;
562
- }
563
- let mat = new DOMMatrix();
564
- for (let i = chain.length - 1; i >= 0; i--) {
565
- const htmlEl = chain[i];
566
- if (!htmlEl) continue;
567
- mat = mat.translate(htmlEl.offsetLeft, htmlEl.offsetTop);
568
- const cs = window.getComputedStyle(htmlEl);
569
- const origin = cs.transformOrigin.split(" ");
570
- const ox = resolveLength(origin[0] ?? "0", htmlEl.offsetWidth);
571
- const oy = resolveLength(origin[1] ?? "0", htmlEl.offsetHeight);
572
- const individualTransform = composeIndividualTransforms(cs);
573
- const hasIndividual = individualTransform !== null;
574
- const hasTransform = cs.transform && cs.transform !== "none";
575
- if (hasIndividual || hasTransform) {
576
- mat = mat.translate(ox, oy);
577
- if (hasIndividual) mat = mat.multiply(individualTransform);
578
- if (hasTransform) {
579
- try {
580
- const t = new DOMMatrix(cs.transform);
581
- if (
582
- Number.isFinite(t.a) &&
583
- Number.isFinite(t.b) &&
584
- Number.isFinite(t.c) &&
585
- Number.isFinite(t.d) &&
586
- Number.isFinite(t.e) &&
587
- Number.isFinite(t.f)
588
- ) {
589
- mat = mat.multiply(t);
590
- }
591
- } catch {
592
- // DOMMatrix constructor throws on malformed input — skip.
593
- }
594
- }
595
- mat = mat.translate(-ox, -oy);
596
- }
597
- }
598
- return mat.toString();
599
- }
600
-
601
- function composeIndividualTransforms(cs: CSSStyleDeclaration): DOMMatrix | null {
602
- const translate = cs.getPropertyValue("translate").trim();
603
- const rotate = cs.getPropertyValue("rotate").trim();
604
- const scale = cs.getPropertyValue("scale").trim();
605
- const hasTranslate = translate && translate !== "none";
606
- const hasRotate = rotate && rotate !== "none";
607
- const hasScale = scale && scale !== "none";
608
- if (!hasTranslate && !hasRotate && !hasScale) return null;
609
- let m = new DOMMatrix();
610
- if (hasTranslate) {
611
- const parts = translate.split(/\s+/);
612
- const tx = parseFloat(parts[0] ?? "0") || 0;
613
- const ty = parseFloat(parts[1] ?? "0") || 0;
614
- if (tx !== 0 || ty !== 0) m = m.translate(tx, ty);
615
- }
616
- if (hasRotate) {
617
- const deg = parseFloat(rotate) || 0;
618
- if (deg !== 0) m = m.rotate(deg);
619
- }
620
- if (hasScale) {
621
- const parts = scale.split(/\s+/);
622
- const sx = parseFloat(parts[0] ?? "1") || 1;
623
- const sy = parseFloat(parts[1] ?? String(sx)) || sx;
624
- if (sx !== 1 || sy !== 1) m = m.scale(sx, sy);
625
- }
626
- return m;
627
- }
628
-
629
- function resolveLength(value: string, basis: number): number {
630
- if (value.endsWith("%")) {
631
- const pct = parseFloat(value) / 100;
632
- return Number.isFinite(pct) ? pct * basis : 0;
633
- }
634
- const n = parseFloat(value);
635
- return Number.isFinite(n) ? n : 0;
636
- }
637
-
638
- for (const el of elements) {
639
- const id = el.id;
640
- if (!id) continue;
641
- const rect = el.getBoundingClientRect();
642
- const style = window.getComputedStyle(el);
643
- const zIndex = getEffectiveZIndex(el);
644
- const isHdrEl = hdrSet.has(id);
645
- // The frame injector now uses `visibility: hidden` (without `opacity: 0`)
646
- // to hide native <video> elements, so the element's own computed opacity
647
- // remains the GSAP-controlled value. Walk from the element itself to
648
- // multiply through any ancestor opacity stacks.
649
- const opacity = getEffectiveOpacity(el);
650
- const visible =
651
- style.visibility !== "hidden" &&
652
- style.display !== "none" &&
653
- rect.width > 0 &&
654
- rect.height > 0;
655
- // offsetWidth/offsetHeight only exist on HTMLElement (not on
656
- // SVGElement, MathMLElement, etc.). Fall back to the bounding rect
657
- // dimensions for non-HTML elements so callers always get sensible
658
- // layout numbers.
659
- const htmlEl = el instanceof HTMLElement ? el : null;
660
- results.push({
661
- id,
662
- zIndex,
663
- x: Math.round(rect.x),
664
- y: Math.round(rect.y),
665
- width: Math.round(rect.width),
666
- height: Math.round(rect.height),
667
- layoutWidth: htmlEl?.offsetWidth || Math.round(rect.width),
668
- layoutHeight: htmlEl?.offsetHeight || Math.round(rect.height),
669
- opacity,
670
- visible,
671
- isHdr: hdrSet.has(id),
672
- // For HDR elements, use the full accumulated viewport matrix so the
673
- // affine blit can apply rotation/scale/translate properly. For DOM
674
- // elements, the element-level transform is sufficient for reference.
675
- transform: isHdrEl ? getViewportMatrix(el) : style.transform || "none",
676
- borderRadius: isHdrEl ? getEffectiveBorderRadius(el) : [0, 0, 0, 0],
677
- // `getComputedStyle` returns "" when the property doesn't apply (e.g.
678
- // for non-replaced elements); normalize to the CSS defaults so callers
679
- // can rely on a populated value.
680
- objectFit: style.objectFit || "fill",
681
- objectPosition: style.objectPosition || "50% 50%",
682
- clipRect: isHdrEl ? getClipRect(el) : null,
683
- });
684
- }
685
- return results;
686
- }, hdrIds);
687
- }
@@ -1,13 +0,0 @@
1
- export const DEFAULT_VP9_CPU_USED = 4;
2
- export const MIN_VP9_CPU_USED = -8;
3
- export const MAX_VP9_CPU_USED = 8;
4
-
5
- export function normalizeVp9CpuUsed(value: number | undefined): number {
6
- if (value === undefined || !Number.isFinite(value)) return DEFAULT_VP9_CPU_USED;
7
- const integer = Math.trunc(value);
8
- return Math.max(MIN_VP9_CPU_USED, Math.min(MAX_VP9_CPU_USED, integer));
9
- }
10
-
11
- export function appendVp9CpuUsedArg(args: string[], value: number | undefined): void {
12
- args.push("-cpu-used", String(normalizeVp9CpuUsed(value)));
13
- }