@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.
- package/dist/services/chunkEncoder.d.ts.map +1 -1
- package/dist/services/chunkEncoder.js +11 -2
- package/dist/services/chunkEncoder.js.map +1 -1
- package/dist/services/parallelCoordinator.d.ts.map +1 -1
- package/dist/services/parallelCoordinator.js +43 -43
- package/dist/services/parallelCoordinator.js.map +1 -1
- package/dist/services/screenshotService.d.ts +9 -1
- package/dist/services/screenshotService.d.ts.map +1 -1
- package/dist/services/screenshotService.js +95 -7
- package/dist/services/screenshotService.js.map +1 -1
- package/dist/services/streamingEncoder.d.ts.map +1 -1
- package/dist/services/streamingEncoder.js +12 -3
- package/dist/services/streamingEncoder.js.map +1 -1
- package/dist/services/videoFrameInjector.d.ts.map +1 -1
- package/dist/services/videoFrameInjector.js +10 -2
- package/dist/services/videoFrameInjector.js.map +1 -1
- package/dist/utils/gpuEncoder.d.ts +6 -3
- package/dist/utils/gpuEncoder.d.ts.map +1 -1
- package/dist/utils/gpuEncoder.js +123 -12
- package/dist/utils/gpuEncoder.js.map +1 -1
- package/package.json +2 -2
- package/src/services/chunkEncoder.test.ts +24 -2
- package/src/services/chunkEncoder.ts +9 -2
- package/src/services/parallelCoordinator.ts +58 -42
- package/src/services/screenshotService.test.ts +317 -0
- package/src/services/screenshotService.ts +99 -8
- package/src/services/streamingEncoder.test.ts +20 -0
- package/src/services/streamingEncoder.ts +10 -3
- package/src/services/videoFrameInjector.test.ts +111 -2
- package/src/services/videoFrameInjector.ts +14 -4
- package/src/utils/gpuEncoder.test.ts +68 -3
- 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
|
-
|
|
263
|
+
workerConfig,
|
|
219
264
|
);
|
|
220
|
-
//
|
|
221
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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<
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|