diff --git a/packages/cli/src/capture/captureCompositionFrame.test.ts b/packages/cli/src/capture/captureCompositionFrame.test.ts index 84bcb73be5..46840097a4 100644 --- a/packages/cli/src/capture/captureCompositionFrame.test.ts +++ b/packages/cli/src/capture/captureCompositionFrame.test.ts @@ -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]); diff --git a/packages/cli/src/capture/captureCompositionFrame.ts b/packages/cli/src/capture/captureCompositionFrame.ts index 49c9af67b4..fff9bf4b2e 100644 --- a/packages/cli/src/capture/captureCompositionFrame.ts +++ b/packages/cli/src/capture/captureCompositionFrame.ts @@ -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; - screenshot(options: { clip: CropRegion; type: "png" }): Promise; + screenshot(options: { clip: CropRegion; type: "png"; omitBackground: true }): Promise; } /** @@ -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); diff --git a/packages/cli/src/commands/keyframes.test.ts b/packages/cli/src/commands/keyframes.test.ts index 62e10e595e..1408061646 100644 --- a/packages/cli/src/commands/keyframes.test.ts +++ b/packages/cli/src/commands/keyframes.test.ts @@ -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(` diff --git a/packages/cli/src/commands/layout.ts b/packages/cli/src/commands/layout.ts index a7025d787e..5879f21290 100644 --- a/packages/cli/src/commands/layout.ts +++ b/packages/cli/src/commands/layout.ts @@ -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, { diff --git a/packages/cli/src/commands/motionShot.ts b/packages/cli/src/commands/motionShot.ts index 0bf1061c6b..43ddf59929 100644 --- a/packages/cli/src/commands/motionShot.ts +++ b/packages/cli/src/commands/motionShot.ts @@ -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 { + ensureShotOutputDir(outPath); let requests = requestsIn; const samples = Math.max(1, Math.min(60, opts.samples ?? 9)); const layout = opts.layout ?? "path"; diff --git a/packages/cli/src/commands/snapshot.test.ts b/packages/cli/src/commands/snapshot.test.ts index 4cb0d394c7..73397d845e 100644 --- a/packages/cli/src/commands/snapshot.test.ts +++ b/packages/cli/src/commands/snapshot.test.ts @@ -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"); + }); +}); diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index ae8e8297b0..bbdbd6f914 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -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); diff --git a/packages/cli/src/utils/renderArgs.test.ts b/packages/cli/src/utils/renderArgs.test.ts index 3db8d6344c..864e5f3e4e 100644 --- a/packages/cli/src/utils/renderArgs.test.ts +++ b/packages/cli/src/utils/renderArgs.test.ts @@ -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([ diff --git a/packages/cli/src/utils/renderArgs.ts b/packages/cli/src/utils/renderArgs.ts index db23734ed6..c29d6e14fc 100644 --- a/packages/cli/src/utils/renderArgs.ts +++ b/packages/cli/src/utils/renderArgs.ts @@ -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 = 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 =