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:
Miguel Angel Simon Sierra
2026-07-10 13:27:52 -04:00
parent 58f45ef758
commit f4cef54b8b
12 changed files with 856 additions and 31 deletions
+51 -1
View File
@@ -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) {