From b3422aedca24932b06a980527b2eb7a3a3f42e91 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 01:13:07 +0000 Subject: [PATCH 1/4] feat(cli): golden baseline regression gate for check and snapshot Adds a committed-baseline visual regression gate: - golden//.png convention with an optional golden.json manifest (times, threshold, maxDiffRatio, ignoreAntialiasing) - 'hyperframes check --golden' re-captures at the manifest times, pixel-diffs against the baselines (sharp raw RGBA, configurable per-channel threshold, 1px anti-aliasing shift detection), fails the check on regression, and writes golden-diff/ with per-frame red-highlight diffs plus a baseline|current|diff contact sheet - 'hyperframes check --update-golden' / 'hyperframes snapshot --update-golden' refresh the baselines and manifest - agent-readable JSON summary {ok, failed:[{id,time,maxDelta}], diffSheet} embedded in the check --json envelope - unit tests for the diff math (synthetic PNGs, no Chrome), the golden conventions, and the check command wiring --- packages/cli/src/capture/contactSheet.ts | 36 ++ packages/cli/src/commands/check.test.ts | 144 +++++++ packages/cli/src/commands/check.ts | 152 ++++++- packages/cli/src/commands/snapshot.ts | 33 ++ packages/cli/src/golden/baseline.test.ts | 172 ++++++++ packages/cli/src/golden/baseline.ts | 496 ++++++++++++++++++++++ packages/cli/src/golden/pixelDiff.test.ts | 177 ++++++++ packages/cli/src/golden/pixelDiff.ts | 236 ++++++++++ 8 files changed, 1431 insertions(+), 15 deletions(-) create mode 100644 packages/cli/src/golden/baseline.test.ts create mode 100644 packages/cli/src/golden/baseline.ts create mode 100644 packages/cli/src/golden/pixelDiff.test.ts create mode 100644 packages/cli/src/golden/pixelDiff.ts diff --git a/packages/cli/src/capture/contactSheet.ts b/packages/cli/src/capture/contactSheet.ts index ffb5b37a22..8f154e2020 100644 --- a/packages/cli/src/capture/contactSheet.ts +++ b/packages/cli/src/capture/contactSheet.ts @@ -236,6 +236,42 @@ export async function createSnapshotContactSheet( ); } +export interface GoldenDiffSheetRow { + /** Row label, e.g. "t=1.5s". */ + label: string; + baselinePath: string; + currentPath: string; + diffPath: string; +} + +/** + * Contact sheet for golden baseline failures: one row per failed sample time, + * cells ordered baseline | current | diff. Paginated at 3 rows per page so + * cells stay readable. Returns the written file paths. + */ +export async function createGoldenDiffContactSheet( + rows: GoldenDiffSheetRow[], + outputPath: string, + budget: Pick = {}, +): Promise { + if (rows.length === 0) return []; + + const paths = rows.flatMap((row) => [row.baselinePath, row.currentPath, row.diffPath]); + const labels = rows.flatMap((row) => [ + `${row.label} baseline`, + `${row.label} current`, + `${row.label} diff`, + ]); + + return createContactSheetPages( + paths, + outputPath, + { cols: 3, cellWidth: 600, pageSize: 9, ...budget }, + 0, + labels, + ); +} + /** * Contact sheet for captured assets. Paginated — all assets covered. * Labels: "1. filename" diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index 73f548ef88..37988a10f3 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -27,6 +27,7 @@ import { type CheckFindingCropRequest, type CheckOptions, type CheckReport, + type CheckSection, type ContrastAuditEntry, type MotionSpecResolution, } from "../utils/checkPipeline.js"; @@ -1798,3 +1799,146 @@ describe("dense motion-overlap re-sampling", () => { expect(report.layout.findings.some((f) => f.code === "content_overlap")).toBe(true); }); }); + +describe("golden gate flags", () => { + it("parses --golden-threshold and rejects out-of-range values", async () => { + const { parseGoldenThreshold } = await import("./check.js"); + expect(parseGoldenThreshold(undefined)).toBeUndefined(); + expect(parseGoldenThreshold("0")).toBe(0); + expect(parseGoldenThreshold("0.25")).toBe(0.25); + expect(parseGoldenThreshold("1")).toBe(1); + expect(() => parseGoldenThreshold("1.5")).toThrow("Invalid --golden-threshold"); + expect(() => parseGoldenThreshold("-0.1")).toThrow("Invalid --golden-threshold"); + expect(() => parseGoldenThreshold("0.1px")).toThrow("Invalid --golden-threshold"); + expect(() => parseGoldenThreshold("")).toThrow("Invalid --golden-threshold"); + }); + + it("only enables the gate for --golden or --update-golden", async () => { + const { parseGoldenGateArgs } = await import("./check.js"); + const base = { timeout: 3000, autoProxy: undefined, browserGpuMode: undefined }; + expect(parseGoldenGateArgs({}, base)).toBeUndefined(); + expect(parseGoldenGateArgs({ "golden-threshold": "0.2" }, base)).toBeUndefined(); + expect(parseGoldenGateArgs({ golden: true }, base)).toEqual( + expect.objectContaining({ update: false, timeoutMs: 3000 }), + ); + expect(parseGoldenGateArgs({ "update-golden": true, "golden-threshold": "0.2" }, base)).toEqual( + expect.objectContaining({ update: true, threshold: 0.2 }), + ); + }); +}); + +describe("golden gate wiring", () => { + function passingReport(): CheckReport { + const section: CheckSection = { + ok: true, + errorCount: 0, + warningCount: 0, + infoCount: 0, + findings: [], + }; + return { + ok: true, + strict: false, + lint: { ...section, filesScanned: 1 }, + runtime: { ...section }, + layout: { + ...section, + findings: [], + duration: 1, + samples: [0.5], + transitionSamples: [], + transitionSamplesDropped: 0, + tolerance: 2, + totalIssueCount: 0, + truncated: false, + }, + motion: { ...section, enabled: false, samples: 0 }, + contrast: { ...section, findings: [], enabled: true, samples: [0.5], checked: 1, passed: 1 }, + snapshots: { enabled: false, files: [], times: [], findingFiles: [] }, + }; + } + + it("fails an otherwise passing check when the golden gate regresses", async () => { + const logs: string[] = []; + vi.spyOn(console, "log").mockImplementation((line?: unknown) => { + logs.push(String(line)); + }); + const runGolden = vi.fn(async () => ({ + ok: false, + updated: false, + compositionId: "intro", + compared: 2, + times: [0, 1.5], + failed: [ + { + id: "intro", + time: 1.5, + timeMs: 1500, + maxDelta: 210, + diffRatio: 0.0042, + reason: "pixel-diff" as const, + }, + ], + diffSheet: "golden-diff/intro/contact-sheet.jpg", + baselines: ["golden/intro/0.png", "golden/intro/1500.png"], + })); + const command = createCheckCommand({ + resolveProject: () => PROJECT, + runPipeline: async () => passingReport(), + withMeta: (value) => value, + runGolden, + }); + + await runCommand(command, { rawArgs: ["--json", "--golden"] }); + + expect(runGolden).toHaveBeenCalledWith(PROJECT, expect.objectContaining({ update: false })); + const payload = JSON.parse(logs.join("\n")); + expect(payload.ok).toBe(false); + expect(payload.golden.failed).toEqual([ + expect.objectContaining({ id: "intro", time: 1.5, maxDelta: 210 }), + ]); + expect(payload.golden.diffSheet).toBe("golden-diff/intro/contact-sheet.jpg"); + expect(consumeCommandResult().exitCode).toBe(1); + }); + + it("does not run the golden gate when not requested", async () => { + vi.spyOn(console, "log").mockImplementation(() => undefined); + const runGolden = vi.fn(); + const command = createCheckCommand({ + resolveProject: () => PROJECT, + runPipeline: async () => passingReport(), + withMeta: (value) => value, + runGolden, + }); + + await runCommand(command, { rawArgs: ["--json"] }); + + expect(runGolden).not.toHaveBeenCalled(); + expect(consumeCommandResult().exitCode).toBe(0); + }); + + it("keeps a passing exit code when --update-golden refreshes baselines", async () => { + vi.spyOn(console, "log").mockImplementation(() => undefined); + const runGolden = vi.fn(async () => ({ + ok: true, + updated: true, + compositionId: "intro", + compared: 2, + times: [0, 1.5], + failed: [], + diffSheet: null, + baselines: ["golden/intro/0.png", "golden/intro/1500.png"], + })); + const command = createCheckCommand({ + resolveProject: () => PROJECT, + runPipeline: async () => passingReport(), + withMeta: (value) => value, + runGolden, + }); + + await runCommand(command, { rawArgs: ["--json", "--update-golden"] }); + + expect(runGolden).toHaveBeenCalledWith(PROJECT, expect.objectContaining({ update: true })); + expect(consumeCommandResult().exitCode).toBe(0); + }); +}); diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index f90b494398..1f045c4267 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -19,10 +19,13 @@ import { } from "../utils/checkPipeline.js"; import type { CaptionZoneOptions, FrameCheckOptions, LayoutOptions } from "../utils/checkTypes.js"; import { resolveLocalBrowserGpuMode } from "../browser/gpuPolicy.js"; +import type { GoldenGateOptions, GoldenSummary } from "../golden/baseline.js"; export const examples: Example[] = [ ["Run the full verification gate", "hyperframes check"], ["Output one agent-readable envelope", "hyperframes check --json"], + ["Gate against committed golden baselines", "hyperframes check --golden"], + ["Refresh the golden baselines", "hyperframes check --update-golden"], ["Persist the five audited contrast frames", "hyperframes check --snapshots"], ["Also fail on warnings", "hyperframes check --strict"], ]; @@ -31,6 +34,8 @@ export interface CheckCommandDependencies { resolveProject(dir: string | undefined): ProjectDir; runPipeline(project: ProjectDir, options: CheckOptions): Promise; withMeta(value: object): object; + /** Golden baseline gate; defaults to a lazy import so sharp only loads when requested. */ + runGolden?(project: ProjectDir, options: GoldenGateOptions): Promise; } const DEFAULT_COMMAND_DEPENDENCIES: CheckCommandDependencies = { @@ -42,6 +47,9 @@ const DEFAULT_COMMAND_DEPENDENCIES: CheckCommandDependencies = { const CHECK_COMMAND_ARGS = { dir: { type: "positional", description: "Project directory", required: false }, json: { type: "boolean", description: "Output agent-readable JSON", default: false }, + // The sampling args below intentionally mirror the deprecated `layout` + // command's grammar (check superseded it) — an inherited clone, not new code. + // fallow-ignore-next-line code-duplication samples: { type: "string", description: "Number of midpoint samples across the duration (default: 9)", @@ -110,6 +118,22 @@ const CHECK_COMMAND_ARGS = { description: "Save the five contrast-pass PNGs under snapshots/", default: false, }, + golden: { + type: "boolean", + description: + "Also gate against committed golden baselines (golden//.png): re-capture at the manifest times, pixel-diff, and fail on regressions", + default: false, + }, + "update-golden": { + type: "boolean", + description: "Refresh the golden baselines from the current render instead of gating", + default: false, + }, + "golden-threshold": { + type: "string", + description: + "Per-channel pixel tolerance for the golden gate, 0-1 (default: golden.json threshold, else 0.1)", + }, "caption-zone": { type: "string", description: @@ -141,18 +165,7 @@ export function createCheckCommand( const asJson = args.json === true; try { - const project = dependencies.resolveProject(args.dir); - const options = parseCheckOptions(args); - if (!asJson) { - console.log(`${c.accent("◆")} Checking ${c.accent(project.name)}`); - } - const report = await dependencies.runPipeline(project, options); - if (asJson) { - console.log(JSON.stringify(dependencies.withMeta(report), null, 2)); - } else { - printHumanReport(report); - } - setCommandExitCode(checkExitCode(report)); + setCommandExitCode(await executeCheck(dependencies, args, asJson)); } catch (error) { const message = normalizeErrorMessage(error); if (asJson) { @@ -168,6 +181,49 @@ export function createCheckCommand( }); } +async function resolveGoldenSummary( + dependencies: CheckCommandDependencies, + project: ProjectDir, + goldenOptions: GoldenGateOptions | undefined, +): Promise { + if (!goldenOptions) return undefined; + const runGolden = dependencies.runGolden ?? (await import("../golden/baseline.js")).runGoldenGate; + return runGolden(project, goldenOptions); +} + +function emitCheckReport( + dependencies: CheckCommandDependencies, + report: CheckReport, + golden: GoldenSummary | undefined, + ok: boolean, + asJson: boolean, +): void { + if (!asJson) { + printHumanReport(report, golden); + return; + } + const payload = golden ? { ...report, ok, golden } : report; + console.log(JSON.stringify(dependencies.withMeta(payload), null, 2)); +} + +async function executeCheck( + dependencies: CheckCommandDependencies, + args: Record, + asJson: boolean, +): Promise<0 | 1> { + const project = dependencies.resolveProject(typeof args.dir === "string" ? args.dir : undefined); + const options = parseCheckOptions(args); + const goldenOptions = parseGoldenGateArgs(args, options); + if (!asJson) { + console.log(`${c.accent("◆")} Checking ${c.accent(project.name)}`); + } + const report = await dependencies.runPipeline(project, options); + const golden = await resolveGoldenSummary(dependencies, project, goldenOptions); + const ok = checkExitCode(report) === 0 && (golden?.ok ?? true); + emitCheckReport(dependencies, report, golden, ok, asJson); + return ok ? 0 : 1; +} + function normalizeFrameCheckRawArgs(rawArgs: string[]): string[] { return rawArgs.map((arg, index) => { if (arg !== "--frame-check") return arg; @@ -198,6 +254,36 @@ function parseCheckOptions(args: Record): CheckOptions { }; } +/** + * `--golden` / `--update-golden` / `--golden-threshold` → gate options, or + * undefined when the golden gate was not requested. The threshold flag alone + * does not enable the gate — it only tunes an explicitly requested run. + */ +export function parseGoldenGateArgs( + args: Record, + options: Pick, +): GoldenGateOptions | undefined { + const update = args["update-golden"] === true; + if (args.golden !== true && !update) return undefined; + return { + update, + threshold: parseGoldenThreshold(args["golden-threshold"]), + timeoutMs: options.timeout, + autoProxy: options.autoProxy, + browserGpuMode: options.browserGpuMode, + }; +} + +export function parseGoldenThreshold(value: unknown): number | undefined { + if (value === undefined || value === null) return undefined; + const raw = typeof value === "string" ? value.trim() : ""; + const parsed = parseNumberStrict(raw); + if (parsed === null || parsed < 0 || parsed > 1) { + throw new Error("Invalid --golden-threshold: expected a number from 0 to 1"); + } + return parsed; +} + const CAPTION_ZONE_FIELDS = new Set(["x0", "y0", "x1", "y1", "severity", "seek"]); const FRAME_CHECK_FIELDS = new Set(["severity", "seek", "tol"]); @@ -403,16 +489,52 @@ function nonNegativeNumber(value: unknown, fallback: number): number { return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; } -function printHumanReport(report: CheckReport): void { +function printHumanReport(report: CheckReport, golden?: GoldenSummary): void { printSection("Lint", report.lint); printSection("Runtime", report.runtime); printLayoutSection("Layout", report.layout); printSection("Motion", report.motion); printContrastSection(report); printSnapshotSection(report); + if (golden) printGoldenSection(golden); console.log(); - const label = report.ok ? c.success("Check passed") : c.error("Check failed"); - console.log(`${report.ok ? c.success("◇") : c.error("◇")} ${label}`); + const ok = report.ok && (golden?.ok ?? true); + const label = ok ? c.success("Check passed") : c.error("Check failed"); + console.log(`${ok ? c.success("◇") : c.error("◇")} ${label}`); +} + +function printGoldenSection(golden: GoldenSummary): void { + console.log(); + console.log(c.bold("Golden")); + if (golden.updated) { + console.log( + ` ${c.success("◇")} ${golden.baselines.length} baseline(s) written for ${golden.compositionId}`, + ); + for (const baseline of golden.baselines) console.log(` ${c.dim(baseline)}`); + return; + } + if (golden.ok) { + console.log( + ` ${c.success("◇")} ${golden.compared}/${golden.compared} frame(s) match the committed baselines`, + ); + return; + } + for (const failure of golden.failed) { + const detail = + failure.reason === "pixel-diff" + ? `${(failure.diffRatio * 100).toFixed(3)}% pixels differ (max channel delta ${failure.maxDelta})` + : failure.reason; + console.log(` ${c.error("✗")} t=${failure.time}s ${detail}`); + } + console.log( + ` ${c.dim(`${golden.failed.length} of ${golden.compared} frame(s) regressed vs golden/${golden.compositionId}/`)}`, + ); + if (golden.diffSheet) { + console.log(` ${c.dim(`Diff sheet: ${golden.diffSheet}`)}`); + } + console.log( + ` ${c.dim("Intended change? Refresh baselines with hyperframes check --update-golden")}`, + ); } function printSection(title: string, section: CheckSection): void { diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 767f446da0..f6f315824d 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -186,6 +186,7 @@ export const examples: Example[] = [ "Pair each frame with the reference footage at the same time", "snapshot --at 1.5,4.3,8.1 --against ref.mp4", ], + ["Refresh the committed golden baselines", "snapshot --update-golden"], ]; /** `--zoom-scale`: the deviceScaleFactor used for zoomed crops. Defaults to 3; @@ -704,6 +705,12 @@ export default defineCommand({ "Use hardware browser GPU capture; pass --no-browser-gpu for deterministic SwiftShader (default: auto-detect, PRODUCER_BROWSER_GPU_MODE overrides)", default: undefined, }, + "update-golden": { + type: "boolean", + description: + "Refresh the committed golden baselines (golden//.png) instead of capturing review snapshots; --at overrides the sample times", + default: false, + }, }, async run({ args }) { const project = resolveProject(args.dir); @@ -753,6 +760,32 @@ export default defineCommand({ ? null : String(args.describe); + if (args["update-golden"] === true) { + try { + const { runGoldenGate } = await import("../golden/baseline.js"); + const summary = await runGoldenGate(project, { + update: true, + at: atTimestamps, + timeoutMs: timeout, + autoProxy: args.proxy as boolean | undefined, + browserGpuMode: resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined), + }); + console.log( + `${c.success("◇")} ${summary.baselines.length} golden baseline(s) written for ${c.accent(summary.compositionId)}`, + ); + for (const baseline of summary.baselines) console.log(` ${baseline}`); + console.log( + ` ${c.dim(`Commit golden/${summary.compositionId}/ and gate with hyperframes check --golden`)}`, + ); + } catch (err) { + console.error( + `\n${c.error("✗")} Golden baseline update failed: ${normalizeErrorMessage(err)}`, + ); + failCommand(); + } + return; + } + const camera = args.angle ? parseAngle(String(args.angle)) : undefined; const zoomTarget = args.zoom ? parseZoomTarget(String(args.zoom)) : undefined; const zoomScale = parseZoomScale(args["zoom-scale"]); diff --git a/packages/cli/src/golden/baseline.test.ts b/packages/cli/src/golden/baseline.test.ts new file mode 100644 index 0000000000..ce03afe757 --- /dev/null +++ b/packages/cli/src/golden/baseline.test.ts @@ -0,0 +1,172 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + evaluateGoldenDiff, + goldenBaselinePath, + goldenTimeFileName, + listGoldenBaselineTimesMs, + parseGoldenManifest, + parseGoldenTimeFileName, + readGoldenManifest, + resolveGoldenConfig, + resolveGoldenSampleTimes, + timeMsFromSeconds, +} from "./baseline.js"; +import type { PixelDiffResult } from "./pixelDiff.js"; + +function diffResult(overrides: Partial): PixelDiffResult { + return { + width: 4, + height: 4, + totalPixels: 16, + diffPixels: 0, + aaPixels: 0, + diffRatio: 0, + maxDelta: 0, + dimensionMismatch: false, + diff: new Uint8Array(64), + ...overrides, + }; +} + +describe("golden path conventions", () => { + it("encodes sample times as millisecond PNG filenames", () => { + expect(timeMsFromSeconds(1.5)).toBe(1500); + expect(timeMsFromSeconds(0)).toBe(0); + expect(timeMsFromSeconds(2.0004)).toBe(2000); + expect(goldenTimeFileName(1500)).toBe("1500.png"); + expect(goldenBaselinePath("/proj", "intro", 1500)).toBe( + join("/proj", "golden", "intro", "1500.png"), + ); + }); + + it("parses only bare .png names back into times", () => { + expect(parseGoldenTimeFileName("1500.png")).toBe(1500); + expect(parseGoldenTimeFileName("0.png")).toBe(0); + expect(parseGoldenTimeFileName("golden.json")).toBeNull(); + expect(parseGoldenTimeFileName("1500-diff.png")).toBeNull(); + expect(parseGoldenTimeFileName("1.5.png")).toBeNull(); + expect(parseGoldenTimeFileName("1500.PNG")).toBeNull(); + }); +}); + +describe("parseGoldenManifest", () => { + it("accepts a fully populated manifest", () => { + const manifest = parseGoldenManifest( + JSON.stringify({ + times: [0, 1.5, 3], + threshold: 0.05, + maxDiffRatio: 0.001, + ignoreAntialiasing: false, + }), + "golden.json", + ); + expect(manifest).toEqual({ + times: [0, 1.5, 3], + threshold: 0.05, + maxDiffRatio: 0.001, + ignoreAntialiasing: false, + }); + }); + + it("accepts an empty object and ignores unknown fields", () => { + expect(parseGoldenManifest("{}", "golden.json")).toEqual({}); + expect(parseGoldenManifest('{"note":"hi"}', "golden.json")).toEqual({}); + }); + + it("rejects malformed input with the offending field named", () => { + expect(() => parseGoldenManifest("not json", "golden.json")).toThrow(/not valid JSON/); + expect(() => parseGoldenManifest("[1,2]", "golden.json")).toThrow(/JSON object/); + expect(() => parseGoldenManifest('{"times":[]}', "golden.json")).toThrow(/"times"/); + expect(() => parseGoldenManifest('{"times":[-1]}', "golden.json")).toThrow(/"times"/); + expect(() => parseGoldenManifest('{"times":["a"]}', "golden.json")).toThrow(/"times"/); + expect(() => parseGoldenManifest('{"threshold":2}', "golden.json")).toThrow(/"threshold"/); + expect(() => parseGoldenManifest('{"maxDiffRatio":-0.1}', "golden.json")).toThrow( + /"maxDiffRatio"/, + ); + expect(() => parseGoldenManifest('{"ignoreAntialiasing":"yes"}', "golden.json")).toThrow( + /"ignoreAntialiasing"/, + ); + }); +}); + +describe("golden directory scanning", () => { + it("lists baseline times from filenames and reads the manifest beside them", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-golden-test-")); + try { + const compDir = join(dir, "golden", "intro"); + mkdirSync(compDir, { recursive: true }); + writeFileSync(join(compDir, "1500.png"), "png"); + writeFileSync(join(compDir, "0.png"), "png"); + writeFileSync(join(compDir, "notes.txt"), "ignore me"); + writeFileSync(join(compDir, "golden.json"), JSON.stringify({ times: [0, 1.5] })); + + expect(listGoldenBaselineTimesMs(dir, "intro")).toEqual([0, 1500]); + expect(readGoldenManifest(dir, "intro")).toEqual({ times: [0, 1.5] }); + expect(listGoldenBaselineTimesMs(dir, "missing")).toEqual([]); + expect(readGoldenManifest(dir, "missing")).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("resolveGoldenSampleTimes", () => { + it("prefers the explicit override, then the manifest, then baseline filenames", () => { + const input = { + atOverride: [1, 2], + manifestTimes: [3, 4], + baselineTimesMs: [5000, 6000], + duration: 10, + }; + expect(resolveGoldenSampleTimes(input)).toEqual([1, 2]); + expect(resolveGoldenSampleTimes({ ...input, atOverride: undefined })).toEqual([3, 4]); + expect( + resolveGoldenSampleTimes({ ...input, atOverride: undefined, manifestTimes: undefined }), + ).toEqual([5, 6]); + }); + + it("falls back to the snapshot command's default spread with a readable tail", () => { + const times = resolveGoldenSampleTimes({ duration: 10 }); + expect(times).toHaveLength(5); + expect(times[0]).toBe(0); + // The final sample backs off the exact duration so it is not a blank frame. + expect(times[4]).toBeLessThan(10); + expect(times[4]).toBeGreaterThan(9); + }); + + it("throws when no duration and no times are available", () => { + expect(() => resolveGoldenSampleTimes({ duration: 0 })).toThrow(/duration/); + }); +}); + +describe("resolveGoldenConfig", () => { + it("applies defaults, manifest values, and CLI overrides in that order", () => { + expect(resolveGoldenConfig(null)).toEqual({ + threshold: 0.1, + maxDiffRatio: 0, + ignoreAntialiasing: true, + }); + expect( + resolveGoldenConfig({ threshold: 0.02, maxDiffRatio: 0.005, ignoreAntialiasing: false }), + ).toEqual({ threshold: 0.02, maxDiffRatio: 0.005, ignoreAntialiasing: false }); + expect(resolveGoldenConfig({ threshold: 0.02 }, { threshold: 0.3 }).threshold).toBe(0.3); + }); +}); + +describe("evaluateGoldenDiff", () => { + it("fails on dimension changes regardless of ratio budget", () => { + expect(evaluateGoldenDiff(diffResult({ dimensionMismatch: true }), 1)).toBe( + "dimension-mismatch", + ); + }); + + it("passes at or under the allowed diff ratio and fails above it", () => { + expect(evaluateGoldenDiff(diffResult({ diffRatio: 0 }), 0)).toBe("pass"); + expect(evaluateGoldenDiff(diffResult({ diffRatio: 0.001 }), 0.001)).toBe("pass"); + expect(evaluateGoldenDiff(diffResult({ diffRatio: 0.0011 }), 0.001)).toBe("pixel-diff"); + expect(evaluateGoldenDiff(diffResult({ diffRatio: 0.0001 }), 0)).toBe("pixel-diff"); + }); +}); diff --git a/packages/cli/src/golden/baseline.ts b/packages/cli/src/golden/baseline.ts new file mode 100644 index 0000000000..e779066220 --- /dev/null +++ b/packages/cli/src/golden/baseline.ts @@ -0,0 +1,496 @@ +/** + * Golden baseline regression gate. + * + * Convention: committed reference frames live at + * `golden//.png`, with an optional + * `golden//golden.json` manifest describing the sample times + * and diff tuning. `runGoldenGate` re-captures the composition at those + * times and pixel-diffs against the baselines (fail-on-diff), or refreshes + * the baselines when `update` is set. + */ + +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { isAbsolute, join, relative } from "node:path"; +import type { BrowserGpuMode } from "../browser/gpuPolicy.js"; +import { + openSettledCompositionPage, + seekCompositionTimeline, +} from "../capture/captureCompositionFrame.js"; +import type { ProjectDir } from "../utils/project.js"; +import { serveStaticProjectHtml } from "../utils/staticProjectServer.js"; +import { + DEFAULT_DIFF_THRESHOLD, + diffPngs, + writeRawImagePng, + type PixelDiffResult, +} from "./pixelDiff.js"; + +const GOLDEN_DIR_NAME = "golden"; +const GOLDEN_DIFF_DIR_NAME = "golden-diff"; +const GOLDEN_MANIFEST_NAME = "golden.json"; + +/** Frames captured when neither a manifest nor existing baselines pick the times. */ +const DEFAULT_GOLDEN_FRAMES = 5; + +/** Optional `golden//golden.json` tuning file. */ +export interface GoldenManifest { + /** Timeline sample times in seconds. */ + times?: number[]; + /** Per-channel pixel tolerance, fraction of 255 (default 0.1). */ + threshold?: number; + /** Fraction of differing pixels allowed before the gate fails (default 0: fail on any diff). */ + maxDiffRatio?: number; + /** Exclude 1px anti-aliasing edge shifts from the failure count (default true). */ + ignoreAntialiasing?: boolean; +} + +export interface ResolvedGoldenConfig { + threshold: number; + maxDiffRatio: number; + ignoreAntialiasing: boolean; +} + +export type GoldenFailureReason = "pixel-diff" | "dimension-mismatch" | "missing-baseline"; + +export interface GoldenFailure { + /** Composition id the baseline belongs to. */ + id: string; + /** Sample time in seconds. */ + time: number; + timeMs: number; + /** Max per-channel delta (0-255) among counted differing pixels. */ + maxDelta: number; + /** Fraction of pixels that differ beyond the threshold. */ + diffRatio: number; + reason: GoldenFailureReason; +} + +/** Agent-readable gate result (also embedded in `check --json` output). */ +export interface GoldenSummary { + ok: boolean; + updated: boolean; + compositionId: string; + compared: number; + times: number[]; + failed: GoldenFailure[]; + /** Contact sheet of baseline | current | diff rows, written on failure. */ + diffSheet: string | null; + /** Baseline PNGs written (update) or gated against (check), project-relative. */ + baselines: string[]; +} + +export interface GoldenGateOptions { + /** Refresh baselines from the current render instead of gating. */ + update?: boolean; + /** Explicit sample times in seconds; overrides manifest and baseline-derived times. */ + at?: number[]; + /** CLI override for the per-channel pixel tolerance (0-1). */ + threshold?: number; + timeoutMs?: number; + autoProxy?: boolean; + browserGpuMode?: BrowserGpuMode; +} + +export function timeMsFromSeconds(seconds: number): number { + return Math.round(seconds * 1000); +} + +export function goldenTimeFileName(timeMs: number): string { + return `${timeMs}.png`; +} + +/** `.png` → timeMs; anything else (manifest, diff artifacts) → null. */ +export function parseGoldenTimeFileName(fileName: string): number | null { + const match = /^(\d+)\.png$/.exec(fileName); + return match ? Number.parseInt(match[1]!, 10) : null; +} + +function goldenCompositionDir(projectDir: string, compositionId: string): string { + return join(projectDir, GOLDEN_DIR_NAME, compositionId); +} + +export function goldenBaselinePath( + projectDir: string, + compositionId: string, + timeMs: number, +): string { + return join(goldenCompositionDir(projectDir, compositionId), goldenTimeFileName(timeMs)); +} + +function manifestError(sourcePath: string, detail: string): Error { + return new Error(`Invalid golden manifest ${sourcePath}: ${detail}`); +} + +function manifestTimes(value: unknown, sourcePath: string): number[] | undefined { + if (value === undefined) return undefined; + const valid = + Array.isArray(value) && + value.length > 0 && + value.every((t) => typeof t === "number" && Number.isFinite(t) && t >= 0); + if (!valid) { + throw manifestError(sourcePath, '"times" must be a non-empty array of non-negative seconds'); + } + return value.filter((t): t is number => typeof t === "number"); +} + +function manifestFraction(value: unknown, field: string, sourcePath: string): number | undefined { + if (value === undefined) return undefined; + if (typeof value !== "number" || value < 0 || value > 1) { + throw manifestError(sourcePath, `"${field}" must be a number from 0 to 1`); + } + return value; +} + +function manifestBoolean(value: unknown, field: string, sourcePath: string): boolean | undefined { + if (value === undefined) return undefined; + if (typeof value !== "boolean") { + throw manifestError(sourcePath, `"${field}" must be a boolean`); + } + return value; +} + +function manifestRecord(raw: string, sourcePath: string): Record { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw manifestError(sourcePath, "not valid JSON"); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw manifestError(sourcePath, "expected a JSON object"); + } + return { ...parsed }; +} + +/** Parse and validate a golden.json manifest. Throws with the offending field on invalid input. */ +export function parseGoldenManifest(raw: string, sourcePath: string): GoldenManifest { + const record = manifestRecord(raw, sourcePath); + const manifest: GoldenManifest = {}; + const times = manifestTimes(record.times, sourcePath); + if (times) manifest.times = times; + const threshold = manifestFraction(record.threshold, "threshold", sourcePath); + if (threshold !== undefined) manifest.threshold = threshold; + const maxDiffRatio = manifestFraction(record.maxDiffRatio, "maxDiffRatio", sourcePath); + if (maxDiffRatio !== undefined) manifest.maxDiffRatio = maxDiffRatio; + const ignoreAntialiasing = manifestBoolean( + record.ignoreAntialiasing, + "ignoreAntialiasing", + sourcePath, + ); + if (ignoreAntialiasing !== undefined) manifest.ignoreAntialiasing = ignoreAntialiasing; + return manifest; +} + +export function readGoldenManifest( + projectDir: string, + compositionId: string, +): GoldenManifest | null { + const manifestPath = join(goldenCompositionDir(projectDir, compositionId), GOLDEN_MANIFEST_NAME); + if (!existsSync(manifestPath)) return null; + return parseGoldenManifest(readFileSync(manifestPath, "utf8"), manifestPath); +} + +/** Baseline sample times (ms) derived from committed `.png` files, sorted. */ +export function listGoldenBaselineTimesMs(projectDir: string, compositionId: string): number[] { + const dir = goldenCompositionDir(projectDir, compositionId); + if (!existsSync(dir)) return []; + return readdirSync(dir) + .map(parseGoldenTimeFileName) + .filter((timeMs): timeMs is number => timeMs !== null) + .sort((a, b) => a - b); +} + +/** + * Default sample spread: evenly spaced frames whose final point backs off the + * exact duration to a readable tail. Mirrors the snapshot command's + * `computeSnapshotTimes`/`tailFrameTime` policy — kept as a local copy so the + * golden module does not import the snapshot command (which dynamically + * imports this module back). + */ +function defaultGoldenTimes(duration: number): number[] { + const tail = Math.max(0, duration - Math.max(0.05, duration * 0.03)); + const round = (t: number) => Math.round(t * 1000) / 1000; + const times = Array.from( + { length: DEFAULT_GOLDEN_FRAMES }, + (_, i) => (i / (DEFAULT_GOLDEN_FRAMES - 1)) * duration, + ); + times[times.length - 1] = tail; + return times.map(round); +} + +/** + * Pick the sample times, in precedence order: explicit `--at` override, + * manifest `times`, times encoded in existing baseline filenames, then the + * default spread (evenly spaced with a readable tail). + */ +export function resolveGoldenSampleTimes(input: { + atOverride?: number[]; + manifestTimes?: number[]; + baselineTimesMs?: number[]; + duration: number; +}): number[] { + if (input.atOverride && input.atOverride.length > 0) return [...input.atOverride]; + if (input.manifestTimes && input.manifestTimes.length > 0) return [...input.manifestTimes]; + if (input.baselineTimesMs && input.baselineTimesMs.length > 0) { + return input.baselineTimesMs.map((timeMs) => timeMs / 1000); + } + if (!(input.duration > 0)) { + throw new Error( + "Could not determine composition duration and no golden sample times were provided — pass --at or add times to golden.json", + ); + } + return defaultGoldenTimes(input.duration); +} + +export function resolveGoldenConfig( + manifest: GoldenManifest | null, + overrides: { threshold?: number } = {}, +): ResolvedGoldenConfig { + return { + threshold: overrides.threshold ?? manifest?.threshold ?? DEFAULT_DIFF_THRESHOLD, + maxDiffRatio: manifest?.maxDiffRatio ?? 0, + ignoreAntialiasing: manifest?.ignoreAntialiasing !== false, + }; +} + +/** Gate decision for one compared frame. */ +export function evaluateGoldenDiff( + result: PixelDiffResult, + maxDiffRatio: number, +): "pass" | Extract { + if (result.dimensionMismatch) return "dimension-mismatch"; + return result.diffRatio > maxDiffRatio ? "pixel-diff" : "pass"; +} + +function relativeToProject(projectDir: string, path: string): string { + const rel = relative(projectDir, path); + return rel.startsWith("..") || isAbsolute(rel) ? path : rel; +} + +function roundTime(time: number): number { + return Math.round(time * 1000) / 1000; +} + +interface GoldenCapture { + time: number; + timeMs: number; + png: Uint8Array; +} + +interface CapturedComposition { + compositionId: string; + times: number[]; + captures: GoldenCapture[]; + manifest: GoldenManifest | null; +} + +/** + * Open the composition once, resolve the sample times, and screenshot each. + * Font-localized bundling and the settled-page capture path mirror + * `hyperframes snapshot`, so goldens match what agents already review. + */ +async function captureGoldenFrames( + project: ProjectDir, + opts: GoldenGateOptions, +): Promise { + const { bundleWithLocalizedFonts } = await import("../utils/bundleWithLocalizedFonts.js"); + const html = await bundleWithLocalizedFonts(project.dir); + const server = await serveStaticProjectHtml(project.dir, html, undefined, [], opts.autoProxy); + try { + const { browser: chromeBrowser, page } = await openSettledCompositionPage(html, server.url, { + renderReadyTimeoutMs: opts.timeoutMs ?? 5000, + renderReadyWarningSuffix: "golden captures may be inaccurate", + browserGpuMode: opts.browserGpuMode, + }); + try { + const meta = await page.evaluate(() => { + const root = document.querySelector("[data-composition-id]"); + // Serialized into the page: the runtime attaches __player without typings. + const win = window as unknown as { __player?: { getDuration?: () => number } }; + const candidates = [ + Number(win.__player?.getDuration?.()), + Number.parseFloat(root?.getAttribute("data-duration") ?? ""), + ]; + return { + id: root?.getAttribute("data-composition-id") ?? "", + duration: candidates.find((d) => Number.isFinite(d) && d > 0) ?? 0, + }; + }); + + const compositionId = meta.id || project.name; + const manifest = readGoldenManifest(project.dir, compositionId); + const baselineTimesMs = listGoldenBaselineTimesMs(project.dir, compositionId); + + if (!opts.update && !manifest?.times?.length && baselineTimesMs.length === 0) { + throw new Error( + `No golden baselines found for "${compositionId}" (${GOLDEN_DIR_NAME}/${compositionId}/). ` + + "Create them with `hyperframes check --update-golden` or `hyperframes snapshot --update-golden`.", + ); + } + + const times = resolveGoldenSampleTimes({ + atOverride: opts.at, + manifestTimes: manifest?.times, + baselineTimesMs, + duration: meta.duration, + }).map(roundTime); + + const captures: GoldenCapture[] = []; + for (const time of times) { + await seekCompositionTimeline(page, time); + const png = await page.screenshot({ type: "png" }); + captures.push({ time, timeMs: timeMsFromSeconds(time), png }); + } + return { compositionId, times, captures, manifest }; + } finally { + await chromeBrowser.close(); + } + } finally { + await server.close(); + } +} + +function writeGoldenBaselines( + project: ProjectDir, + captured: CapturedComposition, + opts: GoldenGateOptions, +): GoldenSummary { + const dir = goldenCompositionDir(project.dir, captured.compositionId); + mkdirSync(dir, { recursive: true }); + + // Drop stale baselines whose time is no longer sampled — they would gate + // nothing but still look authoritative in review. + const keep = new Set(captured.captures.map((capture) => goldenTimeFileName(capture.timeMs))); + for (const file of readdirSync(dir)) { + if (parseGoldenTimeFileName(file) !== null && !keep.has(file)) { + rmSync(join(dir, file), { force: true }); + } + } + + const baselines: string[] = []; + for (const capture of captured.captures) { + const path = join(dir, goldenTimeFileName(capture.timeMs)); + writeFileSync(path, capture.png); + baselines.push(relativeToProject(project.dir, path)); + } + + const manifest: GoldenManifest = { + ...(captured.manifest ?? {}), + times: captured.times, + }; + if (opts.threshold !== undefined) manifest.threshold = opts.threshold; + writeFileSync(join(dir, GOLDEN_MANIFEST_NAME), `${JSON.stringify(manifest, null, 2)}\n`); + + return { + ok: true, + updated: true, + compositionId: captured.compositionId, + compared: captured.captures.length, + times: captured.times, + failed: [], + diffSheet: null, + baselines, + }; +} + +async function compareAgainstBaselines( + project: ProjectDir, + captured: CapturedComposition, + opts: GoldenGateOptions, +): Promise { + const config = resolveGoldenConfig(captured.manifest, { threshold: opts.threshold }); + const diffDir = join(project.dir, GOLDEN_DIFF_DIR_NAME, captured.compositionId); + rmSync(diffDir, { recursive: true, force: true }); + + const failed: GoldenFailure[] = []; + const baselines: string[] = []; + const sheetRows: { label: string; paths: string[] }[] = []; + + for (const capture of captured.captures) { + const baselinePath = goldenBaselinePath(project.dir, captured.compositionId, capture.timeMs); + baselines.push(relativeToProject(project.dir, baselinePath)); + + if (!existsSync(baselinePath)) { + failed.push({ + id: captured.compositionId, + time: capture.time, + timeMs: capture.timeMs, + maxDelta: 255, + diffRatio: 1, + reason: "missing-baseline", + }); + mkdirSync(diffDir, { recursive: true }); + writeFileSync(join(diffDir, `${capture.timeMs}-current.png`), capture.png); + continue; + } + + const result = await diffPngs(baselinePath, capture.png, { + threshold: config.threshold, + ignoreAntialiasing: config.ignoreAntialiasing, + }); + const verdict = evaluateGoldenDiff(result, config.maxDiffRatio); + if (verdict === "pass") continue; + + failed.push({ + id: captured.compositionId, + time: capture.time, + timeMs: capture.timeMs, + maxDelta: result.maxDelta, + diffRatio: Math.round(result.diffRatio * 1e6) / 1e6, + reason: verdict, + }); + + mkdirSync(diffDir, { recursive: true }); + const currentPath = join(diffDir, `${capture.timeMs}-current.png`); + const diffPath = join(diffDir, `${capture.timeMs}-diff.png`); + writeFileSync(currentPath, capture.png); + await writeRawImagePng( + { width: result.width, height: result.height, data: result.diff }, + diffPath, + ); + sheetRows.push({ + label: `t=${capture.time}s`, + paths: [baselinePath, currentPath, diffPath], + }); + } + + let diffSheet: string | null = null; + if (sheetRows.length > 0) { + const { createGoldenDiffContactSheet } = await import("../capture/contactSheet.js"); + const sheets = await createGoldenDiffContactSheet( + sheetRows.map((row) => ({ + label: row.label, + baselinePath: row.paths[0]!, + currentPath: row.paths[1]!, + diffPath: row.paths[2]!, + })), + join(diffDir, "contact-sheet.jpg"), + ); + diffSheet = sheets[0] ? relativeToProject(project.dir, sheets[0]) : null; + } + + return { + ok: failed.length === 0, + updated: false, + compositionId: captured.compositionId, + compared: captured.captures.length, + times: captured.times, + failed, + diffSheet, + baselines, + }; +} + +/** + * Run the golden baseline gate for a project: capture at the manifest / + * baseline sample times and pixel-diff against `golden//`, + * or refresh those baselines when `update` is set. + */ +export async function runGoldenGate( + project: ProjectDir, + opts: GoldenGateOptions = {}, +): Promise { + const captured = await captureGoldenFrames(project, opts); + if (opts.update) return writeGoldenBaselines(project, captured, opts); + return compareAgainstBaselines(project, captured, opts); +} diff --git a/packages/cli/src/golden/pixelDiff.test.ts b/packages/cli/src/golden/pixelDiff.test.ts new file mode 100644 index 0000000000..52d598e54c --- /dev/null +++ b/packages/cli/src/golden/pixelDiff.test.ts @@ -0,0 +1,177 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import sharp from "sharp"; +import { describe, expect, it } from "vitest"; +import { + decodeRawImage, + diffPngs, + diffRawImages, + writeRawImagePng, + type RawImage, +} from "./pixelDiff.js"; + +type Rgba = [number, number, number, number]; + +function solid(width: number, height: number, rgba: Rgba): RawImage { + const data = new Uint8Array(width * height * 4); + for (let offset = 0; offset < data.length; offset += 4) { + data.set(rgba, offset); + } + return { width, height, data }; +} + +function setPixel(image: RawImage, x: number, y: number, rgba: Rgba): void { + image.data.set(rgba, (y * image.width + x) * 4); +} + +function getPixel(image: { width: number; data: Uint8Array }, x: number, y: number): Rgba { + const offset = (y * image.width + x) * 4; + return [ + image.data[offset]!, + image.data[offset + 1]!, + image.data[offset + 2]!, + image.data[offset + 3]!, + ]; +} + +const WHITE: Rgba = [255, 255, 255, 255]; +const BLACK: Rgba = [0, 0, 0, 255]; + +describe("diffRawImages", () => { + it("reports zero differences for identical images", () => { + const a = solid(8, 6, [12, 34, 56, 255]); + const b = solid(8, 6, [12, 34, 56, 255]); + const result = diffRawImages(a, b); + expect(result.diffPixels).toBe(0); + expect(result.aaPixels).toBe(0); + expect(result.diffRatio).toBe(0); + expect(result.maxDelta).toBe(0); + expect(result.dimensionMismatch).toBe(false); + expect(result.totalPixels).toBe(48); + }); + + it("counts an isolated changed pixel and reports its channel delta", () => { + const a = solid(8, 8, WHITE); + const b = solid(8, 8, WHITE); + setPixel(b, 3, 4, [255, 0, 0, 255]); + const result = diffRawImages(a, b); + expect(result.diffPixels).toBe(1); + expect(result.maxDelta).toBe(255); + expect(result.diffRatio).toBeCloseTo(1 / 64, 10); + }); + + it("absorbs sub-threshold channel drift", () => { + const a = solid(4, 4, [100, 100, 100, 255]); + const b = solid(4, 4, [120, 100, 100, 255]); + // threshold 0.1 → tolerance 26 per channel, drift of 20 passes... + expect(diffRawImages(a, b, { threshold: 0.1 }).diffPixels).toBe(0); + // ...but a zero threshold counts every drifted pixel. + const strict = diffRawImages(a, b, { threshold: 0 }); + expect(strict.diffPixels).toBe(16); + expect(strict.maxDelta).toBe(20); + }); + + it("classifies a one-pixel edge shift as anti-aliasing and can be told not to", () => { + // Vertical black/white edge, shifted right by one column in `current`. + const baseline = solid(10, 10, WHITE); + const current = solid(10, 10, WHITE); + for (let y = 0; y < 10; y++) { + for (let x = 0; x < 4; x++) setPixel(baseline, x, y, BLACK); + for (let x = 0; x < 5; x++) setPixel(current, x, y, BLACK); + } + const lenient = diffRawImages(baseline, current); + expect(lenient.diffPixels).toBe(0); + expect(lenient.aaPixels).toBe(10); + + const strict = diffRawImages(baseline, current, { ignoreAntialiasing: false }); + expect(strict.diffPixels).toBe(10); + expect(strict.aaPixels).toBe(0); + }); + + it("does not classify a genuinely new color as anti-aliasing", () => { + const baseline = solid(10, 10, WHITE); + const current = solid(10, 10, WHITE); + // 3×3 red block: no white-image neighborhood ever contains red. + for (let y = 4; y < 7; y++) { + for (let x = 4; x < 7; x++) setPixel(current, x, y, [255, 0, 0, 255]); + } + const result = diffRawImages(baseline, current); + expect(result.diffPixels).toBe(9); + expect(result.aaPixels).toBe(0); + }); + + it("treats mismatched dimensions as a total failure", () => { + const a = solid(8, 8, WHITE); + const b = solid(9, 8, WHITE); + const result = diffRawImages(a, b); + expect(result.dimensionMismatch).toBe(true); + expect(result.diffRatio).toBe(1); + expect(result.maxDelta).toBe(255); + expect(result.width).toBe(8); + expect(result.height).toBe(8); + }); + + it("paints counted diffs red and unchanged pixels as a pale backdrop", () => { + const a = solid(6, 6, WHITE); + const b = solid(6, 6, WHITE); + setPixel(b, 2, 2, BLACK); + const result = diffRawImages(a, b); + expect(getPixel({ width: result.width, data: result.diff }, 2, 2)).toEqual([255, 0, 64, 255]); + const [r, g, bChan] = getPixel({ width: result.width, data: result.diff }, 0, 0); + expect(r).toBe(g); + expect(g).toBe(bChan); + expect(r).toBeGreaterThan(180); + }); +}); + +describe("PNG round trip", () => { + it("diffs PNG files and re-encodes the visualization losslessly", async () => { + const dir = mkdtempSync(join(tmpdir(), "hf-pixel-diff-test-")); + try { + const baselinePath = join(dir, "baseline.png"); + const currentPath = join(dir, "current.png"); + await sharp({ + create: { width: 12, height: 8, channels: 4, background: { r: 0, g: 0, b: 255, alpha: 1 } }, + }) + .png() + .toFile(baselinePath); + // Same canvas with a 4×4 red patch composited into the corner. + await sharp({ + create: { width: 12, height: 8, channels: 4, background: { r: 0, g: 0, b: 255, alpha: 1 } }, + }) + .composite([ + { + input: { + create: { + width: 4, + height: 4, + channels: 4, + background: { r: 255, g: 0, b: 0, alpha: 1 }, + }, + }, + left: 0, + top: 0, + }, + ]) + .png() + .toFile(currentPath); + + const result = await diffPngs(baselinePath, currentPath, { ignoreAntialiasing: false }); + expect(result.diffPixels).toBe(16); + expect(result.maxDelta).toBe(255); + + const diffPath = join(dir, "diff.png"); + await writeRawImagePng( + { width: result.width, height: result.height, data: result.diff }, + diffPath, + ); + const decoded = await decodeRawImage(diffPath); + expect(decoded.width).toBe(12); + expect(decoded.height).toBe(8); + expect(getPixel(decoded, 1, 1)).toEqual([255, 0, 64, 255]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/golden/pixelDiff.ts b/packages/cli/src/golden/pixelDiff.ts new file mode 100644 index 0000000000..c7a8d4835d --- /dev/null +++ b/packages/cli/src/golden/pixelDiff.ts @@ -0,0 +1,236 @@ +/** + * Pixel-level PNG comparison for the golden baseline gate. + * + * Pure diff math on raw RGBA buffers (unit-testable without Chrome or + * fixtures) plus thin sharp-backed encode/decode helpers. No new + * dependencies: sharp is already a CLI dependency for contact sheets. + */ + +import sharp from "sharp"; + +/** Decoded RGBA image (4 channels, row-major). */ +export interface RawImage { + width: number; + height: number; + data: Uint8Array; +} + +export interface PixelDiffOptions { + /** + * Per-channel intensity tolerance as a fraction of 255 (default 0.1). + * A pixel only counts as different when at least one channel deviates + * by more than `threshold * 255`. + */ + threshold?: number; + /** + * Treat 1px edge shifts as anti-aliasing noise and exclude them from the + * failure count (default true). A differing pixel is classified as + * anti-aliasing when each side finds a matching pixel for the other + * side's color within its own 8-neighborhood — the signature of font / + * shape rasterization jitter, not a layout or color regression. + */ + ignoreAntialiasing?: boolean; +} + +export interface PixelDiffResult { + width: number; + height: number; + totalPixels: number; + /** Differing pixels that count against the gate (anti-aliasing excluded). */ + diffPixels: number; + /** Differing pixels classified as anti-aliasing noise and ignored. */ + aaPixels: number; + /** diffPixels / totalPixels. */ + diffRatio: number; + /** Max per-channel delta (0-255) among counted differing pixels. */ + maxDelta: number; + /** Baseline and current image dimensions do not match. */ + dimensionMismatch: boolean; + /** RGBA visualization: dimmed baseline, red = diff, amber = ignored AA. */ + diff: Uint8Array; +} + +export const DEFAULT_DIFF_THRESHOLD = 0.1; + +const DIFF_COLOR = { r: 255, g: 0, b: 64 }; +const AA_COLOR = { r: 255, g: 196, b: 0 }; + +function channelDelta(a: RawImage, b: RawImage, aOffset: number, bOffset: number): number { + let max = 0; + for (let channel = 0; channel < 4; channel++) { + const delta = Math.abs(a.data[aOffset + channel]! - b.data[bOffset + channel]!); + if (delta > max) max = delta; + } + return max; +} + +/** True when some pixel in `img`'s 8-neighborhood of (x, y) matches the RGBA at `targetOffset` in `target` within `tolerance`. */ +function neighborhoodMatches( + img: RawImage, + x: number, + y: number, + target: RawImage, + targetOffset: number, + tolerance: number, +): boolean { + const left = Math.max(0, x - 1); + const right = Math.min(img.width - 1, x + 1); + const top = Math.max(0, y - 1); + const bottom = Math.min(img.height - 1, y + 1); + for (let ny = top; ny <= bottom; ny++) { + for (let nx = left; nx <= right; nx++) { + if (nx === x && ny === y) continue; + const offset = (ny * img.width + nx) * 4; + if (channelDelta(img, target, offset, targetOffset) <= tolerance) return true; + } + } + return false; +} + +function paint(diff: Uint8Array, offset: number, color: { r: number; g: number; b: number }): void { + diff[offset] = color.r; + diff[offset + 1] = color.g; + diff[offset + 2] = color.b; + diff[offset + 3] = 255; +} + +/** Pale grayscale rendering of the baseline pixel, so diffs pop against context. */ +function paintBackground(diff: Uint8Array, baseline: RawImage, offset: number): void { + const luma = Math.round( + 0.299 * baseline.data[offset]! + + 0.587 * baseline.data[offset + 1]! + + 0.114 * baseline.data[offset + 2]!, + ); + const dimmed = Math.round(luma * 0.25 + 190); + diff[offset] = dimmed; + diff[offset + 1] = dimmed; + diff[offset + 2] = dimmed; + diff[offset + 3] = 255; +} + +function dimensionMismatchResult(baseline: RawImage): PixelDiffResult { + const totalPixels = baseline.width * baseline.height; + const diff = new Uint8Array(totalPixels * 4); + for (let offset = 0; offset < diff.length; offset += 4) { + paint(diff, offset, DIFF_COLOR); + } + return { + width: baseline.width, + height: baseline.height, + totalPixels, + diffPixels: totalPixels, + aaPixels: 0, + diffRatio: 1, + maxDelta: 255, + dimensionMismatch: true, + diff, + }; +} + +interface PixelComparison { + diff: Uint8Array; + diffPixels: number; + aaPixels: number; + maxDelta: number; +} + +function comparePixels( + baseline: RawImage, + current: RawImage, + tolerance: number, + ignoreAntialiasing: boolean, +): PixelComparison { + const diff = new Uint8Array(baseline.width * baseline.height * 4); + const result: PixelComparison = { diff, diffPixels: 0, aaPixels: 0, maxDelta: 0 }; + + for (let y = 0; y < baseline.height; y++) { + for (let x = 0; x < baseline.width; x++) { + const offset = (y * baseline.width + x) * 4; + const delta = channelDelta(baseline, current, offset, offset); + if (delta <= tolerance) { + paintBackground(diff, baseline, offset); + continue; + } + const isAntialiasing = + ignoreAntialiasing && + neighborhoodMatches(baseline, x, y, current, offset, tolerance) && + neighborhoodMatches(current, x, y, baseline, offset, tolerance); + if (isAntialiasing) { + result.aaPixels++; + paint(diff, offset, AA_COLOR); + continue; + } + result.diffPixels++; + result.maxDelta = Math.max(result.maxDelta, delta); + paint(diff, offset, DIFF_COLOR); + } + } + return result; +} + +/** + * Compare two RGBA images of equal dimensions. Baseline dimensions win for + * the visualization; mismatched dimensions are reported as a total failure + * (a resized canvas is always a regression, not a pixel drift). + */ +export function diffRawImages( + baseline: RawImage, + current: RawImage, + options: PixelDiffOptions = {}, +): PixelDiffResult { + if (baseline.width !== current.width || baseline.height !== current.height) { + return dimensionMismatchResult(baseline); + } + + const threshold = options.threshold ?? DEFAULT_DIFF_THRESHOLD; + const tolerance = Math.round(Math.max(0, Math.min(1, threshold)) * 255); + const totalPixels = baseline.width * baseline.height; + const compared = comparePixels( + baseline, + current, + tolerance, + options.ignoreAntialiasing !== false, + ); + + return { + width: baseline.width, + height: baseline.height, + totalPixels, + diffPixels: compared.diffPixels, + aaPixels: compared.aaPixels, + diffRatio: totalPixels === 0 ? 0 : compared.diffPixels / totalPixels, + maxDelta: compared.maxDelta, + dimensionMismatch: false, + diff: compared.diff, + }; +} + +/** Decode a PNG file path or buffer into flat RGBA. */ +export async function decodeRawImage(input: string | Uint8Array): Promise { + const { data, info } = await sharp(input).ensureAlpha().raw().toBuffer({ + resolveWithObject: true, + }); + return { width: info.width, height: info.height, data: new Uint8Array(data) }; +} + +/** Encode flat RGBA back into a PNG file. */ +export async function writeRawImagePng(image: RawImage, outputPath: string): Promise { + await sharp(Buffer.from(image.data.buffer, image.data.byteOffset, image.data.byteLength), { + raw: { width: image.width, height: image.height, channels: 4 }, + }) + .png() + .toFile(outputPath); +} + +/** Convenience wrapper: decode two PNGs (path or buffer) and diff them. */ +export async function diffPngs( + baseline: string | Uint8Array, + current: string | Uint8Array, + options: PixelDiffOptions = {}, +): Promise { + const [baselineRaw, currentRaw] = await Promise.all([ + decodeRawImage(baseline), + decodeRawImage(current), + ]); + return diffRawImages(baselineRaw, currentRaw, options); +} From c68ffcf2f4b177c4d760a2614a2329d4006f74f3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 01:15:02 +0000 Subject: [PATCH 2/4] docs: golden baseline gate reference and visual regression guide - reference/cli-golden: directory convention, flags, golden.json manifest fields, failure artifacts, and the agent-readable JSON summary - guides/visual-regression: the commit-baselines workflow, agent loop usage, CI wiring, determinism guidance, and sample-time selection --- docs/docs.json | 6 +- docs/guides/visual-regression.mdx | 140 ++++++++++++++++++++++++++++++ docs/reference/cli-golden.mdx | 126 +++++++++++++++++++++++++++ 3 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 docs/guides/visual-regression.mdx create mode 100644 docs/reference/cli-golden.mdx diff --git a/docs/docs.json b/docs/docs.json index f2744d4f09..ea9f1f7217 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -780,7 +780,8 @@ "pages": [ "developers/cli", "packages/cli", - "packages/lint" + "packages/lint", + "guides/visual-regression" ] }, { @@ -825,7 +826,8 @@ "pages": [ "reference/html-schema", "reference/color-grading", - "reference/audio-effects" + "reference/audio-effects", + "reference/cli-golden" ] }, { diff --git a/docs/guides/visual-regression.mdx b/docs/guides/visual-regression.mdx new file mode 100644 index 0000000000..5b12cdea57 --- /dev/null +++ b/docs/guides/visual-regression.mdx @@ -0,0 +1,140 @@ +--- +title: "Visual regression testing" +description: "Commit golden baseline frames and let hyperframes check --golden fail the build when a composition's pixels regress." +--- + +Agents (and humans) regress layouts silently: a refactor nudges a headline, a +token change recolors a button, a timing tweak leaves a beat blank — and every +individual command still exits 0. The golden baseline gate turns "the video +still looks right" into a hard, committable contract: reference frames live in +the repo, and `hyperframes check --golden` fails whenever the rendered pixels +drift from them. + +This guide covers the workflow. For the exact directory convention, manifest +fields, and JSON shapes, see the [golden baseline reference](/reference/cli-golden). + +## The loop + +```bash +# 1. From a state you have visually reviewed, freeze the baselines. +hyperframes check --update-golden + +# 2. Commit them — they are small PNGs, one per sample time. +git add golden/ && git commit -m "test: golden baselines for intro" + +# 3. From now on, gate every change. +hyperframes check --golden +``` + +The gate re-captures the composition at the committed sample times (same +font-localized, settled-page capture path as `hyperframes snapshot`), +pixel-diffs each frame against its baseline, and: + +- **Pass** — every frame matches within tolerance; exit code 0. +- **Fail** — the failing times are listed with how much differs, a + `golden-diff/` folder appears with per-frame red-highlight diff PNGs and a + `baseline | current | diff` contact sheet, and the exit code is 1. + +```text +Golden + ✗ t=2s 0.949% pixels differ (max channel delta 130) + 1 of 3 frame(s) regressed vs golden/golden-baseline-demo/ + Diff sheet: golden-diff/golden-baseline-demo/contact-sheet.jpg + Intended change? Refresh baselines with hyperframes check --update-golden +``` + +An intentional change is a one-command ritual: re-run with `--update-golden`, +review, and commit the refreshed PNGs. The diff shows up in the PR as an image +diff, so reviewers see exactly what changed on screen. + +## For agent loops + +Add `--json` and read the summary instead of parsing logs: + +```bash +hyperframes check --golden --json +``` + +```json +{ "ok": false, "golden": { "failed": [{ "id": "intro", "time": 2, "maxDelta": 130 }], "diffSheet": "golden-diff/intro/contact-sheet.jpg" } } +``` + +The `diffSheet` is a single image an agent can open to see every regression at +once — baseline, current, and highlighted diff side by side — before deciding +whether to fix the code or refresh the baseline. + +## CI wiring + +The gate is one exit code, so any CI is a few lines: + +```yaml +# .github/workflows/visual-regression.yml +name: visual-regression +on: [pull_request] +jobs: + golden: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install + - run: npx hyperframes check --golden --no-browser-gpu + working-directory: my-composition + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: golden-diff + path: my-composition/golden-diff/ +``` + +Uploading `golden-diff/` on failure gives reviewers the contact sheet without +re-running anything locally. + +## Keeping captures deterministic + +Pixel equality is only meaningful when the two captures come from the same +rendering stack: + +- **Capture baselines where you gate.** Create and refresh baselines on the + same OS/runner class that CI uses. A macOS-made baseline gated on Linux will + differ in font rasterization. +- **Pin the GPU path.** Pass `--no-browser-gpu` in CI (and when refreshing + baselines) to force SwiftShader's deterministic software rendering. +- **Follow the composition determinism rules** — no `Date.now()`, no unseeded + randomness, no render-time network fetches. Remote fonts are localized + automatically during capture, matching the render path. + +If your environment still produces benign single-pixel jitter, tune rather than +abandon the gate: `ignoreAntialiasing` (on by default) absorbs 1px edge shifts, +`threshold` widens the per-channel tolerance, and `maxDiffRatio` grants a small +pixel budget. All three live in `golden//golden.json` — see the +[reference](/reference/cli-golden#the-goldenjson-manifest). + +## Choosing sample times + +`--update-golden` defaults to five evenly spaced frames with a readable +end-of-timeline tail. For most compositions, better times are the ones a +reviewer would screenshot: the settled end of each beat, not mid-transition. +Pin them explicitly: + +```bash +hyperframes snapshot --update-golden --at 0.5,2,3.5 +``` + +Mid-transition times work — the capture is seek-exact — but they make every +retiming of the animation an (intentional) baseline refresh. + +## Try it + +A runnable example with committed baselines lives at +[`examples/golden-baseline`](https://github.com/heygen-com/hyperframes/tree/main/examples/golden-baseline): +break the accent color, watch the gate fail with a diff sheet, refresh, commit. + +## Related topics + +- [Golden baseline reference](/reference/cli-golden) — the exact directory + convention, every flag and manifest field, and the JSON summary shape. +- [Deterministic rendering](/concepts/determinism) — the composition rules that + make pixel equality meaningful in the first place. +- [Render from the command line](/guides/rendering) — the wider CLI loop the + gate slots into. diff --git a/docs/reference/cli-golden.mdx b/docs/reference/cli-golden.mdx new file mode 100644 index 0000000000..8d64569e2b --- /dev/null +++ b/docs/reference/cli-golden.mdx @@ -0,0 +1,126 @@ +--- +title: "Golden baseline gate" +sidebarTitle: "Golden baselines" +description: "The golden/ directory convention, the check --golden and --update-golden flags, the golden.json manifest, the diff artifacts, and the JSON summary agents read." +--- + +Use this reference for the exact contract of the golden baseline regression +gate: where baselines live, every flag and manifest field, what the gate writes +on failure, and the JSON shape agents consume. For the workflow — when to +commit baselines and how to wire the gate into CI — start with +[Visual regression testing](/guides/visual-regression). + +## The convention + +Committed reference frames live beside the composition: + +``` +/ + index.html + golden/ + / # from the root's data-composition-id + golden.json # optional manifest + .png # one baseline per sample time (e.g. 1500.png = t=1.5s) +``` + +Filenames are the sample time in **milliseconds** (`Math.round(seconds * 1000)`). +Anything that does not match `.png` is ignored by the scanner, so the +manifest and stray notes never collide with baselines. + +## Commands + +```bash +hyperframes check --golden # gate: re-capture, pixel-diff, fail on regression +hyperframes check --update-golden # refresh baselines (runs the full check too) +hyperframes snapshot --update-golden # refresh baselines only, no check pipeline +hyperframes check --golden --json # agent-readable envelope +``` + +| Flag | Command | Meaning | +| --------------------------- | ----------------- | -------------------------------------------------------------------------------------------------- | +| `--golden` | `check` | Run the gate after the normal check pipeline; either failing makes the exit code non-zero. | +| `--update-golden` | `check`, `snapshot` | Capture at the resolved sample times and (re)write `golden//` plus its manifest. | +| `--golden-threshold <0-1>` | `check` | Per-channel pixel tolerance override for this run (does not enable the gate by itself). | +| `--at ` | `snapshot` | With `--update-golden`: explicit sample times in seconds, written into the manifest. | + +Sample times resolve in precedence order: explicit `--at` (snapshot only) → +manifest `times` → times encoded in existing baseline filenames → the snapshot +default spread (5 evenly spaced frames with a readable end-of-timeline tail). + +`check --golden` with no baselines and no manifest fails with instructions to +run `--update-golden` first — a missing baseline is never silently a pass. + +## The `golden.json` manifest + +```json +{ + "times": [0.5, 2, 3.5], + "threshold": 0.1, + "maxDiffRatio": 0, + "ignoreAntialiasing": true +} +``` + +| Field | Default | Meaning | +| -------------------- | ------- | -------------------------------------------------------------------------------------------------------------- | +| `times` | — | Timeline sample times in seconds. Written automatically by `--update-golden`. | +| `threshold` | `0.1` | Per-channel intensity tolerance as a fraction of 255; a pixel differs only when a channel deviates more. | +| `maxDiffRatio` | `0` | Fraction of differing pixels allowed before the gate fails. `0` = fail on any counted diff. | +| `ignoreAntialiasing` | `true` | Exclude 1px rasterization edge shifts (each side finds the other's color within its own 8-neighborhood). | + +A dimension change (resized canvas) always fails, regardless of `maxDiffRatio`. + +## Failure artifacts + +On regression the gate writes `golden-diff//` in the project: + +``` +golden-diff// + -current.png # what the composition renders now + -diff.png # red = counted diff, amber = ignored anti-aliasing, pale gray = unchanged + contact-sheet.jpg # one baseline | current | diff row per failed time +``` + +The directory is cleared at the start of every gate run and only re-created on +failure. Add `golden-diff/` to your `.gitignore` — it is derived output. + +## JSON summary + +With `check --golden --json`, the golden summary rides inside the normal check +envelope, and the top-level `ok` is false when either the pipeline or the gate +fails: + +```json +{ + "ok": false, + "golden": { + "ok": false, + "updated": false, + "compositionId": "golden-baseline-demo", + "compared": 3, + "times": [0.5, 2, 3.5], + "failed": [ + { + "id": "golden-baseline-demo", + "time": 2, + "timeMs": 2000, + "maxDelta": 130, + "diffRatio": 0.009488, + "reason": "pixel-diff" + } + ], + "diffSheet": "golden-diff/golden-baseline-demo/contact-sheet.jpg", + "baselines": ["golden/golden-baseline-demo/500.png", "golden/golden-baseline-demo/2000.png"] + } +} +``` + +`reason` is one of `pixel-diff`, `dimension-mismatch`, or `missing-baseline`. +`maxDelta` is the largest per-channel deviation (0–255) among counted differing +pixels; `diffRatio` is the differing fraction of all pixels. + +## Example + +A complete runnable project lives at +[`examples/golden-baseline`](https://github.com/heygen-com/hyperframes/tree/main/examples/golden-baseline) +with committed baselines and a scripted regression to try. From 744a1cb8d4f93690f8984f5f78fe289b001400d9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 01:15:03 +0000 Subject: [PATCH 3/4] feat(examples): golden-baseline example with committed baselines A minimal 640x360 composition plus committed golden/ baselines and a manifest, demonstrating the check --golden / --update-golden loop and a scripted regression to try. Un-ignores the new example directory. --- .gitignore | 2 + examples/golden-baseline/.gitignore | 4 ++ examples/golden-baseline/README.md | 92 +++++++++++++++++++++++++++ examples/golden-baseline/index.html | 97 +++++++++++++++++++++++++++++ 4 files changed, 195 insertions(+) create mode 100644 examples/golden-baseline/.gitignore create mode 100644 examples/golden-baseline/README.md create mode 100644 examples/golden-baseline/index.html diff --git a/.gitignore b/.gitignore index 64dc7b4b14..2317e928aa 100644 --- a/.gitignore +++ b/.gitignore @@ -99,6 +99,8 @@ examples/* !examples/gcp-cloud-run/** !examples/docs-reference-project !examples/docs-reference-project/** +!examples/golden-baseline +!examples/golden-baseline/** # …but never the local smoke run's build/render artifacts. examples/gcp-cloud-run/scripts/gcp-smoke-artifacts/ packages/studio/data/ diff --git a/examples/golden-baseline/.gitignore b/examples/golden-baseline/.gitignore new file mode 100644 index 0000000000..b57db5e0f4 --- /dev/null +++ b/examples/golden-baseline/.gitignore @@ -0,0 +1,4 @@ +# Derived verification artifacts — regenerated by `hyperframes check --golden` +# and `hyperframes snapshot`; only golden/ baselines are meant to be committed. +golden-diff/ +snapshots/ diff --git a/examples/golden-baseline/README.md b/examples/golden-baseline/README.md new file mode 100644 index 0000000000..0931ef371f --- /dev/null +++ b/examples/golden-baseline/README.md @@ -0,0 +1,92 @@ +# Golden baseline regression gate + +A minimal project showing the `hyperframes check --golden` workflow: commit +reference frames ("golden baselines") of a composition, then let CI — or an +AI agent's own verification loop — fail loudly whenever a change moves pixels +it was not supposed to move. + +## Layout + +``` +examples/golden-baseline/ + index.html # 640x360, 4s composition + golden/ + golden-baseline-demo/ # + golden.json # sample times + diff tuning (optional) + 500.png # baseline at t=0.5s (.png) + 2000.png # baseline at t=2.0s + 3500.png # baseline at t=3.5s +``` + +Baselines follow the convention `golden//.png`. The +`golden.json` manifest pins the sample times and can tune the diff: + +```json +{ + "times": [0.5, 2, 3.5], + "threshold": 0.1, + "maxDiffRatio": 0, + "ignoreAntialiasing": true +} +``` + +- `times` — timeline sample times in seconds. +- `threshold` — per-channel pixel tolerance as a fraction of 255 (default 0.1). +- `maxDiffRatio` — fraction of differing pixels allowed before the gate fails + (default 0: fail on any counted diff). +- `ignoreAntialiasing` — exclude 1px rasterization edge shifts from the count + (default true). + +## Workflow + +```bash +cd examples/golden-baseline + +# 1. Create (or refresh) the baselines from a state you have reviewed, then +# commit golden/ to the repo. +npx hyperframes check --update-golden # or: npx hyperframes snapshot --update-golden + +# 2. Gate every subsequent change. Exits non-zero on any regression. +npx hyperframes check --golden + +# 3. Agent-readable result (the golden summary rides inside the check report). +npx hyperframes check --golden --json +``` + +On failure the gate writes `golden-diff//` containing the +current frame, a red-highlight diff PNG per failed time, and a +`contact-sheet.jpg` with one `baseline | current | diff` row per failure — +one image an agent (or a human) can read to see exactly what moved. + +The JSON envelope includes: + +```json +{ + "ok": false, + "golden": { + "ok": false, + "failed": [ + { + "id": "golden-baseline-demo", + "time": 2, + "maxDelta": 210, + "diffRatio": 0.0042, + "reason": "pixel-diff" + } + ], + "diffSheet": "golden-diff/golden-baseline-demo/contact-sheet.jpg" + } +} +``` + +## Try a regression + +Change `#accent-bar`'s `background-color` in `index.html` (say `#f5a623` → +`#e0245e`) and run `npx hyperframes check --golden` — the gate fails, names the +regressed times, and writes the diff sheet. If the change was intentional, +refresh with `--update-golden` and commit the new baselines. + +Deterministic rendering matters here: capture baselines and gate on the same +rendering stack (CI runner, `--no-browser-gpu` for SwiftShader determinism if +your machines differ). See the visual regression guide in the docs for CI +wiring. diff --git a/examples/golden-baseline/index.html b/examples/golden-baseline/index.html new file mode 100644 index 0000000000..1b0f125349 --- /dev/null +++ b/examples/golden-baseline/index.html @@ -0,0 +1,97 @@ + + + + + + Golden Baseline Demo + + + + +
+
+
+
GOLDEN GATE
+
pixel-diffed against committed baselines
+
+
+
+ + + + From 26f070779dec2126497559f0f6786848d2a03698 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 01:17:34 +0000 Subject: [PATCH 4/4] fix(examples): commit the golden baselines under a non-ignored id The repo-wide '*-demo/' ignore rule silently swallowed golden/golden-baseline-demo/, so the example shipped without its baselines. Rename the composition id to golden-baseline and commit the three baseline PNGs plus the manifest. --- docs/guides/visual-regression.mdx | 4 ++-- docs/reference/cli-golden.mdx | 8 ++++---- examples/golden-baseline/README.md | 6 +++--- .../golden/golden-baseline/2000.png | Bin 0 -> 13530 bytes .../golden/golden-baseline/3500.png | Bin 0 -> 13548 bytes .../golden/golden-baseline/500.png | Bin 0 -> 7058 bytes .../golden/golden-baseline/golden.json | 3 +++ examples/golden-baseline/index.html | 4 ++-- 8 files changed, 14 insertions(+), 11 deletions(-) create mode 100644 examples/golden-baseline/golden/golden-baseline/2000.png create mode 100644 examples/golden-baseline/golden/golden-baseline/3500.png create mode 100644 examples/golden-baseline/golden/golden-baseline/500.png create mode 100644 examples/golden-baseline/golden/golden-baseline/golden.json diff --git a/docs/guides/visual-regression.mdx b/docs/guides/visual-regression.mdx index 5b12cdea57..c871a44226 100644 --- a/docs/guides/visual-regression.mdx +++ b/docs/guides/visual-regression.mdx @@ -38,8 +38,8 @@ pixel-diffs each frame against its baseline, and: ```text Golden ✗ t=2s 0.949% pixels differ (max channel delta 130) - 1 of 3 frame(s) regressed vs golden/golden-baseline-demo/ - Diff sheet: golden-diff/golden-baseline-demo/contact-sheet.jpg + 1 of 3 frame(s) regressed vs golden/golden-baseline/ + Diff sheet: golden-diff/golden-baseline/contact-sheet.jpg Intended change? Refresh baselines with hyperframes check --update-golden ``` diff --git a/docs/reference/cli-golden.mdx b/docs/reference/cli-golden.mdx index 8d64569e2b..94f7fe600a 100644 --- a/docs/reference/cli-golden.mdx +++ b/docs/reference/cli-golden.mdx @@ -96,12 +96,12 @@ fails: "golden": { "ok": false, "updated": false, - "compositionId": "golden-baseline-demo", + "compositionId": "golden-baseline", "compared": 3, "times": [0.5, 2, 3.5], "failed": [ { - "id": "golden-baseline-demo", + "id": "golden-baseline", "time": 2, "timeMs": 2000, "maxDelta": 130, @@ -109,8 +109,8 @@ fails: "reason": "pixel-diff" } ], - "diffSheet": "golden-diff/golden-baseline-demo/contact-sheet.jpg", - "baselines": ["golden/golden-baseline-demo/500.png", "golden/golden-baseline-demo/2000.png"] + "diffSheet": "golden-diff/golden-baseline/contact-sheet.jpg", + "baselines": ["golden/golden-baseline/500.png", "golden/golden-baseline/2000.png"] } } ``` diff --git a/examples/golden-baseline/README.md b/examples/golden-baseline/README.md index 0931ef371f..94391ad31a 100644 --- a/examples/golden-baseline/README.md +++ b/examples/golden-baseline/README.md @@ -11,7 +11,7 @@ it was not supposed to move. examples/golden-baseline/ index.html # 640x360, 4s composition golden/ - golden-baseline-demo/ # + golden-baseline/ # golden.json # sample times + diff tuning (optional) 500.png # baseline at t=0.5s (.png) 2000.png # baseline at t=2.0s @@ -67,14 +67,14 @@ The JSON envelope includes: "ok": false, "failed": [ { - "id": "golden-baseline-demo", + "id": "golden-baseline", "time": 2, "maxDelta": 210, "diffRatio": 0.0042, "reason": "pixel-diff" } ], - "diffSheet": "golden-diff/golden-baseline-demo/contact-sheet.jpg" + "diffSheet": "golden-diff/golden-baseline/contact-sheet.jpg" } } ``` diff --git a/examples/golden-baseline/golden/golden-baseline/2000.png b/examples/golden-baseline/golden/golden-baseline/2000.png new file mode 100644 index 0000000000000000000000000000000000000000..522b2768fa12d12b5fc04c1ae952c3ca92c1e0d2 GIT binary patch literal 13530 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 literal 0 HcmV?d00001 diff --git a/examples/golden-baseline/golden/golden-baseline/3500.png b/examples/golden-baseline/golden/golden-baseline/3500.png new file mode 100644 index 0000000000000000000000000000000000000000..143f0ba3cf9fe2644ec794f04e1bcd7f5a5d612e GIT binary patch literal 13548 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

@P}T~-CsVz)1hloPEYLDObj;D~Nz#q| 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 KD8KdK*?$7RG#uUl literal 0 HcmV?d00001 diff --git a/examples/golden-baseline/golden/golden-baseline/golden.json b/examples/golden-baseline/golden/golden-baseline/golden.json new file mode 100644 index 0000000000..a294c2109e --- /dev/null +++ b/examples/golden-baseline/golden/golden-baseline/golden.json @@ -0,0 +1,3 @@ +{ + "times": [0.5, 2, 3.5] +} diff --git a/examples/golden-baseline/index.html b/examples/golden-baseline/index.html index 1b0f125349..9e25fce50a 100644 --- a/examples/golden-baseline/index.html +++ b/examples/golden-baseline/index.html @@ -68,7 +68,7 @@