@hyperframes/engine 0.4.6 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/config.d.ts +6 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +9 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/index.d.ts +11 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +10 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/services/browserManager.d.ts.map +1 -1
  10. package/dist/services/browserManager.js +6 -3
  11. package/dist/services/browserManager.js.map +1 -1
  12. package/dist/services/chunkEncoder.d.ts +14 -7
  13. package/dist/services/chunkEncoder.d.ts.map +1 -1
  14. package/dist/services/chunkEncoder.js +25 -9
  15. package/dist/services/chunkEncoder.js.map +1 -1
  16. package/dist/services/chunkEncoder.types.d.ts +4 -0
  17. package/dist/services/chunkEncoder.types.d.ts.map +1 -1
  18. package/dist/services/hdrCapture.d.ts +62 -0
  19. package/dist/services/hdrCapture.d.ts.map +1 -0
  20. package/dist/services/hdrCapture.js +259 -0
  21. package/dist/services/hdrCapture.js.map +1 -0
  22. package/dist/services/screenshotService.d.ts +115 -0
  23. package/dist/services/screenshotService.d.ts.map +1 -1
  24. package/dist/services/screenshotService.js +252 -19
  25. package/dist/services/screenshotService.js.map +1 -1
  26. package/dist/services/streamingEncoder.d.ts +18 -3
  27. package/dist/services/streamingEncoder.d.ts.map +1 -1
  28. package/dist/services/streamingEncoder.js +104 -50
  29. package/dist/services/streamingEncoder.js.map +1 -1
  30. package/dist/services/videoFrameExtractor.d.ts.map +1 -1
  31. package/dist/services/videoFrameExtractor.js +109 -18
  32. package/dist/services/videoFrameExtractor.js.map +1 -1
  33. package/dist/services/videoFrameInjector.d.ts +59 -0
  34. package/dist/services/videoFrameInjector.d.ts.map +1 -1
  35. package/dist/services/videoFrameInjector.js +290 -0
  36. package/dist/services/videoFrameInjector.js.map +1 -1
  37. package/dist/types.d.ts +32 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/utils/alphaBlit.d.ts +105 -0
  40. package/dist/utils/alphaBlit.d.ts.map +1 -0
  41. package/dist/utils/alphaBlit.js +550 -0
  42. package/dist/utils/alphaBlit.js.map +1 -0
  43. package/dist/utils/ffprobe.d.ts +10 -0
  44. package/dist/utils/ffprobe.d.ts.map +1 -1
  45. package/dist/utils/ffprobe.js +7 -0
  46. package/dist/utils/ffprobe.js.map +1 -1
  47. package/dist/utils/hdr.d.ts +82 -0
  48. package/dist/utils/hdr.d.ts.map +1 -0
  49. package/dist/utils/hdr.js +87 -0
  50. package/dist/utils/hdr.js.map +1 -0
  51. package/dist/utils/layerCompositor.d.ts +36 -0
  52. package/dist/utils/layerCompositor.d.ts.map +1 -0
  53. package/dist/utils/layerCompositor.js +49 -0
  54. package/dist/utils/layerCompositor.js.map +1 -0
  55. package/dist/utils/shaderTransitions.d.ts +166 -0
  56. package/dist/utils/shaderTransitions.d.ts.map +1 -0
  57. package/dist/utils/shaderTransitions.js +894 -0
  58. package/dist/utils/shaderTransitions.js.map +1 -0
  59. package/package.json +3 -2
  60. package/src/config.ts +16 -0
  61. package/src/index.ts +61 -0
  62. package/src/services/browserManager.ts +6 -3
  63. package/src/services/chunkEncoder.test.ts +88 -0
  64. package/src/services/chunkEncoder.ts +35 -9
  65. package/src/services/chunkEncoder.types.ts +3 -0
  66. package/src/services/hdrCapture.test.ts +159 -0
  67. package/src/services/hdrCapture.ts +354 -0
  68. package/src/services/screenshotService.ts +271 -17
  69. package/src/services/streamingEncoder.test.ts +228 -0
  70. package/src/services/streamingEncoder.ts +153 -63
  71. package/src/services/videoFrameExtractor.ts +135 -31
  72. package/src/services/videoFrameInjector.ts +342 -0
  73. package/src/types.ts +34 -0
  74. package/src/utils/alphaBlit.test.ts +993 -0
  75. package/src/utils/alphaBlit.ts +643 -0
  76. package/src/utils/ffprobe.ts +22 -0
  77. package/src/utils/hdr.test.ts +191 -0
  78. package/src/utils/hdr.ts +137 -0
  79. package/src/utils/layerCompositor.test.ts +141 -0
  80. package/src/utils/layerCompositor.ts +58 -0
  81. package/src/utils/shaderTransitions.test.ts +674 -0
  82. package/src/utils/shaderTransitions.ts +1130 -0
  83. package/src/utils/uint16-alignment-audit.test.ts +125 -0
  84. package/tsconfig.json +2 -1
@@ -10,7 +10,9 @@ import { existsSync, mkdirSync, readdirSync, rmSync } from "fs";
10
10
  import { join } from "path";
11
11
  import { parseHTML } from "linkedom";
12
12
  import { extractVideoMetadata, type VideoMetadata } from "../utils/ffprobe.js";
13
+ import { isHdrColorSpace as isHdrColorSpaceUtil } from "../utils/hdr.js";
13
14
  import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
15
+ import { runFfmpeg } from "../utils/runFfmpeg.js";
14
16
  import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
15
17
 
16
18
  export interface VideoElement {
@@ -114,18 +116,28 @@ export async function extractVideoFramesRange(
114
116
  const framePattern = `frame_%05d.${format}`;
115
117
  const outputPattern = join(videoOutputDir, framePattern);
116
118
 
117
- const args: string[] = [
118
- "-ss",
119
- String(startTime),
120
- "-i",
121
- videoPath,
122
- "-t",
123
- String(duration),
124
- "-vf",
125
- `fps=${fps}`,
126
- "-q:v",
127
- format === "jpg" ? String(Math.ceil((100 - quality) / 3)) : "0",
128
- ];
119
+ // When extracting from HDR source, tone-map to SDR in FFmpeg rather than
120
+ // letting Chrome's uncontrollable tone-mapper handle it (which washes out).
121
+ // macOS: VideoToolbox hardware decoder does HDR→SDR natively on Apple Silicon.
122
+ // Linux: zscale filter (when available) or colorspace filter as fallback.
123
+ const isHdr = isHdrColorSpaceUtil(metadata.colorSpace);
124
+ const isMacOS = process.platform === "darwin";
125
+
126
+ const args: string[] = [];
127
+ if (isHdr && isMacOS) {
128
+ args.push("-hwaccel", "videotoolbox");
129
+ }
130
+ args.push("-ss", String(startTime), "-i", videoPath, "-t", String(duration));
131
+
132
+ const vfFilters: string[] = [];
133
+ if (isHdr && isMacOS) {
134
+ // VideoToolbox tone-maps during decode; force output to bt709 SDR format
135
+ vfFilters.push("format=nv12");
136
+ }
137
+ vfFilters.push(`fps=${fps}`);
138
+ args.push("-vf", vfFilters.join(","));
139
+
140
+ args.push("-q:v", format === "jpg" ? String(Math.ceil((100 - quality) / 3)) : "0");
129
141
  if (format === "png") args.push("-compression_level", "6");
130
142
  args.push("-y", outputPattern);
131
143
 
@@ -195,6 +207,53 @@ export async function extractVideoFramesRange(
195
207
  });
196
208
  }
197
209
 
210
+ /**
211
+ * Convert an SDR video to HDR color space (HLG / BT.2020) so it can be
212
+ * composited alongside HDR content without looking washed out.
213
+ *
214
+ * Uses zscale for color space conversion with a nominal peak luminance of
215
+ * 600 nits — high enough that SDR content doesn't appear too dark next to
216
+ * HDR, matching the approach used by HeyGen's Rio pipeline.
217
+ */
218
+ async function convertSdrToHdr(
219
+ inputPath: string,
220
+ outputPath: string,
221
+ signal?: AbortSignal,
222
+ config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
223
+ ): Promise<void> {
224
+ const timeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
225
+
226
+ const args = [
227
+ "-i",
228
+ inputPath,
229
+ "-vf",
230
+ "colorspace=all=bt2020:iall=bt709:range=tv",
231
+ "-color_primaries",
232
+ "bt2020",
233
+ "-color_trc",
234
+ "arib-std-b67",
235
+ "-colorspace",
236
+ "bt2020nc",
237
+ "-c:v",
238
+ "libx264",
239
+ "-preset",
240
+ "fast",
241
+ "-crf",
242
+ "16",
243
+ "-c:a",
244
+ "copy",
245
+ "-y",
246
+ outputPath,
247
+ ];
248
+
249
+ const result = await runFfmpeg(args, { signal, timeout });
250
+ if (!result.success) {
251
+ throw new Error(
252
+ `SDR→HDR conversion failed (exit ${result.exitCode}): ${result.stderr.slice(-300)}`,
253
+ );
254
+ }
255
+ }
256
+
198
257
  export async function extractAllVideoFrames(
199
258
  videos: VideoElement[],
200
259
  baseDir: string,
@@ -208,30 +267,75 @@ export async function extractAllVideoFrames(
208
267
  const errors: Array<{ videoId: string; error: string }> = [];
209
268
  let totalFramesExtracted = 0;
210
269
 
211
- // Process videos in parallel for better performance
212
- const results = await Promise.all(
213
- videos.map(async (video) => {
214
- if (signal?.aborted) {
215
- throw new Error("Video frame extraction cancelled");
270
+ // Phase 1: Resolve paths and download remote videos
271
+ const resolvedVideos: Array<{ video: VideoElement; videoPath: string }> = [];
272
+ for (const video of videos) {
273
+ if (signal?.aborted) break;
274
+ try {
275
+ let videoPath = video.src;
276
+ if (!videoPath.startsWith("/") && !isHttpUrl(videoPath)) {
277
+ const fromCompiled = compiledDir ? join(compiledDir, videoPath) : null;
278
+ videoPath =
279
+ fromCompiled && existsSync(fromCompiled) ? fromCompiled : join(baseDir, videoPath);
216
280
  }
217
- try {
218
- let videoPath = video.src;
219
- if (!videoPath.startsWith("/") && !isHttpUrl(videoPath)) {
220
- const fromCompiled = compiledDir ? join(compiledDir, videoPath) : null;
221
- videoPath =
222
- fromCompiled && existsSync(fromCompiled) ? fromCompiled : join(baseDir, videoPath);
223
- }
224
281
 
225
- if (isHttpUrl(videoPath)) {
226
- const downloadDir = join(options.outputDir, "_downloads");
227
- mkdirSync(downloadDir, { recursive: true });
228
- videoPath = await downloadToTemp(videoPath, downloadDir);
229
- }
282
+ if (isHttpUrl(videoPath)) {
283
+ const downloadDir = join(options.outputDir, "_downloads");
284
+ mkdirSync(downloadDir, { recursive: true });
285
+ videoPath = await downloadToTemp(videoPath, downloadDir);
286
+ }
287
+
288
+ if (!existsSync(videoPath)) {
289
+ errors.push({ videoId: video.id, error: `Video file not found: ${videoPath}` });
290
+ continue;
291
+ }
292
+ resolvedVideos.push({ video, videoPath });
293
+ } catch (err) {
294
+ errors.push({ videoId: video.id, error: err instanceof Error ? err.message : String(err) });
295
+ }
296
+ }
230
297
 
231
- if (!existsSync(videoPath)) {
232
- return { error: { videoId: video.id, error: `Video file not found: ${videoPath}` } };
298
+ // Phase 2: Probe color spaces and normalize if mixed HDR/SDR
299
+ const videoColorSpaces = await Promise.all(
300
+ resolvedVideos.map(async ({ videoPath }) => {
301
+ const metadata = await extractVideoMetadata(videoPath);
302
+ return metadata.colorSpace;
303
+ }),
304
+ );
305
+
306
+ const hasAnyHdr = videoColorSpaces.some(isHdrColorSpaceUtil);
307
+ if (hasAnyHdr) {
308
+ const convertDir = join(options.outputDir, "_hdr_normalized");
309
+ mkdirSync(convertDir, { recursive: true });
310
+
311
+ for (let i = 0; i < resolvedVideos.length; i++) {
312
+ if (signal?.aborted) break;
313
+ const cs = videoColorSpaces[i] ?? null;
314
+ if (!isHdrColorSpaceUtil(cs)) {
315
+ // SDR video in a mixed timeline — convert to HDR color space
316
+ const entry = resolvedVideos[i];
317
+ if (!entry) continue;
318
+ const convertedPath = join(convertDir, `${entry.video.id}_hdr.mp4`);
319
+ try {
320
+ await convertSdrToHdr(entry.videoPath, convertedPath, signal, config);
321
+ entry.videoPath = convertedPath;
322
+ } catch (err) {
323
+ errors.push({
324
+ videoId: entry.video.id,
325
+ error: `SDR→HDR conversion failed: ${err instanceof Error ? err.message : String(err)}`,
326
+ });
233
327
  }
328
+ }
329
+ }
330
+ }
234
331
 
332
+ // Phase 3: Extract frames (parallel)
333
+ const results = await Promise.all(
334
+ resolvedVideos.map(async ({ video, videoPath }) => {
335
+ if (signal?.aborted) {
336
+ throw new Error("Video frame extraction cancelled");
337
+ }
338
+ try {
235
339
  let videoDuration = video.end - video.start;
236
340
 
237
341
  // Fallback: if no data-duration/data-end was specified (end is Infinity or 0),
@@ -117,3 +117,345 @@ export function createVideoFrameInjector(
117
117
  }
118
118
  };
119
119
  }
120
+
121
+ // ── HDR compositing utilities ─────────────────────────────────────────────────
122
+
123
+ /**
124
+ * Bounds and transform of a video element, queried from Chrome each frame.
125
+ * Used by the two-pass HDR compositing pipeline to position native HDR frames.
126
+ */
127
+ export interface VideoElementBounds {
128
+ videoId: string;
129
+ x: number;
130
+ y: number;
131
+ width: number;
132
+ height: number;
133
+ opacity: number;
134
+ /** CSS transform matrix as a DOMMatrix-compatible string, e.g. "matrix(1,0,0,1,0,0)" */
135
+ transform: string;
136
+ zIndex: number;
137
+ visible: boolean;
138
+ }
139
+
140
+ /**
141
+ * Hide specific video elements by ID. Used in Pass 1 of the HDR pipeline so
142
+ * Chrome screenshots only contain DOM content (text, overlays) with transparent
143
+ * holes where the HDR videos go.
144
+ */
145
+ export async function hideVideoElements(page: Page, videoIds: string[]): Promise<void> {
146
+ if (videoIds.length === 0) return;
147
+ await page.evaluate((ids: string[]) => {
148
+ for (const id of ids) {
149
+ const el = document.getElementById(id) as HTMLVideoElement | null;
150
+ if (el) {
151
+ el.style.setProperty("visibility", "hidden", "important");
152
+ el.style.setProperty("opacity", "0", "important");
153
+ // Also hide the injected render frame image if present
154
+ const img = document.getElementById(`__render_frame_${id}__`);
155
+ if (img) img.style.setProperty("visibility", "hidden", "important");
156
+ }
157
+ }
158
+ }, videoIds);
159
+ }
160
+
161
+ /**
162
+ * Restore visibility of video elements after a DOM screenshot.
163
+ */
164
+ export async function showVideoElements(page: Page, videoIds: string[]): Promise<void> {
165
+ if (videoIds.length === 0) return;
166
+ await page.evaluate((ids: string[]) => {
167
+ for (const id of ids) {
168
+ const el = document.getElementById(id) as HTMLVideoElement | null;
169
+ if (el) {
170
+ el.style.removeProperty("visibility");
171
+ el.style.removeProperty("opacity");
172
+ const img = document.getElementById(`__render_frame_${id}__`);
173
+ if (img) img.style.removeProperty("visibility");
174
+ }
175
+ }
176
+ }, videoIds);
177
+ }
178
+
179
+ /**
180
+ * Query the current bounds, transform, and visibility of video elements.
181
+ * Called after seeking (so GSAP has moved things) but before the screenshot.
182
+ */
183
+ export async function queryVideoElementBounds(
184
+ page: Page,
185
+ videoIds: string[],
186
+ ): Promise<VideoElementBounds[]> {
187
+ if (videoIds.length === 0) return [];
188
+ return page.evaluate((ids: string[]): VideoElementBounds[] => {
189
+ return ids.map((id) => {
190
+ const el = document.getElementById(id) as HTMLVideoElement | null;
191
+ if (!el) {
192
+ return {
193
+ videoId: id,
194
+ x: 0,
195
+ y: 0,
196
+ width: 0,
197
+ height: 0,
198
+ opacity: 0,
199
+ transform: "none",
200
+ zIndex: 0,
201
+ visible: false,
202
+ };
203
+ }
204
+ const rect = el.getBoundingClientRect();
205
+ const style = window.getComputedStyle(el);
206
+ const zIndex = parseInt(style.zIndex) || 0;
207
+ const opacity = parseFloat(style.opacity) || 1;
208
+ const transform = style.transform || "none";
209
+ const visible =
210
+ style.visibility !== "hidden" &&
211
+ style.display !== "none" &&
212
+ rect.width > 0 &&
213
+ rect.height > 0;
214
+ return {
215
+ videoId: id,
216
+ x: Math.round(rect.x),
217
+ y: Math.round(rect.y),
218
+ width: Math.round(rect.width),
219
+ height: Math.round(rect.height),
220
+ opacity,
221
+ transform,
222
+ zIndex,
223
+ visible,
224
+ };
225
+ });
226
+ }, videoIds);
227
+ }
228
+
229
+ /**
230
+ * Stacking info for a single timed element, used by the z-ordered layer compositor.
231
+ */
232
+ export interface ElementStackingInfo {
233
+ id: string;
234
+ zIndex: number;
235
+ x: number;
236
+ y: number;
237
+ width: number;
238
+ height: number;
239
+ /** Layout dimensions before CSS transforms (offsetWidth/offsetHeight). */
240
+ layoutWidth: number;
241
+ layoutHeight: number;
242
+ opacity: number;
243
+ visible: boolean;
244
+ isHdr: boolean;
245
+ transform: string; // CSS transform matrix string, e.g. "matrix(1,0,0,1,0,0)" or "none"
246
+ borderRadius: [number, number, number, number]; // [tl, tr, br, bl] in CSS px from nearest clipping ancestor
247
+ }
248
+
249
+ /**
250
+ * Query Chrome for ALL timed elements' stacking context.
251
+ * Returns z-index, bounds, opacity, and whether each element is a native HDR video.
252
+ *
253
+ * Queries every element with `data-start` (not just videos) so the layer compositor
254
+ * can determine z-ordering between DOM content and HDR video elements.
255
+ */
256
+ export async function queryElementStacking(
257
+ page: Page,
258
+ nativeHdrVideoIds: Set<string>,
259
+ ): Promise<ElementStackingInfo[]> {
260
+ const hdrIds = Array.from(nativeHdrVideoIds);
261
+ return page.evaluate((hdrIdList: string[]): ElementStackingInfo[] => {
262
+ const hdrSet = new Set(hdrIdList);
263
+ const elements = document.querySelectorAll("[data-start]");
264
+ const results: ElementStackingInfo[] = [];
265
+
266
+ // Walk up the DOM to find the effective z-index from the nearest
267
+ // positioned ancestor with a z-index. CSS z-index only applies to
268
+ // positioned elements; video elements inside positioned wrappers
269
+ // inherit the wrapper's stacking context.
270
+ //
271
+ // ## Supported subset
272
+ //
273
+ // This implementation looks for explicit `z-index` on positioned
274
+ // (non-static) ancestors. It does NOT detect the CSS stacking contexts
275
+ // created implicitly by other properties — including `opacity < 1`,
276
+ // `transform`, `filter`, `will-change`, `isolation: isolate`, and
277
+ // `mix-blend-mode`. GSAP routinely sets `transform` on wrappers, which
278
+ // creates an implicit stacking context with auto z-index; an HDR video
279
+ // inside such a wrapper with no explicit z-index will return the
280
+ // wrapper-of-the-wrapper's z-index here, potentially reordering layers
281
+ // incorrectly relative to sibling stacking contexts.
282
+ //
283
+ // The workaround is to set explicit `z-index` on the positioned wrapper
284
+ // when you want it treated as a compositing layer root. This matches
285
+ // what compositions need to do anyway for deterministic z-ordering.
286
+ function getEffectiveZIndex(node: Element): number {
287
+ let current: Element | null = node;
288
+ while (current) {
289
+ const cs = window.getComputedStyle(current);
290
+ const pos = cs.position;
291
+ const z = parseInt(cs.zIndex);
292
+ if (!Number.isNaN(z) && pos !== "static") return z;
293
+ current = current.parentElement;
294
+ }
295
+ return 0;
296
+ }
297
+
298
+ // Find border-radius that clips the element. Replaced elements like <video>
299
+ // clip to their own border-radius; ancestors need overflow !== visible.
300
+ function getEffectiveBorderRadius(node: Element): [number, number, number, number] {
301
+ // Resolve a CSS border-radius value to pixels. Chrome's getComputedStyle
302
+ // returns percentages as-is (e.g. "50%"), not resolved to px.
303
+ // Uses offsetWidth/offsetHeight (layout dimensions before CSS transforms)
304
+ // because CSS resolves percentages against the padding box, not the
305
+ // transformed bounding box.
306
+ function resolveRadius(value: string, el: Element): number {
307
+ if (value.includes("%")) {
308
+ const pct = parseFloat(value) / 100;
309
+ const htmlEl = el as HTMLElement;
310
+ const w = htmlEl.offsetWidth || 0;
311
+ const h = htmlEl.offsetHeight || 0;
312
+ return pct * Math.min(w, h);
313
+ }
314
+ return parseFloat(value) || 0;
315
+ }
316
+
317
+ // Check element itself (replaced elements clip to own border-radius)
318
+ const selfCs = window.getComputedStyle(node);
319
+ const selfRadii: [number, number, number, number] = [
320
+ resolveRadius(selfCs.borderTopLeftRadius, node),
321
+ resolveRadius(selfCs.borderTopRightRadius, node),
322
+ resolveRadius(selfCs.borderBottomRightRadius, node),
323
+ resolveRadius(selfCs.borderBottomLeftRadius, node),
324
+ ];
325
+ if (selfRadii[0] > 0 || selfRadii[1] > 0 || selfRadii[2] > 0 || selfRadii[3] > 0) {
326
+ return selfRadii;
327
+ }
328
+
329
+ // Walk ancestors looking for clipping container
330
+ let current: Element | null = node.parentElement;
331
+ while (current) {
332
+ const cs = window.getComputedStyle(current);
333
+ if (cs.overflow !== "visible") {
334
+ const tl = resolveRadius(cs.borderTopLeftRadius, current);
335
+ const tr = resolveRadius(cs.borderTopRightRadius, current);
336
+ const brr = resolveRadius(cs.borderBottomRightRadius, current);
337
+ const bl = resolveRadius(cs.borderBottomLeftRadius, current);
338
+ if (tl > 0 || tr > 0 || brr > 0 || bl > 0) {
339
+ return [tl, tr, brr, bl];
340
+ }
341
+ }
342
+ current = current.parentElement;
343
+ }
344
+ return [0, 0, 0, 0];
345
+ }
346
+
347
+ // Walk up the DOM multiplying each ancestor's opacity. GSAP animates
348
+ // opacity on wrapper divs, not directly on the video element, so the
349
+ // element's own opacity is often 1.0. Multiplying ancestors gives the
350
+ // true effective opacity.
351
+ function getEffectiveOpacity(node: Element): number {
352
+ let opacity = 1;
353
+ let current: Element | null = node;
354
+ while (current) {
355
+ const cs = window.getComputedStyle(current);
356
+ const val = parseFloat(cs.opacity);
357
+ // Note: `val || 1` would turn opacity:0 into 1 (0 is falsy)
358
+ opacity *= Number.isNaN(val) ? 1 : val;
359
+ current = current.parentElement;
360
+ }
361
+ return opacity;
362
+ }
363
+
364
+ // Compute the full CSS transform matrix from element-local coords to
365
+ // viewport coords by walking the offsetParent chain and accumulating
366
+ // position offsets + CSS transforms. This correctly handles GSAP
367
+ // animations on wrapper divs (rotation, scale) that getBoundingClientRect
368
+ // conflates into an axis-aligned bounding box.
369
+ function getViewportMatrix(node: Element): string {
370
+ const chain: HTMLElement[] = [];
371
+ let current: Element | null = node;
372
+ while (current instanceof HTMLElement) {
373
+ chain.push(current);
374
+ const next: Element | null =
375
+ (current.offsetParent as Element | null) ?? current.parentElement;
376
+ if (next === current) break;
377
+ current = next;
378
+ }
379
+ let mat = new DOMMatrix();
380
+ for (let i = chain.length - 1; i >= 0; i--) {
381
+ const htmlEl = chain[i];
382
+ if (!htmlEl) continue;
383
+ mat = mat.translate(htmlEl.offsetLeft, htmlEl.offsetTop);
384
+ const cs = window.getComputedStyle(htmlEl);
385
+ if (cs.transform && cs.transform !== "none") {
386
+ const origin = cs.transformOrigin.split(" ");
387
+ const ox = resolveLength(origin[0] ?? "0", htmlEl.offsetWidth);
388
+ const oy = resolveLength(origin[1] ?? "0", htmlEl.offsetHeight);
389
+ try {
390
+ const t = new DOMMatrix(cs.transform);
391
+ if (
392
+ Number.isFinite(t.a) &&
393
+ Number.isFinite(t.b) &&
394
+ Number.isFinite(t.c) &&
395
+ Number.isFinite(t.d) &&
396
+ Number.isFinite(t.e) &&
397
+ Number.isFinite(t.f)
398
+ ) {
399
+ mat = mat.translate(ox, oy).multiply(t).translate(-ox, -oy);
400
+ }
401
+ } catch {
402
+ // DOMMatrix constructor throws on malformed input — skip ancestor.
403
+ }
404
+ }
405
+ }
406
+ return mat.toString();
407
+ }
408
+
409
+ function resolveLength(value: string, basis: number): number {
410
+ if (value.endsWith("%")) {
411
+ const pct = parseFloat(value) / 100;
412
+ return Number.isFinite(pct) ? pct * basis : 0;
413
+ }
414
+ const n = parseFloat(value);
415
+ return Number.isFinite(n) ? n : 0;
416
+ }
417
+
418
+ for (const el of elements) {
419
+ const id = el.id;
420
+ if (!id) continue;
421
+ const rect = el.getBoundingClientRect();
422
+ const style = window.getComputedStyle(el);
423
+ const zIndex = getEffectiveZIndex(el);
424
+ // For HDR video elements, the frame injector sets `opacity: 0 !important`
425
+ // on the element itself. Start the opacity walk from the parent to get the
426
+ // real GSAP-animated opacity from wrapper divs.
427
+ const isHdrEl = hdrSet.has(id);
428
+ const opacityStartNode = isHdrEl ? el.parentElement : el;
429
+ const opacity = opacityStartNode ? getEffectiveOpacity(opacityStartNode) : 1;
430
+ const visible =
431
+ style.visibility !== "hidden" &&
432
+ style.display !== "none" &&
433
+ rect.width > 0 &&
434
+ rect.height > 0;
435
+ // offsetWidth/offsetHeight only exist on HTMLElement (not on
436
+ // SVGElement, MathMLElement, etc.). Fall back to the bounding rect
437
+ // dimensions for non-HTML elements so callers always get sensible
438
+ // layout numbers.
439
+ const htmlEl = el instanceof HTMLElement ? el : null;
440
+ results.push({
441
+ id,
442
+ zIndex,
443
+ x: Math.round(rect.x),
444
+ y: Math.round(rect.y),
445
+ width: Math.round(rect.width),
446
+ height: Math.round(rect.height),
447
+ layoutWidth: htmlEl?.offsetWidth || Math.round(rect.width),
448
+ layoutHeight: htmlEl?.offsetHeight || Math.round(rect.height),
449
+ opacity,
450
+ visible,
451
+ isHdr: hdrSet.has(id),
452
+ // For HDR elements, use the full accumulated viewport matrix so the
453
+ // affine blit can apply rotation/scale/translate properly. For DOM
454
+ // elements, the element-level transform is sufficient for reference.
455
+ transform: isHdrEl ? getViewportMatrix(el) : style.transform || "none",
456
+ borderRadius: isHdrEl ? getEffectiveBorderRadius(el) : [0, 0, 0, 0],
457
+ });
458
+ }
459
+ return results;
460
+ }, hdrIds);
461
+ }
package/src/types.ts CHANGED
@@ -31,6 +31,29 @@ export interface HfMediaElement {
31
31
  hasAudio?: boolean;
32
32
  }
33
33
 
34
+ /**
35
+ * Metadata for a shader transition between two scenes.
36
+ *
37
+ * Compositions using @hyperframes/shader-transitions populate
38
+ * `window.__hf.transitions` with one entry per transition so the
39
+ * producer can pre-compute scene ranges, capture per-scene buffers,
40
+ * and apply the transition in HDR-aware compositing.
41
+ */
42
+ export interface HfTransitionMeta {
43
+ /** Time the transition starts (seconds) */
44
+ time: number;
45
+ /** Transition duration (seconds) */
46
+ duration: number;
47
+ /** Shader identifier (e.g. "fade", "wipe") */
48
+ shader: string;
49
+ /** GSAP easing string (e.g. "power2.inOut") */
50
+ ease: string;
51
+ /** Scene id the transition starts from */
52
+ fromScene: string;
53
+ /** Scene id the transition ends on */
54
+ toScene: string;
55
+ }
56
+
34
57
  /**
35
58
  * The seek protocol. The only contract between the engine and a page.
36
59
  *
@@ -42,6 +65,15 @@ export interface HfMediaElement {
42
65
  * GSAP, Framer Motion, CSS animations, Three.js — anything works as long
43
66
  * as `seek()` produces deterministic visual output for a given time.
44
67
  */
68
+ export interface HfTransitionMeta {
69
+ time: number;
70
+ duration: number;
71
+ shader: string;
72
+ ease: string;
73
+ fromScene: string;
74
+ toScene: string;
75
+ }
76
+
45
77
  export interface HfProtocol {
46
78
  /** Total duration of the composition in seconds */
47
79
  duration: number;
@@ -49,6 +81,8 @@ export interface HfProtocol {
49
81
  seek(time: number): void;
50
82
  /** Optional: media elements the engine should handle */
51
83
  media?: HfMediaElement[];
84
+ /** Optional: shader transition metadata, populated by @hyperframes/shader-transitions */
85
+ transitions?: HfTransitionMeta[];
52
86
  }
53
87
 
54
88
  // ── Capture Types ──────────────────────────────────────────────────────────────