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:
Miguel Angel Simon Sierra
2026-07-10 13:27:52 -04:00
parent 6152437d2a
commit feb256df8a
5 changed files with 382 additions and 156 deletions
+20 -65
View File
@@ -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<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,
* whichever comes first. Already-ready elements resolve immediately.
@@ -165,6 +110,8 @@ export function raceMediaReady(
): Promise<void> {
if (Number.isFinite(el.duration) && el.duration > 0) return Promise.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 = () => {
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<void>((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<Co
const results: ContrastEntry[] = [];
for (let i = 0; i < CONTRAST_SAMPLES; i++) {
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 {
// __contrastAuditPrepare() hides each candidate text element's own
@@ -459,12 +413,13 @@ async function validateInBrowser(
const browser = await ensureBrowser();
const puppeteer = await import("puppeteer-core");
const { buildChromeArgs, analyzeClipMediaFit } = await import("@hyperframes/engine");
const browserGpuMode =
process.env.PRODUCER_BROWSER_GPU_MODE === "software" ? "software" : "hardware";
const chromeBrowser = await puppeteer.default.launch({
headless: true,
executablePath: browser.executablePath,
args: buildChromeArgs({ ...viewport, captureMode: "screenshot" }, { browserGpuMode }),
args: buildChromeArgs(
{ ...viewport, captureMode: "screenshot" },
{ browserGpuMode: resolveCliChromeGpuMode() },
),
});
const page = await chromeBrowser.newPage();