mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +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:
@@ -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