mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 01:56:04 +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:
@@ -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<number> {
|
||||
// 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<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,
|
||||
* expressed in the registered timeline's own time (what seekTo consumes).
|
||||
@@ -234,6 +201,7 @@ async function runLayoutAudit(
|
||||
): Promise<LayoutAuditResult> {
|
||||
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<MotionFrame[]> {
|
||||
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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user