mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(cli): consolidate snapshot and frame diagnostics (#2402)
* fix(snapshot): preserve exact requested times * fix(cli): fail video snapshots without FFmpeg * fix(cli): honor navigation timeout in diagnostics * fix(cli): preserve snapshot alpha and create shot dirs
This commit is contained in:
@@ -305,7 +305,11 @@ describe("captureRegionCrop", () => {
|
||||
const buffer = await captureRegionCrop(page, region, 3);
|
||||
|
||||
expect(setViewport).toHaveBeenNthCalledWith(1, { ...original, deviceScaleFactor: 3 });
|
||||
expect(screenshot).toHaveBeenCalledWith({ clip: region, type: "png" });
|
||||
expect(screenshot).toHaveBeenCalledWith({
|
||||
clip: region,
|
||||
type: "png",
|
||||
omitBackground: true,
|
||||
});
|
||||
expect(setViewport).toHaveBeenNthCalledWith(2, original);
|
||||
expect(buffer).toBeInstanceOf(Buffer);
|
||||
expect(Array.from(buffer)).toEqual([1, 2, 3]);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
|
||||
import type { Browser, Page } from "puppeteer-core";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
|
||||
import { resolveDiagnosticNavigationTimeoutMs } from "../utils/renderArgs.js";
|
||||
|
||||
const SHADER_TRANSITIONS_TIMEOUT_MS = 90_000;
|
||||
const CAPTURE_SETTLE_MS = 1500;
|
||||
@@ -171,7 +172,10 @@ export async function openSettledCompositionPage(
|
||||
await installPageFunctionGuard(page);
|
||||
await page.setViewport(viewport);
|
||||
await options.beforeNavigate?.(page);
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
|
||||
await page.goto(url, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: resolveDiagnosticNavigationTimeoutMs(),
|
||||
});
|
||||
const renderReadyTimedOut = !(await waitForCompositionSettle(page, options));
|
||||
return { browser: chromeBrowser, page, renderReadyTimedOut };
|
||||
} catch (err) {
|
||||
@@ -446,7 +450,7 @@ export interface CropCapturePage {
|
||||
height: number;
|
||||
deviceScaleFactor?: number;
|
||||
}): Promise<void>;
|
||||
screenshot(options: { clip: CropRegion; type: "png" }): Promise<Uint8Array>;
|
||||
screenshot(options: { clip: CropRegion; type: "png"; omitBackground: true }): Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -465,7 +469,7 @@ export async function captureRegionCrop(
|
||||
const original = page.viewport();
|
||||
if (original) await page.setViewport({ ...original, deviceScaleFactor: scale });
|
||||
try {
|
||||
const shot = await page.screenshot({ clip: region, type: "png" });
|
||||
const shot = await page.screenshot({ clip: region, type: "png", omitBackground: true });
|
||||
return Buffer.isBuffer(shot) ? shot : Buffer.from(shot);
|
||||
} finally {
|
||||
if (original) await page.setViewport(original);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { ensureDOMParser } from "../utils/dom.js";
|
||||
import { collectShotSelectors, resolveScope, surfaceComposition } from "./keyframes.js";
|
||||
import { ensureShotOutputDir } from "./motionShot.js";
|
||||
|
||||
beforeAll(() => ensureDOMParser());
|
||||
|
||||
@@ -26,6 +27,15 @@ describe("keyframes direct composition scope", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyframes shot output", () => {
|
||||
it("creates a missing parent directory before writing --shot", () => {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-keyframes-shot-dir-"));
|
||||
const outputDir = join(projectDir, "nested", "proofs");
|
||||
ensureShotOutputDir(join(outputDir, "shot.png"));
|
||||
expect(existsSync(outputDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyframes multi-stroke traces", () => {
|
||||
it("composites ≥2 position strokes on one element into a single trace", () => {
|
||||
const html = wrap(`
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
import { resolveDiagnosticNavigationTimeoutMs } from "../utils/renderArgs.js";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
|
||||
import { printDeprecationNotice, withMeta } from "../utils/updateCheck.js";
|
||||
@@ -180,7 +181,10 @@ async function alignViewportToComposition(
|
||||
});
|
||||
|
||||
await page.setViewport(size);
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
|
||||
await page.goto(url, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: resolveDiagnosticNavigationTimeoutMs(),
|
||||
});
|
||||
}
|
||||
|
||||
async function runLayoutAudit(
|
||||
@@ -217,7 +221,10 @@ async function runLayoutAudit(
|
||||
const page = await chromeBrowser.newPage();
|
||||
await installPageFunctionGuard(page);
|
||||
await page.setViewport({ width: 1920, height: 1080 });
|
||||
await page.goto(server.url, { waitUntil: "domcontentloaded", timeout: 10000 });
|
||||
await page.goto(server.url, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: resolveDiagnosticNavigationTimeoutMs(),
|
||||
});
|
||||
await alignViewportToComposition(page, server.url);
|
||||
await page
|
||||
.waitForFunction(() => !!(window as unknown as { __timelines?: unknown }).__timelines, {
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
// exactly what it's editing. All geometry + SVG live in ./motionShotLayout.ts
|
||||
// (pure, tested); this file only drives the browser and SAMPLES.
|
||||
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { resolveDiagnosticNavigationTimeoutMs } from "../utils/renderArgs.js";
|
||||
import {
|
||||
buildOnionSvg,
|
||||
ghostAlphas,
|
||||
@@ -25,6 +27,10 @@ export interface ShotRequest {
|
||||
selector: string;
|
||||
}
|
||||
|
||||
export function ensureShotOutputDir(outPath: string): void {
|
||||
mkdirSync(dirname(outPath), { recursive: true });
|
||||
}
|
||||
|
||||
/** Returned by the in-browser selector resolver: which animated selectors a
|
||||
* `--selector SCOPE` actually resolves to (scope itself, or its descendants),
|
||||
* plus diagnostic context when nothing under the scope animates. */
|
||||
@@ -258,7 +264,8 @@ async function openCompositionPage(
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
|
||||
const navigationTimeout = resolveDiagnosticNavigationTimeoutMs();
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: navigationTimeout });
|
||||
const size = await page.evaluate(() => {
|
||||
const root = document.querySelector("[data-composition-id][data-width][data-height]");
|
||||
const w = root ? parseInt(root.getAttribute("data-width") ?? "", 10) : 0;
|
||||
@@ -269,7 +276,7 @@ async function openCompositionPage(
|
||||
};
|
||||
});
|
||||
await page.setViewport(size);
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: navigationTimeout });
|
||||
await page
|
||||
.waitForFunction(() => !!(window as unknown as { __timelines?: unknown }).__timelines, {
|
||||
timeout: 10000,
|
||||
@@ -611,6 +618,7 @@ export async function captureMotionPathShot(
|
||||
outPath: string,
|
||||
opts: ShotOptions = {},
|
||||
): Promise<string> {
|
||||
ensureShotOutputDir(outPath);
|
||||
let requests = requestsIn;
|
||||
const samples = Math.max(1, Math.min(60, opts.samples ?? 9));
|
||||
const layout = opts.layout ?? "path";
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeSnapshotTimes, parseZoomScale, tailFrameTime } from "./snapshot.js";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
computeSnapshotTimes,
|
||||
parseZoomScale,
|
||||
requireSnapshotFfmpeg,
|
||||
tailFrameTime,
|
||||
} from "./snapshot.js";
|
||||
|
||||
// --zoom's crop-region math (selector bbox + padding + clamp, exact region
|
||||
// form, no-match error) is owned by and tested in
|
||||
@@ -21,6 +27,15 @@ describe("tailFrameTime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("transparent snapshot capture", () => {
|
||||
it("asks Chrome to retain the alpha channel in review PNGs", () => {
|
||||
const source = readFileSync(new URL("./snapshot.ts", import.meta.url), "utf8");
|
||||
expect(source).toContain(
|
||||
'page.screenshot({ path: framePath, type: "png", omitBackground: true })',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSnapshotTimes (FINDING [7]: tail is always captured)", () => {
|
||||
it("default frames: last point is the readable tail, never exact duration", () => {
|
||||
const { times, appendedTail } = computeSnapshotTimes(8, { frames: 5 });
|
||||
@@ -62,6 +77,16 @@ describe("computeSnapshotTimes (FINDING [7]: tail is always captured)", () => {
|
||||
expect(times).toEqual([1, 2]);
|
||||
expect(appendedTail).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves exact explicit transition timestamps", () => {
|
||||
const exactTransition = 3.3666666666666667;
|
||||
const { times } = computeSnapshotTimes(8, {
|
||||
frames: 5,
|
||||
at: [exactTransition],
|
||||
includeEnd: false,
|
||||
});
|
||||
expect(times).toEqual([exactTransition]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseZoomScale (--zoom-scale)", () => {
|
||||
@@ -79,3 +104,15 @@ describe("parseZoomScale (--zoom-scale)", () => {
|
||||
expect(parseZoomScale("-1")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireSnapshotFfmpeg", () => {
|
||||
it("rejects video snapshot extraction when FFmpeg is unavailable", () => {
|
||||
expect(() => requireSnapshotFfmpeg(undefined)).toThrow(
|
||||
/FFmpeg is required to extract video frames for snapshots/,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the resolved FFmpeg executable", () => {
|
||||
expect(requireSnapshotFfmpeg("C:\\tools\\ffmpeg.exe")).toBe("C:\\tools\\ffmpeg.exe");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ import { resolveProject } from "../utils/project.js";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { findFFmpeg } from "../browser/ffmpeg.js";
|
||||
import { findFFmpeg, getFFmpegInstallHint } from "../browser/ffmpeg.js";
|
||||
import { parseAngle, type Camera } from "./motionShotLayout.js";
|
||||
import type { Example } from "./_examples.js";
|
||||
|
||||
@@ -60,6 +60,13 @@ function orbitStageSource(): string {
|
||||
* `hyperframes snapshot` indefinitely. */
|
||||
const FFMPEG_EXTRACT_TIMEOUT_MS = 30_000;
|
||||
|
||||
export function requireSnapshotFfmpeg(ffmpegPath: string | undefined): string {
|
||||
if (ffmpegPath) return ffmpegPath;
|
||||
throw new Error(
|
||||
`FFmpeg is required to extract video frames for snapshots. ${getFFmpegInstallHint()}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a single frame from a video file at `timeSeconds` via FFmpeg.
|
||||
* Used to work around Chrome-headless's inability to reliably seek
|
||||
@@ -73,8 +80,7 @@ async function extractVideoFrameToBuffer(
|
||||
const tmp = mkdtempSync(join(tmpdir(), "hf-snapshot-frame-"));
|
||||
const outPath = join(tmp, "frame.png");
|
||||
try {
|
||||
const ffmpegPath = findFFmpeg();
|
||||
if (!ffmpegPath) return null;
|
||||
const ffmpegPath = requireSnapshotFfmpeg(findFFmpeg());
|
||||
// `-ss` before `-i` performs a fast keyframe seek; adequate for snapshot accuracy
|
||||
// (±1 frame) and orders of magnitude faster than the decode-and-scan alternative.
|
||||
const args = ["-hide_banner", "-loglevel", "error"];
|
||||
@@ -159,7 +165,11 @@ export function computeSnapshotTimes(
|
||||
const round = (t: number) => Math.round(t * 1000) / 1000;
|
||||
|
||||
if (opts.at?.length) {
|
||||
const times = opts.at.map(round);
|
||||
// `--at` is an evidence contract: callers may pass exact fractional-frame
|
||||
// boundaries (for example 101 / 30). Do not normalize their requested
|
||||
// positions; rounding to milliseconds can move a transition sample to the
|
||||
// other side of the boundary.
|
||||
const times = [...opts.at];
|
||||
// Only append if the user didn't already sample at/near the readable tail.
|
||||
const hasTail = times.some((t) => Math.abs(t - tail) < 0.05 || t >= duration);
|
||||
if (includeEnd && duration > 0 && !hasTail) {
|
||||
@@ -481,7 +491,7 @@ async function captureSnapshots(
|
||||
);
|
||||
writeFileSync(framePath, buffer);
|
||||
} else {
|
||||
await page.screenshot({ path: framePath, type: "png" });
|
||||
await page.screenshot({ path: framePath, type: "png", omitBackground: true });
|
||||
}
|
||||
const rel = relative(projectDir, framePath);
|
||||
savedPaths.push(rel.startsWith("..") || isAbsolute(rel) ? framePath : rel);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
MAX_PAGE_NAVIGATION_TIMEOUT_SECONDS,
|
||||
hasExplicitCompositionArg,
|
||||
parseBrowserTimeoutMsArg,
|
||||
resolveDiagnosticNavigationTimeoutMs,
|
||||
parseCompositionEntryArg,
|
||||
parseGifLoopArg,
|
||||
resolveDefaultFpsArg,
|
||||
@@ -104,6 +105,21 @@ describe("parseBrowserTimeoutMsArg", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDiagnosticNavigationTimeoutMs", () => {
|
||||
it("uses the render navigation timeout env override", () => {
|
||||
expect(
|
||||
resolveDiagnosticNavigationTimeoutMs({ PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS: "90000" }),
|
||||
).toBe(90_000);
|
||||
});
|
||||
|
||||
it("falls back to ten seconds for missing or invalid values", () => {
|
||||
expect(resolveDiagnosticNavigationTimeoutMs({})).toBe(10_000);
|
||||
expect(
|
||||
resolveDiagnosticNavigationTimeoutMs({ PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS: "invalid" }),
|
||||
).toBe(10_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCompositionEntryArg", () => {
|
||||
it("uses one sentinel classifier for default and explicit composition values", () => {
|
||||
expect([undefined, "", " ", ".", "./"].map(hasExplicitCompositionArg)).toEqual([
|
||||
|
||||
@@ -117,6 +117,14 @@ export function resolveBrowserTimeoutMsArg(raw: string | undefined): number | un
|
||||
return result.value;
|
||||
}
|
||||
|
||||
/** Navigation budget shared by snapshot/check/inspect browser diagnostics. */
|
||||
export function resolveDiagnosticNavigationTimeoutMs(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): number {
|
||||
const parsed = Number(env.PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 10_000;
|
||||
}
|
||||
|
||||
// ── --composition ──────────────────────────────────────────────────────
|
||||
|
||||
export type CompositionEntryParseError =
|
||||
|
||||
Reference in New Issue
Block a user