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:
Miguel Ángel
2026-07-14 01:46:44 -04:00
committed by GitHub
parent b98463ae3a
commit 6933e8acda
9 changed files with 120 additions and 16 deletions
@@ -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);
+11 -1
View File
@@ -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(`
+9 -2
View File
@@ -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, {
+11 -3
View File
@@ -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";
+38 -1
View File
@@ -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");
});
});
+15 -5
View File
@@ -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);
+16
View File
@@ -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([
+8
View File
@@ -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 =