mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
refactor(cli): unify seek/settle and Chrome launch across browser commands
seekCompositionTimeline becomes the single seek implementation with per-caller settle options (rAF mode, font wait, settle sleep), replacing the divergent local seekTo copies in validate and layout. All three launch paths now build args via the engine's buildChromeArgs; screenshot paths keep the engine's software-GPU default for deterministic output. inspect gains one transient content_overlap warning on product-promo (t=12.22s): the gsap.ticker.tick flush samples timeline state the old layout seek missed.
This commit is contained in:
@@ -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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { runFfmpegOnce } from "./captureCompositionFrame.js";
|
import {
|
||||||
|
resolveCliChromeGpuMode,
|
||||||
|
runFfmpegOnce,
|
||||||
|
seekCompositionTimeline,
|
||||||
|
type CompositionSeekPage,
|
||||||
|
} from "./captureCompositionFrame.js";
|
||||||
|
|
||||||
function tempDir(): string {
|
function tempDir(): string {
|
||||||
return mkdtempSync(join(tmpdir(), "hf-capture-frame-test-"));
|
return mkdtempSync(join(tmpdir(), "hf-capture-frame-test-"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fakeSeekPage() {
|
||||||
|
const evaluate = vi.fn(
|
||||||
|
async (
|
||||||
|
_pageFunction: Parameters<CompositionSeekPage["evaluate"]>[0],
|
||||||
|
_value?: number,
|
||||||
|
_fallbackToBridgeAndTimelines?: boolean,
|
||||||
|
): Promise<unknown> => undefined,
|
||||||
|
);
|
||||||
|
const waitForFunction = vi.fn(
|
||||||
|
async (_pageFunction: () => boolean, _options: { timeout: number }): Promise<unknown> =>
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const page: CompositionSeekPage = { evaluate, waitForFunction };
|
||||||
|
return { page, evaluate, waitForFunction };
|
||||||
|
}
|
||||||
|
|
||||||
|
function runBrowserSeek(evaluate: ReturnType<typeof fakeSeekPage>["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", () => {
|
describe("runFfmpegOnce", () => {
|
||||||
it("returns the process exit code and collected stderr", async () => {
|
it("returns the process exit code and collected stderr", async () => {
|
||||||
const dir = tempDir();
|
const dir = tempDir();
|
||||||
|
|||||||
@@ -3,17 +3,35 @@ import type { Browser, Page } from "puppeteer-core";
|
|||||||
import { c } from "../ui/colors.js";
|
import { c } from "../ui/colors.js";
|
||||||
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.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 SHADER_TRANSITIONS_TIMEOUT_MS = 90_000;
|
||||||
const CAPTURE_SETTLE_MS = 1500;
|
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<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositionSeekPage extends CompositionEvaluationPage {
|
||||||
|
waitForFunction?(pageFunction: () => boolean, options: { timeout: number }): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SettledCompositionPage {
|
export interface SettledCompositionPage {
|
||||||
browser: Browser;
|
browser: Browser;
|
||||||
@@ -34,6 +52,12 @@ export interface FfmpegRunResult {
|
|||||||
timedOut: boolean;
|
timedOut: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveCliChromeGpuMode(
|
||||||
|
envMode = process.env.PRODUCER_BROWSER_GPU_MODE,
|
||||||
|
): "software" | "hardware" {
|
||||||
|
return envMode === "software" ? "software" : "hardware";
|
||||||
|
}
|
||||||
|
|
||||||
function compositionRuntimeReadyInBrowser(): boolean {
|
function compositionRuntimeReadyInBrowser(): boolean {
|
||||||
return Boolean(Reflect.get(window, "__renderReady"));
|
return Boolean(Reflect.get(window, "__renderReady"));
|
||||||
}
|
}
|
||||||
@@ -97,20 +121,22 @@ export async function openSettledCompositionPage(
|
|||||||
url: string,
|
url: string,
|
||||||
options: OpenSettledCompositionPageOptions,
|
options: OpenSettledCompositionPageOptions,
|
||||||
): Promise<SettledCompositionPage> {
|
): Promise<SettledCompositionPage> {
|
||||||
|
const viewport = resolveCompositionViewportFromHtml(html);
|
||||||
const { ensureBrowser } = await import("../browser/manager.js");
|
const { ensureBrowser } = await import("../browser/manager.js");
|
||||||
const browser = await ensureBrowser();
|
const browser = await ensureBrowser();
|
||||||
const puppeteer = await import("puppeteer-core");
|
const puppeteer = await import("puppeteer-core");
|
||||||
|
const { buildChromeArgs } = await import("@hyperframes/engine");
|
||||||
|
|
||||||
let chromeBrowser: Browser | undefined;
|
let chromeBrowser: Browser | undefined;
|
||||||
try {
|
try {
|
||||||
chromeBrowser = await puppeteer.default.launch({
|
chromeBrowser = await puppeteer.default.launch({
|
||||||
headless: true,
|
headless: true,
|
||||||
executablePath: browser.executablePath,
|
executablePath: browser.executablePath,
|
||||||
args: CHROME_LAUNCH_ARGS,
|
args: buildChromeArgs({ ...viewport, captureMode: "screenshot" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const page = await chromeBrowser.newPage();
|
const page = await chromeBrowser.newPage();
|
||||||
await page.setViewport(resolveCompositionViewportFromHtml(html));
|
await page.setViewport(viewport);
|
||||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
|
||||||
const renderReadyTimedOut = !(await waitForCompositionSettle(page, options));
|
const renderReadyTimedOut = !(await waitForCompositionSettle(page, options));
|
||||||
return { browser: chromeBrowser, page, renderReadyTimedOut };
|
return { browser: chromeBrowser, page, renderReadyTimedOut };
|
||||||
@@ -121,29 +147,133 @@ export async function openSettledCompositionPage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function seekCompositionTimeline(
|
export async function seekCompositionTimeline(
|
||||||
page: Pick<Page, "evaluate">,
|
page: CompositionSeekPage,
|
||||||
timeSeconds: number,
|
timeSeconds: number,
|
||||||
|
options: SeekCompositionTimelineOptions = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await page.evaluate((t: number) => {
|
if (options.waitForPreferredSeekTargetMs !== undefined) {
|
||||||
const player = (window as any).__player;
|
await waitForPreferredSeekTarget(page, options.waitForPreferredSeekTargetMs);
|
||||||
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);
|
|
||||||
|
|
||||||
await page.evaluate(`new Promise(function(r) {
|
await page.evaluate(
|
||||||
var settled = false;
|
// Serialized into the page; the seek-target cascade must stay one function.
|
||||||
function finish() { if (settled) return; settled = true; r(); }
|
// fallow-ignore-next-line complexity
|
||||||
window.setTimeout(finish, 100);
|
(t: number, fallbackToBridgeAndTimelines: boolean) => {
|
||||||
requestAnimationFrame(function() { requestAnimationFrame(finish); });
|
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<void>((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<CompositionSeekPage, "waitForFunction">,
|
||||||
|
timeoutMs = PREFERRED_SEEK_TARGET_WAIT_MS,
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void>((resolve) => setTimeout(resolve, ms)),
|
||||||
|
]);
|
||||||
|
}, timeoutMs)
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runFfmpegOnce(
|
export async function runFfmpegOnce(
|
||||||
|
|||||||
@@ -26,10 +26,21 @@ import {
|
|||||||
type MotionFrame,
|
type MotionFrame,
|
||||||
} from "../utils/motionAudit.js";
|
} from "../utils/motionAudit.js";
|
||||||
import { findMotionSpec, readMotionSpec, type MotionSpec } from "../utils/motionSpec.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 __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
const SEEK_SETTLE_MS = 120;
|
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.
|
// All new envelope fields are optional (?); additive changes don't bump this.
|
||||||
const INSPECT_SCHEMA_VERSION = 1;
|
const INSPECT_SCHEMA_VERSION = 1;
|
||||||
// Motion verification (#1437): dense sampling grid for the seeked-timeline checks.
|
// 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<number> {
|
async function getCompositionDuration(page: import("puppeteer-core").Page): Promise<number> {
|
||||||
|
// Serialized into the page; the duration-source cascade cannot be split.
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
return page.evaluate(() => {
|
return page.evaluate(() => {
|
||||||
const win = window as unknown as {
|
const win = window as unknown as {
|
||||||
__hf?: { duration?: number };
|
__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<void> {
|
|
||||||
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<void>((resolve) => setTimeout(resolve, ms)),
|
|
||||||
]);
|
|
||||||
}, timeoutMs)
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seekTo(page: import("puppeteer-core").Page, time: number): Promise<void> {
|
|
||||||
await page.evaluate((t: number) => {
|
|
||||||
const win = window as unknown as {
|
|
||||||
__hf?: { seek?: (time: number) => void };
|
|
||||||
__player?: { seek?: (time: number) => void };
|
|
||||||
__timelines?: Record<string, { pause?: () => 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<void>((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,
|
* Collect every tween start/end boundary from the registered timelines,
|
||||||
* expressed in the registered timeline's own time (what seekTo consumes).
|
* expressed in the registered timeline's own time (what seekTo consumes).
|
||||||
@@ -234,6 +201,7 @@ async function runLayoutAudit(
|
|||||||
): Promise<LayoutAuditResult> {
|
): Promise<LayoutAuditResult> {
|
||||||
const { ensureBrowser } = await import("../browser/manager.js");
|
const { ensureBrowser } = await import("../browser/manager.js");
|
||||||
const puppeteer = await import("puppeteer-core");
|
const puppeteer = await import("puppeteer-core");
|
||||||
|
const { buildChromeArgs } = await import("@hyperframes/engine");
|
||||||
const html = await bundleProjectHtml(projectDir);
|
const html = await bundleProjectHtml(projectDir);
|
||||||
const server = await serveStaticProjectHtml(
|
const server = await serveStaticProjectHtml(
|
||||||
projectDir,
|
projectDir,
|
||||||
@@ -247,14 +215,7 @@ async function runLayoutAudit(
|
|||||||
chromeBrowser = await puppeteer.default.launch({
|
chromeBrowser = await puppeteer.default.launch({
|
||||||
headless: true,
|
headless: true,
|
||||||
executablePath: browser.executablePath,
|
executablePath: browser.executablePath,
|
||||||
args: [
|
args: buildChromeArgs({ width: 1920, height: 1080, captureMode: "screenshot" }),
|
||||||
"--no-sandbox",
|
|
||||||
"--disable-gpu",
|
|
||||||
"--disable-dev-shm-usage",
|
|
||||||
"--enable-webgl",
|
|
||||||
"--use-gl=angle",
|
|
||||||
"--use-angle=swiftshader",
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const page = await chromeBrowser.newPage();
|
const page = await chromeBrowser.newPage();
|
||||||
@@ -266,7 +227,7 @@ async function runLayoutAudit(
|
|||||||
timeout: opts.timeout,
|
timeout: opts.timeout,
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
await waitForFonts(page, 750);
|
await waitForCompositionFonts(page, 750);
|
||||||
await new Promise((resolveSettle) => setTimeout(resolveSettle, 250));
|
await new Promise((resolveSettle) => setTimeout(resolveSettle, 250));
|
||||||
|
|
||||||
const duration = await getCompositionDuration(page);
|
const duration = await getCompositionDuration(page);
|
||||||
@@ -330,7 +291,7 @@ async function collectLayoutIssues(
|
|||||||
|
|
||||||
const issues: LayoutIssue[] = [];
|
const issues: LayoutIssue[] = [];
|
||||||
for (const time of samples) {
|
for (const time of samples) {
|
||||||
await seekTo(page, time);
|
await seekCompositionTimeline(page, time, LAYOUT_SEEK_OPTIONS);
|
||||||
const sampleIssues = await page.evaluate(
|
const sampleIssues = await page.evaluate(
|
||||||
(auditOptions: { time: number; tolerance: number }) => {
|
(auditOptions: { time: number; tolerance: number }) => {
|
||||||
const win = window as unknown as {
|
const win = window as unknown as {
|
||||||
@@ -373,7 +334,7 @@ async function collectMotionFrames(
|
|||||||
): Promise<MotionFrame[]> {
|
): Promise<MotionFrame[]> {
|
||||||
const frames: MotionFrame[] = [];
|
const frames: MotionFrame[] = [];
|
||||||
for (const time of times) {
|
for (const time of times) {
|
||||||
await seekTo(page, time);
|
await seekCompositionTimeline(page, time, LAYOUT_SEEK_OPTIONS);
|
||||||
const sample = await page.evaluate(
|
const sample = await page.evaluate(
|
||||||
(options: { selectors: string[]; livenessScopes: string[] }) => {
|
(options: { selectors: string[]; livenessScopes: string[] }) => {
|
||||||
const win = window as unknown as {
|
const win = window as unknown as {
|
||||||
@@ -512,6 +473,8 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
|
|||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Pre-existing command-run branching; U1 only swapped the seek internals.
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
async run({ args }) {
|
async run({ args }) {
|
||||||
const project = resolveProject(args.dir);
|
const project = resolveProject(args.dir);
|
||||||
const samples = Math.max(1, parseInt(args.samples as string, 10) || 9);
|
const samples = Math.max(1, parseInt(args.samples as string, 10) || 9);
|
||||||
|
|||||||
@@ -131,6 +131,16 @@ describe("waitForPreferredSeekTarget", () => {
|
|||||||
|
|
||||||
await expect(waitForPreferredSeekTarget(page, 1)).resolves.toBeUndefined();
|
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", () => {
|
describe("extractCompositionErrorsFromLint", () => {
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import type { ProjectLintResult } from "../utils/lintProject.js";
|
|||||||
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
|
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
|
||||||
import { c } from "../ui/colors.js";
|
import { c } from "../ui/colors.js";
|
||||||
import { withMeta } from "../utils/updateCheck.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 __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
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<void> {
|
|
||||||
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<string, unknown>).__timelines as
|
|
||||||
| Record<string, { seek: (t: number) => 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<unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function waitForPreferredSeekTarget(
|
|
||||||
page: WaitForFunctionPage,
|
|
||||||
timeoutMs = PREFERRED_SEEK_TARGET_WAIT_MS,
|
|
||||||
): Promise<void> {
|
|
||||||
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,
|
* Race a media element's `loadedmetadata`/`error` event against a deadline,
|
||||||
* whichever comes first. Already-ready elements resolve immediately.
|
* whichever comes first. Already-ready elements resolve immediately.
|
||||||
@@ -165,6 +110,8 @@ export function raceMediaReady(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (Number.isFinite(el.duration) && el.duration > 0) return Promise.resolve();
|
if (Number.isFinite(el.duration) && el.duration > 0) return Promise.resolve();
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
|
// Clones its in-page twin below; evaluate() bodies can't import Node helpers.
|
||||||
|
// fallow-ignore-next-line code-duplication
|
||||||
const onReady = () => {
|
const onReady = () => {
|
||||||
el.removeEventListener("loadedmetadata", onReady);
|
el.removeEventListener("loadedmetadata", onReady);
|
||||||
el.removeEventListener("error", onReady);
|
el.removeEventListener("error", onReady);
|
||||||
@@ -209,6 +156,8 @@ async function auditClipDurations(
|
|||||||
nodes.map((el) => {
|
nodes.map((el) => {
|
||||||
if (Number.isFinite(el.duration) && el.duration > 0) return Promise.resolve();
|
if (Number.isFinite(el.duration) && el.duration > 0) return Promise.resolve();
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
|
// fallow-ignore-next-line code-duplication
|
||||||
|
// Serialized twin of the Node-side metadata wait above.
|
||||||
// fallow-ignore-next-line code-duplication
|
// fallow-ignore-next-line code-duplication
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
el.removeEventListener("loadedmetadata", onReady);
|
el.removeEventListener("loadedmetadata", onReady);
|
||||||
@@ -300,7 +249,12 @@ async function runContrastAudit(page: import("puppeteer-core").Page): Promise<Co
|
|||||||
const results: ContrastEntry[] = [];
|
const results: ContrastEntry[] = [];
|
||||||
for (let i = 0; i < CONTRAST_SAMPLES; i++) {
|
for (let i = 0; i < CONTRAST_SAMPLES; i++) {
|
||||||
const t = +(((i + 0.5) / CONTRAST_SAMPLES) * duration).toFixed(3);
|
const t = +(((i + 0.5) / CONTRAST_SAMPLES) * duration).toFixed(3);
|
||||||
await seekTo(page, t);
|
await seekCompositionTimeline(page, t, {
|
||||||
|
fallbackToBridgeAndTimelines: true,
|
||||||
|
waitForPreferredSeekTargetMs: PREFERRED_SEEK_TARGET_WAIT_MS,
|
||||||
|
animationFrameSettle: "none",
|
||||||
|
settleMs: SEEK_SETTLE_MS,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// __contrastAuditPrepare() hides each candidate text element's own
|
// __contrastAuditPrepare() hides each candidate text element's own
|
||||||
@@ -459,12 +413,13 @@ async function validateInBrowser(
|
|||||||
const browser = await ensureBrowser();
|
const browser = await ensureBrowser();
|
||||||
const puppeteer = await import("puppeteer-core");
|
const puppeteer = await import("puppeteer-core");
|
||||||
const { buildChromeArgs, analyzeClipMediaFit } = await import("@hyperframes/engine");
|
const { buildChromeArgs, analyzeClipMediaFit } = await import("@hyperframes/engine");
|
||||||
const browserGpuMode =
|
|
||||||
process.env.PRODUCER_BROWSER_GPU_MODE === "software" ? "software" : "hardware";
|
|
||||||
const chromeBrowser = await puppeteer.default.launch({
|
const chromeBrowser = await puppeteer.default.launch({
|
||||||
headless: true,
|
headless: true,
|
||||||
executablePath: browser.executablePath,
|
executablePath: browser.executablePath,
|
||||||
args: buildChromeArgs({ ...viewport, captureMode: "screenshot" }, { browserGpuMode }),
|
args: buildChromeArgs(
|
||||||
|
{ ...viewport, captureMode: "screenshot" },
|
||||||
|
{ browserGpuMode: resolveCliChromeGpuMode() },
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const page = await chromeBrowser.newPage();
|
const page = await chromeBrowser.newPage();
|
||||||
|
|||||||
Reference in New Issue
Block a user