mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 10:46:06 +00:00
feat(cli): snapshot --zoom and per-finding crops on check --snapshots
snapshot --zoom <selector|x,y,w,h> + --zoom-scale (default 3) crops via Puppeteer clip at raised deviceScaleFactor — density changes, layout never does. Selector resolves per frame with 24px padding; no match is a loud error, and a frame whose clamped region is a sliver (element collapsed or animated off-canvas) is skipped with a stderr note rather than written as a useless few-pixel image. check --snapshots additionally writes finding-NN-<code>.png crops for error findings with bboxes (cap 12, deterministic re-seek in a second session) and draws labeled annotation boxes on overview frames via a transient overlay injected only after audits complete. Skill reference gains the zoom workflow: check reports a finding, zoom into it, fix, re-check.
This commit is contained in:
@@ -13,15 +13,18 @@ import { createCheckCommand } from "./check.js";
|
||||
import {
|
||||
DEFAULT_CHECK_OPTIONS,
|
||||
checkExitCode,
|
||||
findingCropFilename,
|
||||
runAuditGrid,
|
||||
runCheckPipeline,
|
||||
selectContrastTimes,
|
||||
selectFindingCropRequests,
|
||||
type AnchoredLayoutIssue,
|
||||
type CheckAnchor,
|
||||
type CheckAuditDriver,
|
||||
type CheckBrowserResult,
|
||||
type CheckDependencies,
|
||||
type CheckFinding,
|
||||
type CheckFindingCropRequest,
|
||||
type CheckOptions,
|
||||
type CheckReport,
|
||||
type ContrastAuditEntry,
|
||||
@@ -29,7 +32,12 @@ import {
|
||||
} from "../utils/checkPipeline.js";
|
||||
import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport.js";
|
||||
import type { ProjectLintResult } from "../utils/lintProject.js";
|
||||
import type { LayoutIssue, LayoutOverflow, LayoutRect } from "../utils/layoutAudit.js";
|
||||
import type {
|
||||
LayoutIssue,
|
||||
LayoutIssueCode,
|
||||
LayoutOverflow,
|
||||
LayoutRect,
|
||||
} from "../utils/layoutAudit.js";
|
||||
import type { ProjectDir } from "../utils/project.js";
|
||||
|
||||
const PROJECT: ProjectDir = {
|
||||
@@ -241,6 +249,7 @@ function dependencies(
|
||||
motion?: MotionSpecResolution;
|
||||
runtime?: CheckFinding[];
|
||||
writeSnapshot?: CheckDependencies["writeSnapshot"];
|
||||
captureFindingCrops?: CheckDependencies["captureFindingCrops"];
|
||||
} = {},
|
||||
): { deps: CheckDependencies; runBrowserCheck: ReturnType<typeof vi.fn> } {
|
||||
const runBrowserCheck = vi.fn(
|
||||
@@ -264,6 +273,7 @@ function dependencies(
|
||||
`snapshots/frame-${String(index).padStart(2, "0")}-at-${time.toFixed(1)}s.png`,
|
||||
),
|
||||
),
|
||||
captureFindingCrops: options.captureFindingCrops ?? vi.fn(async () => []),
|
||||
};
|
||||
return { deps, runBrowserCheck };
|
||||
}
|
||||
@@ -655,6 +665,115 @@ it("keeps contrast and snapshot sampling on the pre-gate layout grid", async ()
|
||||
expect(collectContrast).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
function layoutFindingOf(
|
||||
code: LayoutIssueCode,
|
||||
severity: "error" | "warning" | "info",
|
||||
bbox: { x: number; y: number; width: number; height: number },
|
||||
time = 1,
|
||||
): AnchoredLayoutIssue {
|
||||
return {
|
||||
code,
|
||||
severity,
|
||||
message: code,
|
||||
...anchor("#el", time),
|
||||
bbox,
|
||||
rect: {
|
||||
left: bbox.x,
|
||||
top: bbox.y,
|
||||
right: bbox.x + bbox.width,
|
||||
bottom: bbox.y + bbox.height,
|
||||
...bbox,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function checkFindingOf(
|
||||
code: string,
|
||||
severity: "error" | "warning" | "info",
|
||||
bbox: { x: number; y: number; width: number; height: number },
|
||||
time = 1,
|
||||
): CheckFinding {
|
||||
return { code, severity, message: code, ...anchor("#el", time), bbox };
|
||||
}
|
||||
|
||||
function emptySection<T extends CheckFinding>(findings: T[] = []) {
|
||||
return { ok: true, errorCount: 0, warningCount: 0, infoCount: 0, findings };
|
||||
}
|
||||
|
||||
function reportWithFindings(overrides: Partial<CheckReport> = {}): CheckReport {
|
||||
return {
|
||||
ok: true,
|
||||
strict: false,
|
||||
lint: { ...emptySection(), filesScanned: 0 },
|
||||
runtime: emptySection(),
|
||||
layout: {
|
||||
...emptySection(),
|
||||
duration: 10,
|
||||
samples: [],
|
||||
transitionSamples: [],
|
||||
transitionSamplesDropped: 0,
|
||||
tolerance: 2,
|
||||
totalIssueCount: 0,
|
||||
truncated: false,
|
||||
},
|
||||
motion: { ...emptySection(), enabled: false, samples: 0 },
|
||||
contrast: { ...emptySection(), enabled: true, samples: [], checked: 0, passed: 0 },
|
||||
snapshots: { enabled: false, files: [], times: [], findingFiles: [] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("selectFindingCropRequests", () => {
|
||||
const NON_ZERO_BBOX = { x: 10, y: 20, width: 100, height: 50 };
|
||||
const ZERO_BBOX = { x: 0, y: 0, width: 0, height: 0 };
|
||||
|
||||
it("filenames a request finding-NN-<code>.png with the finding's time and bbox", () => {
|
||||
const report = reportWithFindings({
|
||||
layout: {
|
||||
...reportWithFindings().layout,
|
||||
findings: [layoutFindingOf("clipped_text", "error", NON_ZERO_BBOX, 2.5)],
|
||||
},
|
||||
});
|
||||
|
||||
expect(selectFindingCropRequests(report)).toEqual([
|
||||
{ filename: "finding-00-clipped_text.png", time: 2.5, bbox: NON_ZERO_BBOX },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips warnings/info and findings without a real bbox", () => {
|
||||
const report = reportWithFindings({
|
||||
layout: {
|
||||
...reportWithFindings().layout,
|
||||
findings: [
|
||||
layoutFindingOf("content_overlap", "warning", NON_ZERO_BBOX),
|
||||
layoutFindingOf("clipped_text", "error", ZERO_BBOX),
|
||||
],
|
||||
},
|
||||
runtime: { ...emptySection([checkFindingOf("console_error", "info", NON_ZERO_BBOX)]) },
|
||||
});
|
||||
|
||||
expect(selectFindingCropRequests(report)).toEqual([]);
|
||||
});
|
||||
|
||||
it("caps at 12 requests across sections", () => {
|
||||
const findings = Array.from({ length: 15 }, (_, index) =>
|
||||
checkFindingOf(`code_${index}`, "error", NON_ZERO_BBOX, index),
|
||||
);
|
||||
const report = reportWithFindings({
|
||||
runtime: { ...emptySection(findings) },
|
||||
});
|
||||
|
||||
const requests = selectFindingCropRequests(report);
|
||||
expect(requests).toHaveLength(12);
|
||||
expect(requests[0]?.filename).toBe(findingCropFilename(0, "code_0"));
|
||||
expect(requests[11]?.filename).toBe(findingCropFilename(11, "code_11"));
|
||||
});
|
||||
|
||||
it("sanitizes unusual characters out of the code when building a filename", () => {
|
||||
expect(findingCropFilename(3, "weird code/name")).toBe("finding-03-weird_code_name.png");
|
||||
});
|
||||
});
|
||||
|
||||
describe("check pipeline", () => {
|
||||
const originalExitCode = process.exitCode;
|
||||
|
||||
@@ -833,6 +952,47 @@ describe("check pipeline", () => {
|
||||
expect(absentWriter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("captures finding crops for error findings with bboxes only when --snapshots is set", async () => {
|
||||
const capture = vi.fn(
|
||||
async (
|
||||
_project: ProjectDir,
|
||||
_options: CheckOptions,
|
||||
_requests: CheckFindingCropRequest[],
|
||||
) => ["snapshots/finding-00-clipped_text.png"],
|
||||
);
|
||||
const { report } = await runScenario(
|
||||
fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue()]) }),
|
||||
{ snapshots: true },
|
||||
{ captureFindingCrops: capture },
|
||||
);
|
||||
|
||||
expect(capture).toHaveBeenCalledTimes(1);
|
||||
expect(capture.mock.calls[0]?.[2]).toEqual([
|
||||
{
|
||||
filename: "finding-00-clipped_text.png",
|
||||
time: 0.5,
|
||||
bbox: { x: 10, y: 20, width: 300, height: 80 },
|
||||
},
|
||||
]);
|
||||
expect(report.snapshots.findingFiles).toEqual(["snapshots/finding-00-clipped_text.png"]);
|
||||
|
||||
const withoutSnapshots = vi.fn(async () => ["unused.png"]);
|
||||
await runScenario(
|
||||
fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue()]) }),
|
||||
{ snapshots: false },
|
||||
{ captureFindingCrops: withoutSnapshots },
|
||||
);
|
||||
expect(withoutSnapshots).not.toHaveBeenCalled();
|
||||
|
||||
const noErrors = vi.fn(async () => ["unused.png"]);
|
||||
await runScenario(
|
||||
fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue("warning")]) }),
|
||||
{ snapshots: true },
|
||||
{ captureFindingCrops: noErrors },
|
||||
);
|
||||
expect(noErrors).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("--strict flips a warnings-only result from exit 0 to exit 1", async () => {
|
||||
const warningDriver = () =>
|
||||
fakeDriver({ collectLayout: vi.fn(async () => [layoutIssue("warning")]) });
|
||||
|
||||
@@ -339,6 +339,12 @@ function printSnapshotSection(report: CheckReport): void {
|
||||
} else {
|
||||
console.log(` ${c.success("◇")} ${report.snapshots.files.length} PNG(s) saved`);
|
||||
for (const file of report.snapshots.files) console.log(` ${c.dim(file)}`);
|
||||
if (report.snapshots.findingFiles.length > 0) {
|
||||
console.log(
|
||||
` ${c.success("◇")} ${report.snapshots.findingFiles.length} finding crop(s) saved`,
|
||||
);
|
||||
for (const file of report.snapshots.findingFiles) console.log(` ${c.dim(file)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeSnapshotTimes, tailFrameTime } from "./snapshot.js";
|
||||
import { computeSnapshotTimes, parseZoomScale, 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
|
||||
// ../capture/captureCompositionFrame.test.ts alongside its implementation.
|
||||
|
||||
describe("tailFrameTime", () => {
|
||||
it("backs off ~3% of duration so the final frame isn't the blank exact-end", () => {
|
||||
@@ -59,3 +63,19 @@ describe("computeSnapshotTimes (FINDING [7]: tail is always captured)", () => {
|
||||
expect(appendedTail).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseZoomScale (--zoom-scale)", () => {
|
||||
it("defaults to 3 when unset", () => {
|
||||
expect(parseZoomScale(undefined)).toBe(3);
|
||||
});
|
||||
|
||||
it("honors an explicit scale", () => {
|
||||
expect(parseZoomScale("2")).toBe(2);
|
||||
});
|
||||
|
||||
it("falls back to the default for invalid or non-positive input", () => {
|
||||
expect(parseZoomScale("abc")).toBe(3);
|
||||
expect(parseZoomScale("0")).toBe(3);
|
||||
expect(parseZoomScale("-1")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,13 @@ import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync, writeFileSync
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve, join, relative, isAbsolute, basename } from "node:path";
|
||||
import {
|
||||
captureRegionCrop,
|
||||
openSettledCompositionPage,
|
||||
parseZoomTarget,
|
||||
resolveCropRegion,
|
||||
runFfmpegOnce,
|
||||
seekCompositionTimeline,
|
||||
type ZoomTarget,
|
||||
} from "../capture/captureCompositionFrame.js";
|
||||
import { resolveProject } from "../utils/project.js";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
@@ -104,8 +108,20 @@ export const examples: Example[] = [
|
||||
["Capture 5 key frames from a composition", "snapshot capture"],
|
||||
["Capture 10 evenly-spaced frames", "snapshot capture --frames 10"],
|
||||
["View the 3D stage from an isometric angle", "snapshot capture --angle iso"],
|
||||
["Zoom into an element for a high-density crop", "snapshot --zoom '#headline'"],
|
||||
[
|
||||
"Zoom into an exact pixel region at 2x density",
|
||||
"snapshot --zoom 100,50,400,300 --zoom-scale 2",
|
||||
],
|
||||
];
|
||||
|
||||
/** `--zoom-scale`: the deviceScaleFactor used for zoomed crops. Defaults to 3;
|
||||
* falls back to the default for anything that doesn't parse as a positive number. */
|
||||
export function parseZoomScale(value: unknown): number {
|
||||
const parsed = parseFloat(String(value ?? ""));
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeking the timeline to EXACTLY `data-duration` renders blank — the runtime
|
||||
* treats t >= clip-end as past-end and unmounts the clip (verified on a V4 3D
|
||||
@@ -172,6 +188,8 @@ async function captureSnapshots(
|
||||
outputDir?: string;
|
||||
angle?: Camera;
|
||||
includeEnd?: boolean;
|
||||
zoom?: ZoomTarget;
|
||||
zoomScale?: number;
|
||||
},
|
||||
): Promise<string[]> {
|
||||
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
|
||||
@@ -425,7 +443,25 @@ async function captureSnapshots(
|
||||
const filename = `frame-${String(i).padStart(2, "0")}-at-${timeLabel}.png`;
|
||||
const framePath = join(snapshotDir, filename);
|
||||
|
||||
await page.screenshot({ path: framePath, type: "png" });
|
||||
if (opts.zoom) {
|
||||
// Clip screenshot at a raised deviceScaleFactor — never CSS zoom or
|
||||
// viewport resizing — so the composition's own layout is untouched.
|
||||
const canvas = await page.evaluate(() => ({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
}));
|
||||
const region = await resolveCropRegion(page, opts.zoom, canvas);
|
||||
if (!region) {
|
||||
console.error(
|
||||
` ${c.warn("⚠")} --zoom target has no visible box at ${time.toFixed(1)}s — frame skipped`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const buffer = await captureRegionCrop(page, region, opts.zoomScale ?? 3);
|
||||
writeFileSync(framePath, buffer);
|
||||
} else {
|
||||
await page.screenshot({ path: framePath, type: "png" });
|
||||
}
|
||||
const rel = relative(projectDir, framePath);
|
||||
savedPaths.push(rel.startsWith("..") || isAbsolute(rel) ? framePath : rel);
|
||||
}
|
||||
@@ -480,6 +516,16 @@ export default defineCommand({
|
||||
"Always include a readable end-of-timeline frame (default: true). Pass --no-end to capture only your exact --at times.",
|
||||
default: true,
|
||||
},
|
||||
zoom: {
|
||||
type: "string",
|
||||
description:
|
||||
"Zoom into a CSS selector or an exact pixel region 'x,y,w,h'. Crops a high-density screenshot instead of the full frame — a raised deviceScaleFactor, never CSS zoom or viewport resizing, so layout stays identical. A selector matching nothing is an error, not a silent full-frame shot.",
|
||||
},
|
||||
"zoom-scale": {
|
||||
type: "string",
|
||||
description: "Device-scale-factor density for --zoom crops (default: 3)",
|
||||
default: "3",
|
||||
},
|
||||
describe: {
|
||||
type: "string",
|
||||
description:
|
||||
@@ -508,6 +554,8 @@ export default defineCommand({
|
||||
: String(args.describe);
|
||||
|
||||
const camera = args.angle ? parseAngle(String(args.angle)) : undefined;
|
||||
const zoomTarget = args.zoom ? parseZoomTarget(String(args.zoom)) : undefined;
|
||||
const zoomScale = parseZoomScale(args["zoom-scale"]);
|
||||
|
||||
const label = atTimestamps
|
||||
? `${atTimestamps.length} frames at [${atTimestamps.map((t) => t.toFixed(1) + "s").join(", ")}]`
|
||||
@@ -529,6 +577,8 @@ export default defineCommand({
|
||||
outputDir: snapshotDir,
|
||||
angle: camera,
|
||||
includeEnd: args.end !== false,
|
||||
zoom: zoomTarget,
|
||||
zoomScale,
|
||||
});
|
||||
|
||||
if (paths.length === 0) {
|
||||
|
||||
Reference in New Issue
Block a user