@hyperframes/engine 0.6.46 → 0.6.48

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 (32) hide show
  1. package/dist/services/chunkEncoder.d.ts.map +1 -1
  2. package/dist/services/chunkEncoder.js +11 -2
  3. package/dist/services/chunkEncoder.js.map +1 -1
  4. package/dist/services/parallelCoordinator.d.ts.map +1 -1
  5. package/dist/services/parallelCoordinator.js +43 -43
  6. package/dist/services/parallelCoordinator.js.map +1 -1
  7. package/dist/services/screenshotService.d.ts +9 -1
  8. package/dist/services/screenshotService.d.ts.map +1 -1
  9. package/dist/services/screenshotService.js +95 -7
  10. package/dist/services/screenshotService.js.map +1 -1
  11. package/dist/services/streamingEncoder.d.ts.map +1 -1
  12. package/dist/services/streamingEncoder.js +12 -3
  13. package/dist/services/streamingEncoder.js.map +1 -1
  14. package/dist/services/videoFrameInjector.d.ts.map +1 -1
  15. package/dist/services/videoFrameInjector.js +10 -2
  16. package/dist/services/videoFrameInjector.js.map +1 -1
  17. package/dist/utils/gpuEncoder.d.ts +6 -3
  18. package/dist/utils/gpuEncoder.d.ts.map +1 -1
  19. package/dist/utils/gpuEncoder.js +123 -12
  20. package/dist/utils/gpuEncoder.js.map +1 -1
  21. package/package.json +2 -2
  22. package/src/services/chunkEncoder.test.ts +24 -2
  23. package/src/services/chunkEncoder.ts +9 -2
  24. package/src/services/parallelCoordinator.ts +58 -42
  25. package/src/services/screenshotService.test.ts +317 -0
  26. package/src/services/screenshotService.ts +99 -8
  27. package/src/services/streamingEncoder.test.ts +20 -0
  28. package/src/services/streamingEncoder.ts +10 -3
  29. package/src/services/videoFrameInjector.test.ts +111 -2
  30. package/src/services/videoFrameInjector.ts +14 -4
  31. package/src/utils/gpuEncoder.test.ts +68 -3
  32. package/src/utils/gpuEncoder.ts +155 -8
@@ -25,6 +25,7 @@ import {
25
25
  import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
26
26
  import { assertSwiftShader } from "../utils/assertSwiftShader.js";
27
27
  import { readWebGlVendorInfoFromCanvas } from "../utils/readWebGlVendorInfoFromCanvas.js";
28
+ import { resolveHeadlessShellPath } from "./browserManager.js";
28
29
 
29
30
  export interface WorkerTask {
30
31
  workerId: number;
@@ -191,6 +192,33 @@ export function shouldVerifyWorkerGpu(workerId: number, config?: Partial<EngineC
191
192
  return config?.browserGpuMode === "software" && workerId === 0;
192
193
  }
193
194
 
195
+ async function captureFrameRange(
196
+ session: CaptureSession,
197
+ task: WorkerTask,
198
+ captureOptions: CaptureOptions,
199
+ signal: AbortSignal | undefined,
200
+ onFrameCaptured: ((workerId: number, frameIndex: number) => void) | undefined,
201
+ onFrameBuffer: ((frameIndex: number, buffer: Buffer) => Promise<void>) | undefined,
202
+ ): Promise<number> {
203
+ let framesCaptured = 0;
204
+ const outputOffset = task.outputFrameOffset ?? 0;
205
+ for (let i = task.startFrame; i < task.endFrame; i++) {
206
+ if (signal?.aborted) throw new Error("Parallel worker cancelled");
207
+ const time = (i * captureOptions.fps.den) / captureOptions.fps.num;
208
+ const fileFrameIdx = i - outputOffset;
209
+
210
+ if (onFrameBuffer) {
211
+ const { buffer } = await captureFrameToBuffer(session, fileFrameIdx, time);
212
+ await onFrameBuffer(i, buffer);
213
+ } else {
214
+ await captureFrame(session, fileFrameIdx, time);
215
+ }
216
+ framesCaptured++;
217
+ if (onFrameCaptured) onFrameCaptured(task.workerId, i);
218
+ }
219
+ return framesCaptured;
220
+ }
221
+
194
222
  async function executeWorkerTask(
195
223
  task: WorkerTask,
196
224
  serverUrl: string,
@@ -200,6 +228,7 @@ async function executeWorkerTask(
200
228
  onFrameCaptured?: (workerId: number, frameIndex: number) => void,
201
229
  onFrameBuffer?: (frameIndex: number, buffer: Buffer) => Promise<void>,
202
230
  config?: Partial<EngineConfig>,
231
+ parallel?: boolean,
203
232
  ): Promise<WorkerResult> {
204
233
  const startTime = Date.now();
205
234
  let framesCaptured = 0;
@@ -209,58 +238,43 @@ async function executeWorkerTask(
209
238
  let session: CaptureSession | null = null;
210
239
  let perf: CapturePerfSummary | undefined;
211
240
 
241
+ // BeginFrame's compositor is process-global — multiple pages driving
242
+ // beginFrame in the same browser race it and crash with "Target closed".
243
+ // Only disable the pool when BeginFrame mode would actually be active.
244
+ // Must match the predicate in createCaptureSession (frameCapture.ts):
245
+ // Linux + headless-shell + !forceScreenshot + !supersampling.
246
+ const supersampling = (captureOptions.deviceScaleFactor ?? 1) > 1;
247
+ const needsSeparateBrowsers =
248
+ parallel &&
249
+ process.platform === "linux" &&
250
+ !config?.forceScreenshot &&
251
+ !supersampling &&
252
+ resolveHeadlessShellPath(config) !== undefined;
253
+ const workerConfig: Partial<EngineConfig> | undefined = needsSeparateBrowsers
254
+ ? { ...config, enableBrowserPool: false }
255
+ : config;
256
+
212
257
  try {
213
258
  session = await createCaptureSession(
214
259
  serverUrl,
215
260
  task.outputDir,
216
261
  captureOptions,
217
262
  createBeforeCaptureHook(),
218
- config,
263
+ workerConfig,
219
264
  );
220
- // Per-worker SwiftShader assertion, gated to worker 0 only.
221
- // When `browserGpuMode: "software"` is declared, the chunk's GL backend
222
- // must be verified as SwiftShader before the first frame — a host that
223
- // falls back to a hardware GL backend (or silently fails to load
224
- // SwiftShader) would otherwise produce non-deterministic pixels and
225
- // break the distributed byte-identical-retry contract. Running this
226
- // probe on every worker means N concurrent navigations to a WebGL
227
- // probe page per chunk; with `chunkWorkerCount=6` × 3 chunks, that's
228
- // 18 simultaneous CDP page-loads, which inflated c=3 worst-case wall
229
- // by ~24s vs c=6/c=8 on the texture-launch bench. Workers in the same
230
- // chunk share the same Chrome binary, flags, and OS/driver state, so
231
- // worker 0's success is representative — gate it there and skip the
232
- // rest. See `heygen-com/hyperframes#955` for the bench data and the
233
- // pre-warmup probe interaction (which `renderChunk` already skips
234
- // when `chunkWorkerCount > 1`).
235
- if (shouldVerifyWorkerGpu(task.workerId, config)) {
265
+ // Worker-0-only SwiftShader assertion see `shouldVerifyWorkerGpu` and #955.
266
+ if (shouldVerifyWorkerGpu(task.workerId, workerConfig)) {
236
267
  await assertSwiftShader(session.page, readWebGlVendorInfoFromCanvas);
237
268
  }
238
269
  await initializeSession(session);
239
-
240
- const outputOffset = task.outputFrameOffset ?? 0;
241
- for (let i = task.startFrame; i < task.endFrame; i++) {
242
- if (signal?.aborted) {
243
- throw new Error("Parallel worker cancelled");
244
- }
245
- // captureOptions.fps is an Fps rational; collapse to decimal for the
246
- // frame-index → time math. The 1-in-1001 ULP loss for NTSC is invisible
247
- // at our scales (frame count tops out at single-digit thousands).
248
- const time = (i * captureOptions.fps.den) / captureOptions.fps.num;
249
- const fileFrameIdx = i - outputOffset;
250
-
251
- if (onFrameBuffer) {
252
- // The streaming-encode callback receives the absolute index `i`
253
- // (not `fileFrameIdx`) so the encoder sequences frames against the
254
- // composition's timeline.
255
- const { buffer } = await captureFrameToBuffer(session, fileFrameIdx, time);
256
- await onFrameBuffer(i, buffer);
257
- } else {
258
- await captureFrame(session, fileFrameIdx, time);
259
- }
260
- framesCaptured++;
261
-
262
- if (onFrameCaptured) onFrameCaptured(task.workerId, i);
263
- }
270
+ framesCaptured = await captureFrameRange(
271
+ session,
272
+ task,
273
+ captureOptions,
274
+ signal,
275
+ onFrameCaptured,
276
+ onFrameBuffer,
277
+ );
264
278
 
265
279
  perf = getCapturePerfSummary(session);
266
280
  return {
@@ -318,6 +332,7 @@ export async function executeParallelCapture(
318
332
  }
319
333
  };
320
334
 
335
+ const parallel = tasks.length > 1;
321
336
  const results = await Promise.all(
322
337
  tasks.map((task) =>
323
338
  executeWorkerTask(
@@ -329,6 +344,7 @@ export async function executeParallelCapture(
329
344
  onFrameCaptured,
330
345
  onFrameBuffer,
331
346
  config,
347
+ parallel,
332
348
  ),
333
349
  ),
334
350
  );
@@ -6,6 +6,7 @@ import {
6
6
  pageScreenshotCapture,
7
7
  cdpSessionCache,
8
8
  injectVideoFramesBatch,
9
+ syncVideoFrameVisibility,
9
10
  } from "./screenshotService.js";
10
11
 
11
12
  // Stub a Page + CDPSession just enough that pageScreenshotCapture can call
@@ -191,3 +192,319 @@ describe("injectVideoFramesBatch replacement layout", () => {
191
192
  expect(img?.style.inset).toBe("auto");
192
193
  });
193
194
  });
195
+
196
+ describe("video-frame injection respects ancestor visibility", () => {
197
+ // Regression guard: the runtime's `[data-start]` lifecycle hides
198
+ // out-of-window sub-composition hosts with `visibility:hidden`, but the
199
+ // injector used to ignore that and paint a replacement <img> for every
200
+ // active `<video data-start>` element. Inner-PIP videos inside *other*
201
+ // moments still appear active in the raw time-window check (their auto-
202
+ // injected `data-start="0"` + probed full-source duration cover the
203
+ // whole timeline), so the bug produced one full-bleed speaker overlay
204
+ // per inactive sub-comp — covering whichever moment was actually visible.
205
+ //
206
+ // The skip is intentionally narrow: `visibility:hidden` on a regular
207
+ // `[data-start]` container must NOT skip injection, because the
208
+ // replacement <img>'s explicit `visibility:visible` overrides the
209
+ // ancestor (CSS spec) and consumers rely on that to hold the final
210
+ // GSAP-driven frame when an authored `data-duration` outlives the
211
+ // composition's GSAP timeline. We therefore only treat
212
+ // `visibility:hidden` as a skip signal on sub-composition hosts
213
+ // (`[data-composition-src]` / `[data-composition-file]`). `display:none`,
214
+ // by contrast, takes the whole subtree out of layout regardless of any
215
+ // child override, so it always triggers the skip.
216
+
217
+ type StyleLike = {
218
+ display?: string;
219
+ visibility?: string;
220
+ opacity?: string;
221
+ objectFit?: string;
222
+ objectPosition?: string;
223
+ zIndex?: string;
224
+ };
225
+
226
+ type HostAttribute = "data-composition-src" | "data-composition-file" | "data-start";
227
+
228
+ function setupHostHiddenScenario(
229
+ hostStyle: StyleLike,
230
+ options: { hostAttribute?: HostAttribute } = {},
231
+ ) {
232
+ const hostAttribute = options.hostAttribute ?? "data-composition-src";
233
+ const hostAttrMarkup =
234
+ hostAttribute === "data-start"
235
+ ? 'data-start="0" data-duration="10"'
236
+ : `${hostAttribute}="sub.html"`;
237
+ const { window, document } = parseHTML(
238
+ `<html><body><div id="host" ${hostAttrMarkup}><div id="pip-frame"><video id="pip" data-start="0" data-duration="10"></video></div></div></body></html>`,
239
+ );
240
+
241
+ Object.defineProperty(window.HTMLImageElement.prototype, "decode", {
242
+ configurable: true,
243
+ value: () => Promise.resolve(),
244
+ });
245
+
246
+ const host = document.getElementById("host") as HTMLElement;
247
+ const pipFrame = document.getElementById("pip-frame") as HTMLElement;
248
+ const video = document.getElementById("pip") as HTMLVideoElement;
249
+
250
+ Object.defineProperties(video, {
251
+ offsetLeft: { configurable: true, get: () => 0 },
252
+ offsetTop: { configurable: true, get: () => 0 },
253
+ offsetWidth: { configurable: true, get: () => 1080 },
254
+ offsetHeight: { configurable: true, get: () => 1920 },
255
+ });
256
+ video.getBoundingClientRect = () =>
257
+ ({
258
+ x: 0,
259
+ y: 0,
260
+ left: 0,
261
+ top: 0,
262
+ right: 1080,
263
+ bottom: 1920,
264
+ width: 1080,
265
+ height: 1920,
266
+ toJSON: () => ({}),
267
+ }) as DOMRect;
268
+
269
+ const styles = new Map<Element, StyleLike>();
270
+ styles.set(host, hostStyle);
271
+ styles.set(pipFrame, {});
272
+ styles.set(video, { opacity: "1", objectFit: "cover", objectPosition: "center", zIndex: "1" });
273
+
274
+ Object.defineProperty(window, "getComputedStyle", {
275
+ configurable: true,
276
+ value: (el: Element) => {
277
+ const declared = styles.get(el) ?? {};
278
+ return {
279
+ display: declared.display ?? "block",
280
+ visibility: declared.visibility ?? "visible",
281
+ opacity: declared.opacity ?? "1",
282
+ objectFit: declared.objectFit ?? "fill",
283
+ objectPosition: declared.objectPosition ?? "50% 50%",
284
+ zIndex: declared.zIndex ?? "auto",
285
+ getPropertyValue: (prop: string) => {
286
+ const camel = prop.replace(/-([a-z])/g, (_, c: string) =>
287
+ c.toUpperCase(),
288
+ ) as keyof StyleLike;
289
+ return declared[camel] ?? "";
290
+ },
291
+ };
292
+ },
293
+ });
294
+
295
+ return { window, document, video, host, pipFrame };
296
+ }
297
+
298
+ function withGlobals<T extends { window: Window; document: Document; video: HTMLVideoElement }>(
299
+ setup: T,
300
+ ): { teardown: () => void; setup: T } {
301
+ const globals = globalThis as unknown as { window?: Window; document?: Document };
302
+ const previousWindow = globals.window;
303
+ const previousDocument = globals.document;
304
+ globals.window = setup.window;
305
+ globals.document = setup.document;
306
+ return {
307
+ setup,
308
+ teardown: () => {
309
+ globals.window = previousWindow;
310
+ globals.document = previousDocument;
311
+ },
312
+ };
313
+ }
314
+
315
+ function passthroughPage(): Page {
316
+ return {
317
+ evaluate: async (fn: (...args: unknown[]) => unknown, ...args: unknown[]) =>
318
+ // The implementation is built to run inside the page sandbox via
319
+ // `page.evaluate`, but linkedom gives us a DOM compatible enough to
320
+ // execute the function body directly in Node.
321
+ Promise.resolve((fn as (...a: unknown[]) => unknown)(...args)),
322
+ } as unknown as Page;
323
+ }
324
+
325
+ it("skips replacement-frame creation when the video's host has visibility:hidden", async () => {
326
+ const { teardown, setup } = withGlobals(setupHostHiddenScenario({ visibility: "hidden" }));
327
+ try {
328
+ await injectVideoFramesBatch(passthroughPage(), [
329
+ {
330
+ videoId: "pip",
331
+ dataUri:
332
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
333
+ },
334
+ ]);
335
+ } finally {
336
+ teardown();
337
+ }
338
+
339
+ // No replacement <img> should be injected next to the video — the host is
340
+ // currently hidden, so painting a frame over it would bleed onto whichever
341
+ // sibling host is actually visible on this seek.
342
+ const sibling = setup.video.nextElementSibling as HTMLElement | null;
343
+ expect(sibling).toBeNull();
344
+ });
345
+
346
+ it("skips replacement-frame creation when the video's host has display:none", async () => {
347
+ const { teardown, setup } = withGlobals(setupHostHiddenScenario({ display: "none" }));
348
+ try {
349
+ await injectVideoFramesBatch(passthroughPage(), [
350
+ {
351
+ videoId: "pip",
352
+ dataUri:
353
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
354
+ },
355
+ ]);
356
+ } finally {
357
+ teardown();
358
+ }
359
+
360
+ const sibling = setup.video.nextElementSibling as HTMLElement | null;
361
+ expect(sibling).toBeNull();
362
+ });
363
+
364
+ it("hides an existing replacement <img> when the host becomes visibility:hidden", async () => {
365
+ // First seed an existing __render_frame__ <img> next to the video (the
366
+ // state the page is in after a previous seek when the host was visible).
367
+ const { teardown, setup } = withGlobals(setupHostHiddenScenario({ visibility: "hidden" }));
368
+ const seededImg = setup.document.createElement("img");
369
+ seededImg.classList.add("__render_frame__");
370
+ seededImg.style.visibility = "visible";
371
+ setup.video.parentNode?.insertBefore(seededImg, setup.video.nextSibling);
372
+
373
+ try {
374
+ await injectVideoFramesBatch(passthroughPage(), [
375
+ {
376
+ videoId: "pip",
377
+ dataUri:
378
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
379
+ },
380
+ ]);
381
+ } finally {
382
+ teardown();
383
+ }
384
+
385
+ expect(seededImg.style.visibility).toBe("hidden");
386
+ });
387
+
388
+ it("syncVideoFrameVisibility hides the replacement <img> for ancestor-hidden actives", async () => {
389
+ const { teardown, setup } = withGlobals(setupHostHiddenScenario({ visibility: "hidden" }));
390
+ const seededImg = setup.document.createElement("img");
391
+ seededImg.classList.add("__render_frame__");
392
+ seededImg.style.visibility = "visible";
393
+ setup.video.parentNode?.insertBefore(seededImg, setup.video.nextSibling);
394
+
395
+ try {
396
+ // "pip" IS in the active set (per the raw time-window check) but the
397
+ // host is hidden. sync must keep the <img> hidden, not flip it to
398
+ // `visibility: visible`.
399
+ await syncVideoFrameVisibility(passthroughPage(), ["pip"]);
400
+ } finally {
401
+ teardown();
402
+ }
403
+
404
+ expect(seededImg.style.visibility).toBe("hidden");
405
+ });
406
+
407
+ it("still injects when a plain [data-start] host is visibility:hidden (CSS-escapable)", async () => {
408
+ // Regression guard for the style-9-prod symptom: a regular
409
+ // `[data-start]` container whose GSAP timeline is shorter than its
410
+ // authored `data-duration` ends up `visibility: hidden` past the
411
+ // timeline end. The replacement <img>'s explicit `visibility: visible`
412
+ // correctly overrides that per CSS spec, so the injector must NOT
413
+ // short-circuit — it would otherwise drop the final-state frame and
414
+ // produce blank tail frames.
415
+ const { teardown, setup } = withGlobals(
416
+ setupHostHiddenScenario({ visibility: "hidden" }, { hostAttribute: "data-start" }),
417
+ );
418
+
419
+ try {
420
+ await injectVideoFramesBatch(passthroughPage(), [
421
+ {
422
+ videoId: "pip",
423
+ dataUri:
424
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
425
+ },
426
+ ]);
427
+ } finally {
428
+ teardown();
429
+ }
430
+
431
+ const sibling = setup.video.nextElementSibling as HTMLElement | null;
432
+ expect(sibling).not.toBeNull();
433
+ expect(sibling?.classList.contains("__render_frame__")).toBe(true);
434
+ expect(sibling?.style.visibility).toBe("visible");
435
+ });
436
+
437
+ it("syncVideoFrameVisibility shows the replacement <img> when a plain [data-start] host is visibility:hidden", async () => {
438
+ const { teardown, setup } = withGlobals(
439
+ setupHostHiddenScenario({ visibility: "hidden" }, { hostAttribute: "data-start" }),
440
+ );
441
+ const seededImg = setup.document.createElement("img");
442
+ seededImg.classList.add("__render_frame__");
443
+ seededImg.style.visibility = "hidden";
444
+ setup.video.parentNode?.insertBefore(seededImg, setup.video.nextSibling);
445
+
446
+ try {
447
+ await syncVideoFrameVisibility(passthroughPage(), ["pip"]);
448
+ } finally {
449
+ teardown();
450
+ }
451
+
452
+ // The host's `visibility: hidden` is escapable; sync must flip the
453
+ // <img> to `visibility: visible` so it overrides the ancestor.
454
+ expect(seededImg.style.visibility).toBe("visible");
455
+ });
456
+
457
+ // Regression for the layered/HDR mask path: `applyDomLayerMask` writes an
458
+ // `!important` stylesheet rule `#${showId} *{visibility:visible !important}`
459
+ // which, if a sub-comp host id appears in the show set, would revive a
460
+ // plain (non-important) inline `visibility: hidden` on a descendant
461
+ // `__render_frame__` — the cascade rule is "important stylesheet author
462
+ // beats non-important inline author". To stay safe regardless of which
463
+ // layer ends up in `show`, the ancestor-hidden hide must be written with
464
+ // `!important` so inline `!important` beats stylesheet `!important`.
465
+ //
466
+ // linkedom strips `!important` from `cssText`/`getPropertyPriority`, so we
467
+ // pin the contract on the API call site instead: a `setProperty(name,
468
+ // value, "important")` invocation on the live `<img>`'s style.
469
+ it("injectVideoFramesBatch hides a stale <img> with !important so the layer mask cannot revive it", async () => {
470
+ const { teardown, setup } = withGlobals(setupHostHiddenScenario({ visibility: "hidden" }));
471
+ const seededImg = setup.document.createElement("img");
472
+ seededImg.classList.add("__render_frame__");
473
+ seededImg.style.visibility = "visible";
474
+ setup.video.parentNode?.insertBefore(seededImg, setup.video.nextSibling);
475
+ const setPropertySpy = vi.spyOn(seededImg.style, "setProperty");
476
+
477
+ try {
478
+ await injectVideoFramesBatch(passthroughPage(), [
479
+ {
480
+ videoId: "pip",
481
+ dataUri:
482
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
483
+ },
484
+ ]);
485
+ } finally {
486
+ teardown();
487
+ }
488
+
489
+ expect(seededImg.style.visibility).toBe("hidden");
490
+ expect(setPropertySpy).toHaveBeenCalledWith("visibility", "hidden", "important");
491
+ });
492
+
493
+ it("syncVideoFrameVisibility hides an existing <img> with !important so the layer mask cannot revive it", async () => {
494
+ const { teardown, setup } = withGlobals(setupHostHiddenScenario({ visibility: "hidden" }));
495
+ const seededImg = setup.document.createElement("img");
496
+ seededImg.classList.add("__render_frame__");
497
+ seededImg.style.visibility = "visible";
498
+ setup.video.parentNode?.insertBefore(seededImg, setup.video.nextSibling);
499
+ const setPropertySpy = vi.spyOn(seededImg.style, "setProperty");
500
+
501
+ try {
502
+ await syncVideoFrameVisibility(passthroughPage(), ["pip"]);
503
+ } finally {
504
+ teardown();
505
+ }
506
+
507
+ expect(seededImg.style.visibility).toBe("hidden");
508
+ expect(setPropertySpy).toHaveBeenCalledWith("visibility", "hidden", "important");
509
+ });
510
+ });
@@ -369,13 +369,22 @@ export async function removeDomLayerMask(page: Page, extraHideIds: string[]): Pr
369
369
  );
370
370
  }
371
371
 
372
+ /**
373
+ * Returns the subset of `updates.videoId`s that were actually painted in
374
+ * this call. Videos skipped because of a hidden visual ancestor are NOT
375
+ * included — the caller relies on this to avoid recording a `lastInjected`
376
+ * cache entry for a frame that never reached the page, which would otherwise
377
+ * short-circuit the next inject at the same frameIndex and leave the host's
378
+ * first visible frame blank.
379
+ */
372
380
  export async function injectVideoFramesBatch(
373
381
  page: Page,
374
382
  updates: Array<{ videoId: string; dataUri: string }>,
375
- ): Promise<void> {
376
- if (updates.length === 0) return;
377
- await page.evaluate(
383
+ ): Promise<string[]> {
384
+ if (updates.length === 0) return [];
385
+ return await page.evaluate(
378
386
  async (items: Array<{ videoId: string; dataUri: string }>, visualProperties: string[]) => {
387
+ const injectedIds: string[] = [];
379
388
  const pendingDecodes: Array<Promise<void>> = [];
380
389
  const replacementLayoutProperties = new Set([
381
390
  "width",
@@ -386,12 +395,66 @@ export async function injectVideoFramesBatch(
386
395
  "bottom",
387
396
  "inset",
388
397
  ]);
398
+ // Walk ancestors looking for a host that the page has hidden. The
399
+ // runtime hides `[data-composition-src]` and `[data-start]` hosts that
400
+ // fall outside their time window; a nested `<video data-start>` inside
401
+ // such a host still appears "active" in the raw time-window check (its
402
+ // own `data-start`/`data-end` cover the whole clip), so without this
403
+ // guard we would paint a full-bleed replacement frame over a sibling
404
+ // host that *is* visible.
405
+ //
406
+ // `display: none` is always a skip signal — a `display: none` ancestor
407
+ // takes its whole subtree out of layout, and a child `<img>` cannot
408
+ // escape that. `visibility: hidden`, by contrast, is escapable: a
409
+ // descendant with `visibility: visible` overrides an ancestor's
410
+ // `visibility: hidden` per the CSS spec, and the replacement `<img>`
411
+ // intentionally sets `visibility: visible`. We therefore only treat
412
+ // `visibility: hidden` as a skip signal on sub-composition hosts
413
+ // (`[data-composition-src]` / `[data-composition-file]`), which is the
414
+ // scenario this guard exists for. Plain `[data-start]` containers may
415
+ // be hidden with `visibility: hidden` while still wanting their inner
416
+ // video's final-state frame to paint through (e.g. a GSAP timeline
417
+ // shorter than the host's authored data-duration, where the runtime
418
+ // truncates visibility but the replacement <img> must hold its last
419
+ // frame) — those must NOT be skipped here.
420
+ const isVisualAncestorHidden = (el: HTMLElement): boolean => {
421
+ let parent = el.parentElement;
422
+ while (parent !== null && parent !== document.documentElement) {
423
+ const computed = window.getComputedStyle(parent);
424
+ if (computed.display === "none") return true;
425
+ if (
426
+ computed.visibility === "hidden" &&
427
+ (parent.hasAttribute("data-composition-src") ||
428
+ parent.hasAttribute("data-composition-file"))
429
+ ) {
430
+ return true;
431
+ }
432
+ parent = parent.parentElement;
433
+ }
434
+ return false;
435
+ };
389
436
  for (const item of items) {
390
437
  const video = document.getElementById(item.videoId) as HTMLVideoElement | null;
391
438
  if (!video) continue;
392
439
 
393
440
  let img = video.nextElementSibling as HTMLImageElement | null;
394
- const isNewImage = !img || !img.classList.contains("__render_frame__");
441
+ const hasImg = img !== null && img.classList.contains("__render_frame__");
442
+
443
+ if (isVisualAncestorHidden(video)) {
444
+ // Don't paint a frame over a hidden host — if an existing replacement
445
+ // <img> is still around from when the host was visible, hide it so it
446
+ // doesn't bleed through a sibling host that *is* visible on this seek.
447
+ //
448
+ // Use `!important` so the inline hide survives `applyDomLayerMask`'s
449
+ // stylesheet `#${showId} *{visibility:visible !important}` when the
450
+ // sub-comp host happens to land in the active layer's `show` set —
451
+ // important stylesheet beats non-important inline, but important
452
+ // inline beats important stylesheet.
453
+ if (hasImg && img) img.style.setProperty("visibility", "hidden", "important");
454
+ continue;
455
+ }
456
+
457
+ const isNewImage = !hasImg;
395
458
  const computedStyle = window.getComputedStyle(video);
396
459
  // Read the GSAP-controlled opacity directly from the native <video>.
397
460
  // We hide the <video> below with `visibility: hidden` only (never
@@ -474,10 +537,12 @@ export async function injectVideoFramesBatch(
474
537
  // GSAP-controlled value.
475
538
  video.style.setProperty("visibility", "hidden", "important");
476
539
  video.style.setProperty("pointer-events", "none", "important");
540
+ injectedIds.push(item.videoId);
477
541
  }
478
542
  if (pendingDecodes.length > 0) {
479
543
  await Promise.all(pendingDecodes);
480
544
  }
545
+ return injectedIds;
481
546
  },
482
547
  updates,
483
548
  [...MEDIA_VISUAL_STYLE_PROPERTIES],
@@ -489,12 +554,33 @@ export async function syncVideoFrameVisibility(
489
554
  activeVideoIds: string[],
490
555
  ): Promise<void> {
491
556
  await page.evaluate((ids: string[]) => {
557
+ // Mirror the ancestor-visibility guard from `injectVideoFramesBatch`.
558
+ // See that copy for the full rationale on why `visibility: hidden` is
559
+ // narrowed to sub-composition hosts only — keep these two functions in
560
+ // sync so the inactive-arm decision matches the inject-time decision.
561
+ const isVisualAncestorHidden = (el: HTMLElement): boolean => {
562
+ let parent = el.parentElement;
563
+ while (parent !== null && parent !== document.documentElement) {
564
+ const computed = window.getComputedStyle(parent);
565
+ if (computed.display === "none") return true;
566
+ if (
567
+ computed.visibility === "hidden" &&
568
+ (parent.hasAttribute("data-composition-src") ||
569
+ parent.hasAttribute("data-composition-file"))
570
+ ) {
571
+ return true;
572
+ }
573
+ parent = parent.parentElement;
574
+ }
575
+ return false;
576
+ };
492
577
  const active = new Set(ids);
493
578
  const videos = Array.from(document.querySelectorAll("video[data-start]")) as HTMLVideoElement[];
494
579
  for (const video of videos) {
495
580
  const img = video.nextElementSibling as HTMLElement | null;
496
581
  const hasImg = img && img.classList.contains("__render_frame__");
497
- if (active.has(video.id)) {
582
+ const ancestorHidden = isVisualAncestorHidden(video);
583
+ if (active.has(video.id) && !ancestorHidden) {
498
584
  // Active video: show injected <img>, hide native <video>.
499
585
  // Do NOT clobber inline opacity here — GSAP-controlled opacity must
500
586
  // survive until injectVideoFramesBatch reads it via getComputedStyle.
@@ -506,13 +592,18 @@ export async function syncVideoFrameVisibility(
506
592
  img.style.visibility = "visible";
507
593
  }
508
594
  } else {
509
- // Inactive video: hide both. Use visibility only (never opacity) so we
510
- // never clobber GSAP-controlled inline opacity.
595
+ // Inactive (or ancestor-hidden) video: hide both. Use visibility only
596
+ // (never opacity) so we never clobber GSAP-controlled inline opacity.
597
+ // Use `!important` on the <img> hide so `applyDomLayerMask`'s
598
+ // important stylesheet rule (`#${showId} *{visibility:visible !important}`)
599
+ // cannot revive a stale frame when the sub-comp host lands in the
600
+ // active layer's `show` set — same mask-defense reasoning as the
601
+ // `isVisualAncestorHidden` branch in `injectVideoFramesBatch`.
511
602
  video.style.removeProperty("display");
512
603
  video.style.setProperty("visibility", "hidden", "important");
513
604
  video.style.setProperty("pointer-events", "none", "important");
514
605
  if (hasImg) {
515
- img.style.visibility = "hidden";
606
+ img.style.setProperty("visibility", "hidden", "important");
516
607
  }
517
608
  }
518
609
  }
@@ -265,6 +265,26 @@ describe("buildStreamingArgs", () => {
265
265
  const args = buildStreamingArgs({ ...baseGpu, preset: "medium" }, "/tmp/out.mp4", "qsv");
266
266
  expect(presetArg(args)).toBe("medium");
267
267
  });
268
+
269
+ it("uses AMD AMF encoder names and quality flags when selected", () => {
270
+ const h264Args = buildStreamingArgs(
271
+ { ...baseGpu, preset: "medium", quality: 23 },
272
+ "/tmp/out.mp4",
273
+ "amf",
274
+ );
275
+ expect(h264Args[h264Args.indexOf("-c:v") + 1]).toBe("h264_amf");
276
+ expect(h264Args[h264Args.indexOf("-qp_i") + 1]).toBe("23");
277
+ expect(h264Args).toContain("-bf");
278
+ expect(h264Args[h264Args.indexOf("-bf") + 1]).toBe("0");
279
+
280
+ const h265Args = buildStreamingArgs(
281
+ { ...baseGpu, codec: "h265", preset: "medium", quality: 23 },
282
+ "/tmp/out.mp4",
283
+ "amf",
284
+ );
285
+ expect(h265Args[h265Args.indexOf("-c:v") + 1]).toBe("hevc_amf");
286
+ expect(h265Args[h265Args.indexOf("-qp_i") + 1]).toBe("23");
287
+ });
268
288
  });
269
289
  });
270
290