diff --git a/packages/cli/src/capture/captureCompositionFrame.test.ts b/packages/cli/src/capture/captureCompositionFrame.test.ts index cf942565c..559a54f91 100644 --- a/packages/cli/src/capture/captureCompositionFrame.test.ts +++ b/packages/cli/src/capture/captureCompositionFrame.test.ts @@ -1,13 +1,181 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { runFfmpegOnce } from "./captureCompositionFrame.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + resolveCliChromeGpuMode, + runFfmpegOnce, + seekCompositionTimeline, + type CompositionSeekPage, +} from "./captureCompositionFrame.js"; function tempDir(): string { return mkdtempSync(join(tmpdir(), "hf-capture-frame-test-")); } +function fakeSeekPage() { + const evaluate = vi.fn( + async ( + _pageFunction: Parameters[0], + _value?: number, + _fallbackToBridgeAndTimelines?: boolean, + ): Promise => undefined, + ); + const waitForFunction = vi.fn( + async (_pageFunction: () => boolean, _options: { timeout: number }): Promise => + undefined, + ); + const page: CompositionSeekPage = { evaluate, waitForFunction }; + return { page, evaluate, waitForFunction }; +} + +function runBrowserSeek(evaluate: ReturnType["evaluate"]): void { + const seekInBrowser = evaluate.mock.calls[0]?.[0]; + if (typeof seekInBrowser !== "function") throw new Error("Expected a browser seek function"); + Reflect.apply(seekInBrowser, undefined, evaluate.mock.calls[0]?.slice(1) ?? []); +} + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("seekCompositionTimeline", () => { + it("keeps the existing raced double-frame settle as the default", async () => { + const { page, evaluate, waitForFunction } = fakeSeekPage(); + + await seekCompositionTimeline(page, 1.25); + + expect(waitForFunction).not.toHaveBeenCalled(); + expect(evaluate).toHaveBeenCalledTimes(2); + expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 1.25, false); + expect(evaluate.mock.calls[1]?.[0]).toContain("window.setTimeout(finish, 100)"); + }); + + it("prefers renderSeek so the runtime synchronizes clip visibility", async () => { + const { page, evaluate } = fakeSeekPage(); + const renderSeek = vi.fn(); + const bridgeSeek = vi.fn(); + const playerSeek = vi.fn(); + const timelineSeek = vi.fn(); + vi.stubGlobal("window", { + __player: { renderSeek, seek: playerSeek }, + __hf: { seek: bridgeSeek }, + __timelines: { main: { seek: timelineSeek } }, + }); + + await seekCompositionTimeline(page, 2.25); + runBrowserSeek(evaluate); + + expect(renderSeek).toHaveBeenCalledWith(2.25); + expect(bridgeSeek).not.toHaveBeenCalled(); + expect(playerSeek).not.toHaveBeenCalled(); + expect(timelineSeek).not.toHaveBeenCalled(); + }); + + function fakeBridgeOnlySeekPage() { + const { page, evaluate } = fakeSeekPage(); + const bridgeSeek = vi.fn(); + const tickerTick = vi.fn(); + vi.stubGlobal("window", { __hf: { seek: bridgeSeek }, gsap: { ticker: { tick: tickerTick } } }); + return { page, evaluate, bridgeSeek, tickerTick }; + } + + it("keeps bridge and raw fallbacks disabled for default capture callers", async () => { + const { page, evaluate, bridgeSeek, tickerTick } = fakeBridgeOnlySeekPage(); + + await seekCompositionTimeline(page, 2.5); + runBrowserSeek(evaluate); + + expect(bridgeSeek).not.toHaveBeenCalled(); + expect(tickerTick).not.toHaveBeenCalled(); + }); + + it("opts into the bridge before player and raw timeline fallbacks", async () => { + const { page, evaluate, bridgeSeek, tickerTick } = fakeBridgeOnlySeekPage(); + + await seekCompositionTimeline(page, 2.5, { fallbackToBridgeAndTimelines: true }); + runBrowserSeek(evaluate); + + expect(bridgeSeek).toHaveBeenCalledWith(2.5); + expect(tickerTick).toHaveBeenCalledOnce(); + }); + + it("opts into pausing and seeking raw timelines when no preferred target exists", async () => { + const { page, evaluate } = fakeSeekPage(); + const pause = vi.fn(); + const seek = vi.fn(); + vi.stubGlobal("window", { __timelines: { main: { pause, seek } } }); + + await seekCompositionTimeline(page, 1.75, { fallbackToBridgeAndTimelines: true }); + runBrowserSeek(evaluate); + + expect(pause).toHaveBeenCalledOnce(); + expect(seek).toHaveBeenCalledWith(1.75); + }); + + it("supports validate settling without adding an animation-frame or font wait", async () => { + vi.useFakeTimers(); + const { page, evaluate, waitForFunction } = fakeSeekPage(); + + const pending = seekCompositionTimeline(page, 3, { + fallbackToBridgeAndTimelines: true, + waitForPreferredSeekTargetMs: 500, + animationFrameSettle: "none", + settleMs: 150, + }); + await vi.advanceTimersByTimeAsync(150); + await pending; + + expect(waitForFunction).toHaveBeenCalledWith(expect.any(Function), { timeout: 500 }); + expect(evaluate).toHaveBeenCalledTimes(1); + expect(evaluate).toHaveBeenCalledWith(expect.any(Function), 3, true); + }); + + it("supports layout's ordered double-frame, bounded font, and sleep settles", async () => { + vi.useFakeTimers(); + const { page, evaluate } = fakeSeekPage(); + + const pending = seekCompositionTimeline(page, 4, { + fallbackToBridgeAndTimelines: true, + animationFrameSettle: "double", + waitForFontsMs: 500, + settleMs: 120, + }); + await vi.advanceTimersByTimeAsync(120); + await pending; + + expect(evaluate).toHaveBeenCalledTimes(3); + expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 4, true); + expect(evaluate).toHaveBeenNthCalledWith(2, expect.any(Function)); + expect(evaluate).toHaveBeenNthCalledWith(3, expect.any(Function), 500); + }); +}); + +describe("resolveCliChromeGpuMode", () => { + it("preserves validate's software-only opt-in mapping", () => { + expect(resolveCliChromeGpuMode("software")).toBe("software"); + expect(resolveCliChromeGpuMode("hardware")).toBe("hardware"); + expect(resolveCliChromeGpuMode("auto")).toBe("hardware"); + expect(resolveCliChromeGpuMode("")).toBe("hardware"); + }); +}); + +describe("screenshot Chrome arguments", () => { + it("leaves shared capture and layout on the engine's software default", () => { + const defaultScreenshotArgs = + /args:\s*buildChromeArgs\(\s*\{[^}]*captureMode:\s*"screenshot"[^}]*\}\s*\),/; + const captureSource = readFileSync( + new URL("./captureCompositionFrame.ts", import.meta.url), + "utf8", + ); + const layoutSource = readFileSync(new URL("../commands/layout.ts", import.meta.url), "utf8"); + + expect(captureSource).toMatch(defaultScreenshotArgs); + expect(layoutSource).toMatch(defaultScreenshotArgs); + }); +}); + describe("runFfmpegOnce", () => { it("returns the process exit code and collected stderr", async () => { const dir = tempDir(); diff --git a/packages/cli/src/capture/captureCompositionFrame.ts b/packages/cli/src/capture/captureCompositionFrame.ts index fbc15331a..6fe0d23cc 100644 --- a/packages/cli/src/capture/captureCompositionFrame.ts +++ b/packages/cli/src/capture/captureCompositionFrame.ts @@ -3,17 +3,35 @@ import type { Browser, Page } from "puppeteer-core"; import { c } from "../ui/colors.js"; import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js"; -const CHROME_LAUNCH_ARGS = [ - "--no-sandbox", - "--disable-gpu", - "--disable-dev-shm-usage", - "--enable-webgl", - "--use-gl=angle", - "--use-angle=swiftshader", -]; - const SHADER_TRANSITIONS_TIMEOUT_MS = 90_000; const CAPTURE_SETTLE_MS = 1500; +const PREFERRED_SEEK_TARGET_WAIT_MS = 500; + +export interface SeekCompositionTimelineOptions { + fallbackToBridgeAndTimelines?: boolean; + waitForPreferredSeekTargetMs?: number; + animationFrameSettle?: "race" | "double" | "none"; + waitForFontsMs?: number; + settleMs?: number; +} + +type CompositionPageFunction = + | string + | (() => unknown) + | ((value: number) => unknown) + | ((value: number, fallbackToBridgeAndTimelines: boolean) => unknown); + +export interface CompositionEvaluationPage { + evaluate( + pageFunction: CompositionPageFunction, + value?: number, + fallbackToBridgeAndTimelines?: boolean, + ): Promise; +} + +export interface CompositionSeekPage extends CompositionEvaluationPage { + waitForFunction?(pageFunction: () => boolean, options: { timeout: number }): Promise; +} export interface SettledCompositionPage { browser: Browser; @@ -34,6 +52,12 @@ export interface FfmpegRunResult { timedOut: boolean; } +export function resolveCliChromeGpuMode( + envMode = process.env.PRODUCER_BROWSER_GPU_MODE, +): "software" | "hardware" { + return envMode === "software" ? "software" : "hardware"; +} + function compositionRuntimeReadyInBrowser(): boolean { return Boolean(Reflect.get(window, "__renderReady")); } @@ -97,20 +121,22 @@ export async function openSettledCompositionPage( url: string, options: OpenSettledCompositionPageOptions, ): Promise { + const viewport = resolveCompositionViewportFromHtml(html); const { ensureBrowser } = await import("../browser/manager.js"); const browser = await ensureBrowser(); const puppeteer = await import("puppeteer-core"); + const { buildChromeArgs } = await import("@hyperframes/engine"); let chromeBrowser: Browser | undefined; try { chromeBrowser = await puppeteer.default.launch({ headless: true, executablePath: browser.executablePath, - args: CHROME_LAUNCH_ARGS, + args: buildChromeArgs({ ...viewport, captureMode: "screenshot" }), }); const page = await chromeBrowser.newPage(); - await page.setViewport(resolveCompositionViewportFromHtml(html)); + await page.setViewport(viewport); await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 }); const renderReadyTimedOut = !(await waitForCompositionSettle(page, options)); return { browser: chromeBrowser, page, renderReadyTimedOut }; @@ -121,29 +147,133 @@ export async function openSettledCompositionPage( } export async function seekCompositionTimeline( - page: Pick, + page: CompositionSeekPage, timeSeconds: number, + options: SeekCompositionTimelineOptions = {}, ): Promise { - await page.evaluate((t: number) => { - const player = (window as any).__player; - if (!player) return; - const safe = Math.max(0, Number(t) || 0); - if (typeof player.renderSeek === "function") { - player.renderSeek(safe); - } else if (typeof player.seek === "function") { - player.seek(safe); - } - if ((window as any).gsap?.ticker?.tick) { - (window as any).gsap.ticker.tick(); - } - }, timeSeconds); + if (options.waitForPreferredSeekTargetMs !== undefined) { + await waitForPreferredSeekTarget(page, options.waitForPreferredSeekTargetMs); + } - await page.evaluate(`new Promise(function(r) { - var settled = false; - function finish() { if (settled) return; settled = true; r(); } - window.setTimeout(finish, 100); - requestAnimationFrame(function() { requestAnimationFrame(finish); }); - })`); + await page.evaluate( + // Serialized into the page; the seek-target cascade must stay one function. + // fallow-ignore-next-line complexity + (t: number, fallbackToBridgeAndTimelines: boolean) => { + const getProperty = (target: unknown, key: string): unknown => { + if ((typeof target !== "object" || target === null) && typeof target !== "function") { + return undefined; + } + return Reflect.get(target, key); + }; + const call = (fn: unknown, receiver: unknown, args: unknown[]): boolean => { + if (typeof fn !== "function") return false; + Reflect.apply(fn, receiver, args); + return true; + }; + + const player = Reflect.get(window, "__player"); + if (!player && !fallbackToBridgeAndTimelines) return; + + const safe = Math.max(0, Number(t) || 0); + const renderSeek = getProperty(player, "renderSeek"); + const playerSeek = getProperty(player, "seek"); + const hf = Reflect.get(window, "__hf"); + const bridgeSeek = getProperty(hf, "seek"); + + // Prefer renderSeek because it also runs the runtime's data-start/data-duration + // visibility sync; raw timeline seeks leave off-window clips visible to audits. + if (call(renderSeek, player, [safe])) { + // Preferred runtime target handled the seek. + } else if (fallbackToBridgeAndTimelines && call(bridgeSeek, hf, [safe])) { + // Producer bridge handled the seek. + } else if (call(playerSeek, player, [safe])) { + // Legacy player target handled the seek. + } else if (fallbackToBridgeAndTimelines) { + const timelines = Reflect.get(window, "__timelines"); + if (typeof timelines === "object" && timelines !== null) { + for (const key of Object.keys(timelines)) { + const timeline = Reflect.get(timelines, key); + call(getProperty(timeline, "pause"), timeline, []); + call(getProperty(timeline, "seek"), timeline, [safe]); + } + } + } + + const gsap = Reflect.get(window, "gsap"); + const ticker = getProperty(gsap, "ticker"); + call(getProperty(ticker, "tick"), ticker, []); + }, + timeSeconds, + options.fallbackToBridgeAndTimelines === true, + ); + + const animationFrameSettle = options.animationFrameSettle ?? "race"; + if (animationFrameSettle === "race") { + await page.evaluate(`new Promise(function(r) { + var settled = false; + function finish() { if (settled) return; settled = true; r(); } + window.setTimeout(finish, 100); + requestAnimationFrame(function() { requestAnimationFrame(finish); }); + })`); + } else if (animationFrameSettle === "double") { + await page.evaluate( + () => + new Promise((resolveFrame) => + requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame())), + ), + ); + } + + if (options.waitForFontsMs !== undefined) { + await waitForCompositionFonts(page, options.waitForFontsMs); + } + if (options.settleMs !== undefined) { + const settleMs = Math.max(0, options.settleMs); + await new Promise((resolveSettle) => setTimeout(resolveSettle, settleMs)); + } +} + +export async function waitForPreferredSeekTarget( + page: Pick, + timeoutMs = PREFERRED_SEEK_TARGET_WAIT_MS, +): Promise { + if (!page.waitForFunction) return; + try { + await page.waitForFunction( + () => { + const player = Reflect.get(window, "__player"); + const hf = Reflect.get(window, "__hf"); + const renderSeek = + typeof player === "object" && player !== null + ? Reflect.get(player, "renderSeek") + : undefined; + const bridgeSeek = + typeof hf === "object" && hf !== null ? Reflect.get(hf, "seek") : undefined; + return typeof renderSeek === "function" || typeof bridgeSeek === "function"; + }, + { timeout: timeoutMs }, + ); + } catch { + // Legacy/static pages may only expose raw timelines; keep that fallback available. + } +} + +export async function waitForCompositionFonts( + page: CompositionEvaluationPage, + timeoutMs: number, +): Promise { + await page + .evaluate((ms: number) => { + const fonts = Reflect.get(document, "fonts"); + if (typeof fonts !== "object" || fonts === null) return Promise.resolve(); + const ready = Reflect.get(fonts, "ready"); + if (!ready) return Promise.resolve(); + return Promise.race([ + Promise.resolve(ready).then(() => undefined), + new Promise((resolve) => setTimeout(resolve, ms)), + ]); + }, timeoutMs) + .catch(() => {}); } export async function runFfmpegOnce( diff --git a/packages/cli/src/commands/layout.ts b/packages/cli/src/commands/layout.ts index 34a70eb5f..5739ad498 100644 --- a/packages/cli/src/commands/layout.ts +++ b/packages/cli/src/commands/layout.ts @@ -26,10 +26,21 @@ import { type MotionFrame, } from "../utils/motionAudit.js"; import { findMotionSpec, readMotionSpec, type MotionSpec } from "../utils/motionSpec.js"; +import { + seekCompositionTimeline, + waitForCompositionFonts, + type SeekCompositionTimelineOptions, +} from "../capture/captureCompositionFrame.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const SEEK_SETTLE_MS = 120; +const LAYOUT_SEEK_OPTIONS: SeekCompositionTimelineOptions = { + fallbackToBridgeAndTimelines: true, + animationFrameSettle: "double", + waitForFontsMs: 500, + settleMs: SEEK_SETTLE_MS, +}; // All new envelope fields are optional (?); additive changes don't bump this. const INSPECT_SCHEMA_VERSION = 1; // Motion verification (#1437): dense sampling grid for the seeked-timeline checks. @@ -68,6 +79,8 @@ function buildMotionSampleTimes(duration: number): number[] { } async function getCompositionDuration(page: import("puppeteer-core").Page): Promise { + // Serialized into the page; the duration-source cascade cannot be split. + // fallow-ignore-next-line complexity return page.evaluate(() => { const win = window as unknown as { __hf?: { duration?: number }; @@ -96,52 +109,6 @@ async function getCompositionDuration(page: import("puppeteer-core").Page): Prom }); } -async function waitForFonts(page: import("puppeteer-core").Page, timeoutMs: number): Promise { - await page - .evaluate((ms: number) => { - const fonts = (document as Document & { fonts?: FontFaceSet }).fonts; - if (!fonts?.ready) return Promise.resolve(); - return Promise.race([ - fonts.ready.then(() => undefined), - new Promise((resolve) => setTimeout(resolve, ms)), - ]); - }, timeoutMs) - .catch(() => {}); -} - -async function seekTo(page: import("puppeteer-core").Page, time: number): Promise { - await page.evaluate((t: number) => { - const win = window as unknown as { - __hf?: { seek?: (time: number) => void }; - __player?: { seek?: (time: number) => void }; - __timelines?: Record void; seek?: (time: number) => void }>; - }; - if (typeof win.__hf?.seek === "function") { - win.__hf.seek(t); - return; - } - if (typeof win.__player?.seek === "function") { - win.__player.seek(t); - return; - } - const timelines = win.__timelines; - if (timelines) { - for (const timeline of Object.values(timelines)) { - if (typeof timeline.pause === "function") timeline.pause(); - if (typeof timeline.seek === "function") timeline.seek(t); - } - } - }, time); - await page.evaluate( - () => - new Promise((resolveFrame) => - requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame())), - ), - ); - await waitForFonts(page, 500); - await new Promise((resolveSettle) => setTimeout(resolveSettle, SEEK_SETTLE_MS)); -} - /** * Collect every tween start/end boundary from the registered timelines, * expressed in the registered timeline's own time (what seekTo consumes). @@ -234,6 +201,7 @@ async function runLayoutAudit( ): Promise { const { ensureBrowser } = await import("../browser/manager.js"); const puppeteer = await import("puppeteer-core"); + const { buildChromeArgs } = await import("@hyperframes/engine"); const html = await bundleProjectHtml(projectDir); const server = await serveStaticProjectHtml( projectDir, @@ -247,14 +215,7 @@ async function runLayoutAudit( chromeBrowser = await puppeteer.default.launch({ headless: true, executablePath: browser.executablePath, - args: [ - "--no-sandbox", - "--disable-gpu", - "--disable-dev-shm-usage", - "--enable-webgl", - "--use-gl=angle", - "--use-angle=swiftshader", - ], + args: buildChromeArgs({ width: 1920, height: 1080, captureMode: "screenshot" }), }); const page = await chromeBrowser.newPage(); @@ -266,7 +227,7 @@ async function runLayoutAudit( timeout: opts.timeout, }) .catch(() => {}); - await waitForFonts(page, 750); + await waitForCompositionFonts(page, 750); await new Promise((resolveSettle) => setTimeout(resolveSettle, 250)); const duration = await getCompositionDuration(page); @@ -330,7 +291,7 @@ async function collectLayoutIssues( const issues: LayoutIssue[] = []; for (const time of samples) { - await seekTo(page, time); + await seekCompositionTimeline(page, time, LAYOUT_SEEK_OPTIONS); const sampleIssues = await page.evaluate( (auditOptions: { time: number; tolerance: number }) => { const win = window as unknown as { @@ -373,7 +334,7 @@ async function collectMotionFrames( ): Promise { const frames: MotionFrame[] = []; for (const time of times) { - await seekTo(page, time); + await seekCompositionTimeline(page, time, LAYOUT_SEEK_OPTIONS); const sample = await page.evaluate( (options: { selectors: string[]; livenessScopes: string[] }) => { const win = window as unknown as { @@ -512,6 +473,8 @@ export function createInspectCommand(commandName: "inspect" | "layout") { default: false, }, }, + // Pre-existing command-run branching; U1 only swapped the seek internals. + // fallow-ignore-next-line complexity async run({ args }) { const project = resolveProject(args.dir); const samples = Math.max(1, parseInt(args.samples as string, 10) || 9); diff --git a/packages/cli/src/commands/validate.test.ts b/packages/cli/src/commands/validate.test.ts index 7353806c4..bd3941286 100644 --- a/packages/cli/src/commands/validate.test.ts +++ b/packages/cli/src/commands/validate.test.ts @@ -131,6 +131,16 @@ describe("waitForPreferredSeekTarget", () => { await expect(waitForPreferredSeekTarget(page, 1)).resolves.toBeUndefined(); }); + + it("does not fail validation when the page stub throws synchronously", async () => { + const page = { + waitForFunction: vi.fn(() => { + throw new Error("waiting failed synchronously"); + }), + }; + + await expect(waitForPreferredSeekTarget(page, 1)).resolves.toBeUndefined(); + }); }); describe("extractCompositionErrorsFromLint", () => { diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 5f10ae3a9..a5b40a688 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -9,6 +9,12 @@ import type { ProjectLintResult } from "../utils/lintProject.js"; import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js"; import { c } from "../ui/colors.js"; import { withMeta } from "../utils/updateCheck.js"; +import { + resolveCliChromeGpuMode, + seekCompositionTimeline, +} from "../capture/captureCompositionFrame.js"; + +export { waitForPreferredSeekTarget } from "../capture/captureCompositionFrame.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -85,67 +91,6 @@ async function getCompositionDuration(page: import("puppeteer-core").Page): Prom }); } -async function seekTo(page: import("puppeteer-core").Page, time: number): Promise { - await waitForPreferredSeekTarget(page); - await page.evaluate((t: number) => { - // window.__player.renderSeek is exposed directly by the composition - // runtime (packages/core/src/runtime/init.ts) on every page load, and - // — unlike raw timeline.seek() — it also runs the runtime's own - // [data-start]/[data-duration] visibility sync, hiding clips outside - // their timeline window. window.__hf.seek only exists when the - // producer's render-pipeline bridge script has been injected, which - // validate's static preview server never does, so it was always - // falling through to the raw __timelines seek below and skipping that - // sync — leaving off-window elements looking fully visible to any - // check (e.g. the contrast audit) that reads computed style afterward. - const player = (window as unknown as { __player?: { renderSeek?: (t: number) => void } }) - .__player; - if (player && typeof player.renderSeek === "function") { - player.renderSeek(t); - return; - } - if (window.__hf && typeof window.__hf.seek === "function") { - window.__hf.seek(t); - return; - } - const timelines = (window as unknown as Record).__timelines as - | Record void }> - | undefined; - if (timelines) { - for (const tl of Object.values(timelines)) { - if (typeof tl.seek === "function") tl.seek(t); - } - } - }, time); - await new Promise((r) => setTimeout(r, SEEK_SETTLE_MS)); -} - -interface WaitForFunctionPage { - waitForFunction: (pageFunction: () => boolean, options: { timeout: number }) => Promise; -} - -export async function waitForPreferredSeekTarget( - page: WaitForFunctionPage, - timeoutMs = PREFERRED_SEEK_TARGET_WAIT_MS, -): Promise { - try { - await page.waitForFunction( - () => { - const w = window as unknown as { - __hf?: { seek?: unknown }; - __player?: { renderSeek?: unknown }; - }; - return typeof w.__player?.renderSeek === "function" || typeof w.__hf?.seek === "function"; - }, - { timeout: timeoutMs }, - ); - } catch { - // Older/static pages may only expose raw window.__timelines. Keep the - // legacy fallback path rather than turning a missing player API into a - // validate failure. - } -} - /** * Race a media element's `loadedmetadata`/`error` event against a deadline, * whichever comes first. Already-ready elements resolve immediately. @@ -165,6 +110,8 @@ export function raceMediaReady( ): Promise { if (Number.isFinite(el.duration) && el.duration > 0) return Promise.resolve(); return new Promise((resolve) => { + // Clones its in-page twin below; evaluate() bodies can't import Node helpers. + // fallow-ignore-next-line code-duplication const onReady = () => { el.removeEventListener("loadedmetadata", onReady); el.removeEventListener("error", onReady); @@ -209,6 +156,8 @@ async function auditClipDurations( nodes.map((el) => { if (Number.isFinite(el.duration) && el.duration > 0) return Promise.resolve(); return new Promise((resolve) => { + // fallow-ignore-next-line code-duplication + // Serialized twin of the Node-side metadata wait above. // fallow-ignore-next-line code-duplication const cleanup = () => { el.removeEventListener("loadedmetadata", onReady); @@ -300,7 +249,12 @@ async function runContrastAudit(page: import("puppeteer-core").Page): Promise