heygen-com--hyperframes
85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
419 行
18 KiB
TypeScript
419 行
18 KiB
TypeScript
/**
|
|
* Scenario 06: live-playback parity vs synchronous seek.
|
|
*
|
|
* Loads the gsap-heavy fixture, plays it from t=0, then captures the rendered
|
|
* frame at a known timestamp (t≈5.0s, mid-animation). Without releasing the
|
|
* page, we then synchronously seek the same player back to that exact captured
|
|
* timestamp and capture a *reference* frame. The two PNGs are diffed with
|
|
* `ffmpeg -lavfi ssim` and the resulting average SSIM is the parity metric.
|
|
*
|
|
* Per the proposal:
|
|
* Test 5: Live-playback parity (player-perf-parity)
|
|
* Play composition → freeze at known t → screenshot → seek to same t →
|
|
* screenshot → compare via SSIM
|
|
* Assert: SSIM > 0.95 (effectively perfect with deterministic rendering)
|
|
*
|
|
* Baseline note (paritySsimMin=0.93, set deliberately wider than the proposal's
|
|
* 0.95): the host runner is headless Chromium with all the determinism flags
|
|
* we can practically apply, but the gsap-heavy fixture still has a small
|
|
* sub-pixel rasterization wobble between "paint immediately after pause()"
|
|
* and "paint after sync seek." Empirically the worst run sits around 0.96–0.98,
|
|
* but a 2-point cushion keeps us from chasing flakes on slower CI hardware
|
|
* while still catching real parity drift (anything < 0.93 means the two
|
|
* paths produced visibly different pixels, not just sub-pixel jitter).
|
|
* If we tighten determinism further (e.g. fixed device pixel ratio + forced
|
|
* software raster) we should ratchet this baseline back up to 0.95.
|
|
*
|
|
* Why this matters:
|
|
* `<hyperframes-player>`'s sync-seek path goes through `_trySyncSeek`, which
|
|
* for same-origin embeds calls into the iframe runtime's `seek()` directly.
|
|
* Live playback advances frames via the runtime's animation loop. If those
|
|
* two paths drift out of agreement — different rounding, different sub-frame
|
|
* sampling, different state ordering — scrubbing a paused composition will
|
|
* show different pixels than a paused-during-playback frame at the same time.
|
|
* This test pins them together visually.
|
|
*
|
|
* Methodology details:
|
|
* - Capture point is t=5.0s. The gsap-heavy fixture is a 10s composition
|
|
* with 60 tiles each running a staggered 4s out-and-back tween. At 5.0s
|
|
* a large fraction of those tiles are mid-flight, so the rendered frame
|
|
* has many distinct, position-sensitive pixels — the worst case for any
|
|
* sub-frame disagreement between the two paths.
|
|
* - Live capture uses an iframe-side rAF watcher that polls
|
|
* `__player.getTime()` every animation frame. When `getTime() >= 5.0`,
|
|
* the watcher calls `__player.pause()` *from inside the same rAF tick*.
|
|
* `pause()` is synchronous (it calls `timeline.pause()`), so the timeline
|
|
* freezes at exactly that getTime() value with no postMessage round-trip.
|
|
* We then read `getTime()` one more time to capture the canonical frozen
|
|
* timestamp `T_actual` — that's the ground truth both screenshots target.
|
|
* - Both screenshots wait for two `requestAnimationFrame` ticks on the host
|
|
* page before capture. The first rAF flushes any pending style/layout
|
|
* work; the second rAF guarantees the compositor has painted. This is
|
|
* the same paint-settlement pattern as packages/producer/src/parity-harness.ts.
|
|
* - Reference capture issues `el.seek(T_actual)` from the host page. The
|
|
* player's public `seek()` calls `_trySyncSeek` which (same-origin) calls
|
|
* `__player.seek()` synchronously, so we don't need a postMessage await.
|
|
* - SSIM is computed by `ffmpeg -lavfi ssim`, which emits per-channel and
|
|
* overall scores to stderr. We parse the `All:` value (clamped at 1.0
|
|
* because ffmpeg occasionally reports 1.000001 for identical inputs).
|
|
* - Both PNGs and the captured T_actual value are written under
|
|
* `tests/perf/results/parity/run-N/` for CI artifact upload and local
|
|
* debugging. The directory is gitignored via the existing
|
|
* `packages/player/tests/perf/results/` rule.
|
|
*
|
|
* Output metric:
|
|
* - parity_ssim_min (higher-is-better, baseline paritySsimMin = 0.93)
|
|
*
|
|
* Aggregation: min() across runs. We want the *worst* observed parity to
|
|
* pass the gate, so that one bad run can't get masked by averaging.
|
|
*/
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import type { Browser, Frame, Page } from "puppeteer-core";
|
|
import { loadHostPage } from "../runner.ts";
|
|
import type { Metric } from "../perf-gate.ts";
|
|
|
|
export type ParityScenarioOpts = {
|
|
browser: Browser;
|
|
origin: string;
|
|
/** Number of measurement runs. */
|
|
runs: number;
|
|
/** If null, runs the default fixture (gsap-heavy). */
|
|
fixture: string | null;
|
|
};
|
|
|
|
const DEFAULT_FIXTURE = "gsap-heavy";
|
|
/** Mid-composition; gsap-heavy is 10s and has many tiles in motion at this point. */
|
|
const TARGET_TIME_S = 5.0;
|
|
/** rAF watcher will resolve as soon as getTime() crosses TARGET_TIME_S. */
|
|
const TARGET_TIMEOUT_MS = 15_000;
|
|
const PLAY_CONFIRM_TIMEOUT_MS = 5_000;
|
|
const FRAME_LOOKUP_TIMEOUT_MS = 5_000;
|
|
/** ffmpeg occasionally reports 1.000001 on identical inputs; clamp to keep
|
|
* baseline math sane. */
|
|
const SSIM_CLAMP_MAX = 1.0;
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const RESULTS_DIR = resolve(HERE, "../results/parity");
|
|
|
|
declare global {
|
|
interface Window {
|
|
/** Promise resolved by the iframe rAF watcher with the frozen player time (s). */
|
|
__perfParityPauseAwait?: Promise<number>;
|
|
__player?: {
|
|
play: () => void;
|
|
pause: () => void;
|
|
seek: (timeSeconds: number) => void;
|
|
getTime: () => number;
|
|
getDuration: () => number;
|
|
isPlaying: () => boolean;
|
|
};
|
|
}
|
|
}
|
|
|
|
type RunResult = {
|
|
ssim: number;
|
|
capturedTime: number;
|
|
};
|
|
|
|
/**
|
|
* Find the iframe Puppeteer Frame that hosts the fixture composition. Same
|
|
* helper as the other scenarios; duplicated locally so each scenario file is
|
|
* self-contained.
|
|
*/
|
|
async function getFixtureFrame(page: Page, fixture: string): Promise<Frame> {
|
|
const expected = `/fixtures/${fixture}/`;
|
|
const deadline = Date.now() + FRAME_LOOKUP_TIMEOUT_MS;
|
|
while (Date.now() < deadline) {
|
|
const frame = page.frames().find((f) => f.url().includes(expected));
|
|
if (frame) return frame;
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
}
|
|
throw new Error(`[scenario:parity] fixture frame not found for "${fixture}" within timeout`);
|
|
}
|
|
|
|
/**
|
|
* Wait for two animation frames on the host page so the compositor has had a
|
|
* chance to paint the latest player state before we screenshot. First rAF
|
|
* flushes pending style/layout, second rAF guarantees a painted commit.
|
|
*/
|
|
async function waitForPaint(page: Page): Promise<void> {
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise<void>((resolve) =>
|
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
|
),
|
|
);
|
|
}
|
|
|
|
function ensureDir(path: string): void {
|
|
if (!existsSync(path)) {
|
|
mkdirSync(path, { recursive: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run `ffmpeg -lavfi ssim` against two PNGs and return the overall SSIM
|
|
* score. ffmpeg writes the score to stderr in the form:
|
|
*
|
|
* [Parsed_ssim_0 @ 0x...] SSIM Y:0.998... U:0.999... V:0.999... All:0.998... (28.3)
|
|
*
|
|
* We grab the `All:` value, parse it as a float, and clamp to SSIM_CLAMP_MAX.
|
|
*
|
|
* Three failure modes, kept distinct so CI is debuggable without re-running:
|
|
* - `result.error` (e.g. ENOENT) — ffmpeg never started; the binary is
|
|
* missing or unexecutable. We surface the OS error so the operator
|
|
* immediately knows to install ffmpeg on the runner instead of chasing
|
|
* an "exit=undefined" red herring.
|
|
* - `result.status !== 0` — ffmpeg started but exited non-zero. Usually a
|
|
* decode/argument error; stderr has the real message.
|
|
* - parse failure — ffmpeg ran successfully but its output didn't contain
|
|
* the expected `All:` token. Indicates a version skew or a no-op input.
|
|
*
|
|
* On the second and third failure modes we additionally re-run ffmpeg with
|
|
* `stats_file` pointed at `<runDir>/ssim-stats.log` so the next CI artifact
|
|
* upload contains a per-frame SSIM dump alongside the two PNGs. That log is
|
|
* the cheapest possible bridge between "the assert tripped" and "this pixel
|
|
* region drifted" — without it, debugging a parity regression means pulling
|
|
* the PNGs locally and eyeballing them.
|
|
*/
|
|
function computeSsim(referencePath: string, actualPath: string, runDir: string): number {
|
|
const result = spawnSync(
|
|
"ffmpeg",
|
|
["-hide_banner", "-i", referencePath, "-i", actualPath, "-lavfi", "ssim", "-f", "null", "-"],
|
|
{ stdio: "pipe" },
|
|
);
|
|
if (result.error) {
|
|
// spawnSync surfaces ENOENT / EACCES / etc. on `result.error`. status is
|
|
// null in this case — ffmpeg never actually ran. Calling toString() on
|
|
// result.status would print "null", which is exactly what produced the
|
|
// confusing "exit=undefined" line that masked the real ENOENT in CI.
|
|
throw new Error(
|
|
`[scenario:parity] ffmpeg could not be started (${(result.error as NodeJS.ErrnoException).code ?? "unknown"}): ${result.error.message}. ` +
|
|
"Install ffmpeg on the runner (apt-get install -y ffmpeg) — the parity scenario " +
|
|
"requires it for SSIM scoring.",
|
|
);
|
|
}
|
|
if (result.status !== 0) {
|
|
const stderr = (result.stderr || Buffer.from("")).toString("utf-8");
|
|
writeSsimStatsOnFailure(referencePath, actualPath, runDir);
|
|
throw new Error(`[scenario:parity] ffmpeg ssim failed (exit=${result.status}): ${stderr}`);
|
|
}
|
|
const stderr = (result.stderr || Buffer.from("")).toString("utf-8");
|
|
const match = stderr.match(/All:\s*([0-9.]+)/);
|
|
if (!match) {
|
|
writeSsimStatsOnFailure(referencePath, actualPath, runDir);
|
|
throw new Error(`[scenario:parity] could not parse SSIM from ffmpeg stderr: ${stderr}`);
|
|
}
|
|
const raw = Number.parseFloat(match[1]);
|
|
if (!Number.isFinite(raw)) {
|
|
writeSsimStatsOnFailure(referencePath, actualPath, runDir);
|
|
throw new Error(`[scenario:parity] parsed SSIM is not finite: "${match[1]}"`);
|
|
}
|
|
return Math.min(SSIM_CLAMP_MAX, raw);
|
|
}
|
|
|
|
/**
|
|
* Best-effort: re-invoke ffmpeg with `stats_file=<runDir>/ssim-stats.log`
|
|
* so the per-frame SSIM dump lands in the artifact directory. This runs
|
|
* only on the failure paths in `computeSsim` — a successful parity check
|
|
* doesn't need the dump. We swallow any error from this helper because
|
|
* the caller is already on its way to throwing the original failure;
|
|
* losing the diagnostic dump shouldn't change the surfaced error.
|
|
*/
|
|
function writeSsimStatsOnFailure(referencePath: string, actualPath: string, runDir: string): void {
|
|
try {
|
|
const statsPath = resolve(runDir, "ssim-stats.log");
|
|
spawnSync(
|
|
"ffmpeg",
|
|
[
|
|
"-hide_banner",
|
|
"-i",
|
|
referencePath,
|
|
"-i",
|
|
actualPath,
|
|
"-lavfi",
|
|
// ffmpeg's lavfi parser uses '\:' to escape the path separator inside
|
|
// a filter argument. We don't expect ':' in `statsPath` but escape
|
|
// defensively to keep this robust on weird mounts.
|
|
`ssim=stats_file=${statsPath.replace(/:/g, "\\:")}`,
|
|
"-f",
|
|
"null",
|
|
"-",
|
|
],
|
|
{ stdio: "pipe" },
|
|
);
|
|
} catch {
|
|
// Best-effort: never let stats-dump failure mask the real error.
|
|
}
|
|
}
|
|
|
|
async function runOnce(
|
|
opts: ParityScenarioOpts,
|
|
fixture: string,
|
|
idx: number,
|
|
total: number,
|
|
): Promise<RunResult> {
|
|
const ctx = await opts.browser.createBrowserContext();
|
|
try {
|
|
const page = await ctx.newPage();
|
|
const { duration } = await loadHostPage(page, opts.origin, { fixture });
|
|
if (duration < TARGET_TIME_S + 0.1) {
|
|
throw new Error(
|
|
`[scenario:parity] fixture composition is ${duration.toFixed(2)}s but parity target needs >= ${(TARGET_TIME_S + 0.1).toFixed(2)}s`,
|
|
);
|
|
}
|
|
const frame = await getFixtureFrame(page, fixture);
|
|
|
|
// Install the iframe-side rAF watcher *before* we issue play(). The
|
|
// watcher polls __player.getTime() every animation frame and, the first
|
|
// time getTime() >= TARGET_TIME_S, calls __player.pause() in the same
|
|
// tick. pause() is synchronous (it calls timeline.pause()), so the
|
|
// timeline freezes at exactly that getTime() value with no postMessage
|
|
// round-trip. The Promise resolves with that frozen value as the
|
|
// canonical T_actual we'll use for both screenshots.
|
|
await frame.evaluate(
|
|
(target: number, timeoutMs: number) => {
|
|
window.__perfParityPauseAwait = new Promise<number>((resolve, reject) => {
|
|
const deadlineWall = performance.timeOrigin + performance.now() + timeoutMs;
|
|
const tick = () => {
|
|
const player = window.__player;
|
|
if (!player) {
|
|
reject(new Error("[parity] __player missing during rAF watcher"));
|
|
return;
|
|
}
|
|
const wall = performance.timeOrigin + performance.now();
|
|
const time = player.getTime();
|
|
if (Number.isFinite(time) && time >= target) {
|
|
// Pause from inside the rAF tick — synchronous in the runtime,
|
|
// so the timeline can't advance any further before we read
|
|
// getTime() back out as the canonical frozen value.
|
|
player.pause();
|
|
resolve(player.getTime());
|
|
return;
|
|
}
|
|
if (wall > deadlineWall) {
|
|
reject(new Error(`[parity] timeout waiting for getTime >= ${target} (last=${time})`));
|
|
return;
|
|
}
|
|
requestAnimationFrame(tick);
|
|
};
|
|
requestAnimationFrame(tick);
|
|
});
|
|
},
|
|
TARGET_TIME_S,
|
|
TARGET_TIMEOUT_MS,
|
|
);
|
|
|
|
// Start playback from the host page.
|
|
await page.evaluate(() => {
|
|
const el = document.getElementById("player") as (HTMLElement & { play: () => void }) | null;
|
|
if (!el) throw new Error("[scenario:parity] player element missing on host page");
|
|
el.play();
|
|
});
|
|
|
|
// Confirm the runtime is actually playing before we wait on the rAF
|
|
// watcher. Without this we can hang waiting for getTime() to advance
|
|
// when play() hasn't kicked the timeline yet.
|
|
await frame.waitForFunction(() => window.__player?.isPlaying?.() === true, {
|
|
timeout: PLAY_CONFIRM_TIMEOUT_MS,
|
|
});
|
|
|
|
// Block until the iframe watcher pauses the timeline and resolves with
|
|
// the frozen player time. This is the canonical T_actual for the run.
|
|
const capturedTime = (await frame.evaluate(
|
|
() => window.__perfParityPauseAwait as Promise<number>,
|
|
)) as number;
|
|
|
|
if (!Number.isFinite(capturedTime) || capturedTime < TARGET_TIME_S) {
|
|
throw new Error(
|
|
`[scenario:parity] watcher resolved with invalid time: ${capturedTime} (target=${TARGET_TIME_S})`,
|
|
);
|
|
}
|
|
|
|
// Capture frame #1: the live-playback frame frozen by pause().
|
|
await waitForPaint(page);
|
|
const actualImage = (await page.screenshot({ type: "png" })) as Buffer | Uint8Array;
|
|
|
|
// Capture frame #2: the same time, reached via synchronous seek. The
|
|
// player is already paused, so seek() lands the timeline directly on
|
|
// capturedTime via _trySyncSeek -> __player.seek().
|
|
await page.evaluate((targetSeconds: number) => {
|
|
const el = document.getElementById("player") as
|
|
| (HTMLElement & { seek: (t: number) => void })
|
|
| null;
|
|
if (!el) throw new Error("[scenario:parity] player element missing on host page");
|
|
el.seek(targetSeconds);
|
|
}, capturedTime);
|
|
|
|
await waitForPaint(page);
|
|
const referenceImage = (await page.screenshot({ type: "png" })) as Buffer | Uint8Array;
|
|
|
|
// Persist artifacts under results/parity/run-N/ for CI upload and local
|
|
// inspection. Captured time is written alongside so we can reproduce
|
|
// a specific run's seek target later.
|
|
const runDir = resolve(RESULTS_DIR, `run-${idx + 1}`);
|
|
ensureDir(runDir);
|
|
const actualPath = resolve(runDir, "actual.png");
|
|
const referencePath = resolve(runDir, "reference.png");
|
|
writeFileSync(actualPath, actualImage);
|
|
writeFileSync(referencePath, referenceImage);
|
|
writeFileSync(
|
|
resolve(runDir, "captured-time.txt"),
|
|
`${capturedTime}\n${TARGET_TIME_S}\n`,
|
|
"utf-8",
|
|
);
|
|
|
|
const ssim = computeSsim(referencePath, actualPath, runDir);
|
|
console.log(
|
|
`[scenario:parity] run[${idx + 1}/${total}] ssim=${ssim.toFixed(6)} captured_time=${capturedTime.toFixed(6)}s artifacts=${runDir}`,
|
|
);
|
|
|
|
await page.close();
|
|
return { ssim, capturedTime };
|
|
} finally {
|
|
await ctx.close();
|
|
}
|
|
}
|
|
|
|
export async function runParity(opts: ParityScenarioOpts): Promise<Metric[]> {
|
|
const fixture = opts.fixture ?? DEFAULT_FIXTURE;
|
|
const runs = Math.max(1, opts.runs);
|
|
console.log(`[scenario:parity] fixture=${fixture} runs=${runs} target=${TARGET_TIME_S}s`);
|
|
|
|
// Wipe stale per-run dirs from previous invocations so artifact upload
|
|
// only contains this run's PNGs. We don't rm -rf the parent dir to avoid
|
|
// surprising anyone debugging a previous failure.
|
|
ensureDir(RESULTS_DIR);
|
|
|
|
const ssims: number[] = [];
|
|
for (let i = 0; i < runs; i++) {
|
|
const result = await runOnce(opts, fixture, i, runs);
|
|
ssims.push(result.ssim);
|
|
}
|
|
|
|
// Worst case wins. A min < 0.93 means at least one run produced visibly
|
|
// different pixels between live playback and sync seek at the same time —
|
|
// which is the regression we're guarding against (see file-level JSDoc
|
|
// for why the gate is 0.93 rather than the proposal's 0.95).
|
|
const minSsim = Math.min(...ssims);
|
|
const meanSsim = ssims.reduce((a, b) => a + b, 0) / ssims.length;
|
|
console.log(
|
|
`[scenario:parity] aggregate min=${minSsim.toFixed(6)} mean=${meanSsim.toFixed(6)} runs=${runs}`,
|
|
);
|
|
|
|
return [
|
|
{
|
|
name: "parity_ssim_min",
|
|
baselineKey: "paritySsimMin",
|
|
value: minSsim,
|
|
unit: "ssim",
|
|
direction: "higher-is-better",
|
|
samples: ssims,
|
|
},
|
|
];
|
|
}
|