mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(cli): validate seeks the runtime player directly, not raw timelines (#1895)
* fix(cli): validate seeks the runtime player directly, not raw timelines validate's seekTo() only checked for window.__hf.seek (a bridge object the producer's render-pipeline file server injects) before falling back to grabbing window.__timelines and calling .seek() on each raw GSAP timeline directly. validate serves compositions through a plain static file server that never injects that bridge, so this fallback ran on every single validate invocation. Seeking a raw timeline moves the animation state but skips the runtime's own [data-start]/[data-duration] visibility sync (syncMediaForCurrentState in packages/core/src/runtime/init.ts), which is what sets an off-window clip's inline visibility/display styles. Skipping it left elements outside their timeline window looking fully visible to any check that reads computed style afterward at that seek time. This surfaced as validate's WCAG contrast audit (contrast-audit.browser.js) flagging text in off-window clips against whatever background happened to be behind them, since its own visibility filtering trusts the runtime to have already hidden them. Fix: prefer window.__player.renderSeek, which the composition runtime exposes directly on every page load (no bridge required) and which does run the visibility sync, before falling back to the __hf/raw timeline paths. No changes needed to contrast-audit.browser.js itself since its existing visibility check now sees correct computed style. No new test added: seekTo's branch selection runs entirely inside a page.evaluate() callback, which Puppeteer serializes via .toString() for the browser context, so it can't import and call a project-local window.__player stub from a jsdom/vitest test without testing a copy of the logic rather than the shipped code. Verified instead by reading the runtime chain end-to-end: window.__player.renderSeek is always set by packages/core/src/runtime/init.ts's createPlayerApiCompat, calls through to player.renderSeek, which calls syncMediaForCurrentState(). * fix(cli): wait for runtime seek target in validate
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
extractCompositionErrorsFromLint,
|
||||
navigationTimeoutHint,
|
||||
raceMediaReady,
|
||||
resolveNavigationTimeoutMs,
|
||||
shouldIgnoreRequestFailure,
|
||||
waitForPreferredSeekTarget,
|
||||
} from "./validate.js";
|
||||
import type { ProjectLintResult } from "../utils/lintProject.js";
|
||||
|
||||
@@ -91,6 +92,28 @@ describe("shouldIgnoreRequestFailure", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("waitForPreferredSeekTarget", () => {
|
||||
it("waits for the runtime player/bridge target before falling back to raw timelines", async () => {
|
||||
const page = {
|
||||
waitForFunction: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
await waitForPreferredSeekTarget(page, 123);
|
||||
|
||||
expect(page.waitForFunction).toHaveBeenCalledWith(expect.any(Function), { timeout: 123 });
|
||||
});
|
||||
|
||||
it("does not fail validation when only the legacy raw timeline fallback is available", async () => {
|
||||
const page = {
|
||||
waitForFunction: vi.fn(async () => {
|
||||
throw new Error("waiting failed: timeout");
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(waitForPreferredSeekTarget(page, 1)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractCompositionErrorsFromLint", () => {
|
||||
// `bundleToSingleHtml` (the inliner validate.ts bundles through) is
|
||||
// intentionally tolerant of missing/empty/unparsable data-composition-src
|
||||
|
||||
@@ -32,6 +32,7 @@ interface ContrastEntry {
|
||||
|
||||
const CONTRAST_SAMPLES = 5;
|
||||
const SEEK_SETTLE_MS = 150;
|
||||
const PREFERRED_SEEK_TARGET_WAIT_MS = 500;
|
||||
const MEDIA_EXTENSIONS = /\.(aac|flac|m4a|mov|mp3|mp4|oga|ogg|wav|webm)$/i;
|
||||
// Floor for the initial page navigation. A blocking external <script> (GSAP
|
||||
// from a CDN, etc.) delays `domcontentloaded`; the actual render (much larger
|
||||
@@ -84,7 +85,24 @@ 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;
|
||||
@@ -101,6 +119,32 @@ async function seekTo(page: import("puppeteer-core").Page, time: number): Promis
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user