feat(cli): snapshot --keep redoes one still and leaves the others (#5605)

* feat(cli): snapshot --keep redoes one board still and leaves the others

* fix(skills): the board recipe waits for a released snapshot --keep

* fix(cli): snapshot --keep refuses two times with one name and replaces linked stills
This commit is contained in:
Miguel Ángel
2026-10-14 19:25:31 +00:00
committed by GitHub
parent 71595dd820
commit c0636d017f
4 changed files with 153 additions and 53 deletions
+1
View File
@@ -869,6 +869,7 @@ npx hyperframes snapshot my-project --at 1.5,4.3 --against ref.mp4
| `--composition <id>` | Spread `--frames` across the visible interval of the element with this `data-composition-id`. Use one of `--at`, `--range` or `--composition`. |
| `--output, -o` | Where the PNGs go (default: a new `<project>/.hyperframes/artifacts/snapshots/<id>` per run) |
| `--end` / `--no-end` | Always add a readable end-of-timeline frame (default: on). `--no-end` captures only your `--at` times. |
| `--keep` | Add to `--output` instead of clearing it, naming each still for its time: `at-0s.png`, `at-1.5s.png`, `at-12.25s.png` (no trailing zeros). Snapshotting one moment again replaces only that still. No contact sheet. |
| `--zoom` | Crop to a CSS selector or an exact `x,y,w,h` region, using a raised device scale factor. Layout is untouched — never CSS zoom or a resized viewport. A selector that matches nothing is an error. |
| `--against` | A reference video, such as footage you are rebuilding. For every captured time also saves its frame-exact frame (`ref-XX-…png`) and a labelled render / reference pair (`pair-XX-…jpg`). |
| `--zoom-scale` | Pixel density for `--zoom` crops (default 3) |
@@ -1,16 +1,23 @@
import { runCommand } from "citty";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import {
existsSync,
linkSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const capture = vi.hoisted(() => ({
evaluate: vi.fn(),
screenshot: vi.fn(async ({ path }: { path: string }) => {
const bytes = Buffer.from("captured PNG");
writeFileSync(path, bytes);
return bytes;
}),
screenshot: vi.fn(async () => Buffer.from("captured PNG")),
closeBrowser: vi.fn(async () => undefined),
closeServer: vi.fn(async () => undefined),
seek: vi.fn(async () => undefined),
@@ -43,6 +50,21 @@ import snapshotCommand from "./snapshot.js";
import { historyDeps } from "../utils/historyOwner.js";
import { openProjectHistory } from "@hyperframes/studio-server/history";
/** The page's answers for one run: asset check, duration, ready, then two per frame. */
function primePage(frames: number) {
capture.evaluate
.mockReset()
.mockResolvedValueOnce({ loaded: [], errored: [], unused: [] })
.mockResolvedValueOnce(2)
.mockResolvedValueOnce(true);
for (let i = 0; i < frames; i++) {
capture.evaluate.mockResolvedValueOnce([]).mockResolvedValueOnce(undefined);
}
}
const PROJECT_HTML =
'<html><body><div id="root" data-composition-id="main" data-width="640" data-height="360" data-start="0" data-duration="2">Visible</div><script>window.__timelines = { main: { duration() { return 2; }, seek() {} } };</script></body></html>';
describe("snapshot contact sheet failures", () => {
let project: string;
@@ -50,19 +72,8 @@ describe("snapshot contact sheet failures", () => {
vi.resetModules();
vi.clearAllMocks();
project = mkdtempSync(join(tmpdir(), "hf-snapshot-contact-sheet-"));
writeFileSync(
join(project, "index.html"),
'<html><body><div id="root" data-composition-id="main" data-width="640" data-height="360" data-start="0" data-duration="2">Visible</div><script>window.__timelines = { main: { duration() { return 2; }, seek() {} } };</script></body></html>',
);
capture.evaluate
.mockReset()
.mockResolvedValueOnce({ loaded: [], errored: [], unused: [] })
.mockResolvedValueOnce(2)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce([])
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce([])
.mockResolvedValueOnce(undefined);
writeFileSync(join(project, "index.html"), PROJECT_HTML);
primePage(2);
vi.spyOn(console, "log").mockImplementation(() => undefined);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
vi.spyOn(console, "error").mockImplementation(() => undefined);
@@ -161,20 +172,8 @@ describe("snapshot contact sheet failures", () => {
historyRoot: historyDeps.historyRoot,
ownerWaitMs: 0,
});
capture.screenshot.mockImplementation(async ({ path }: { path: string }) => {
const bytes = Buffer.from("replaced PNG");
writeFileSync(path, bytes);
return bytes;
});
capture.evaluate
.mockReset()
.mockResolvedValueOnce({ loaded: [], errored: [], unused: [] })
.mockResolvedValueOnce(2)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce([])
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce([])
.mockResolvedValueOnce(undefined);
capture.screenshot.mockImplementation(async () => Buffer.from("replaced PNG"));
primePage(2);
try {
await runCommand(snapshotCommand, args);
for (const [index, time] of ["0.5", "1"].entries()) {
@@ -194,3 +193,70 @@ describe("snapshot contact sheet failures", () => {
}
});
});
describe("snapshot --keep", () => {
let project: string;
let stills: string;
beforeEach(() => {
project = mkdtempSync(join(tmpdir(), "hf-snapshot-keep-"));
stills = join(project, "boards", "stills");
writeFileSync(join(project, "index.html"), PROJECT_HTML);
vi.spyOn(console, "log").mockImplementation(() => undefined);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
rmSync(project, { recursive: true, force: true });
});
async function snapshot(at: string, bytes: string) {
capture.screenshot.mockImplementation(async () => Buffer.from(bytes));
primePage(at.split(",").length);
await runCommand(snapshotCommand, {
rawArgs: [project, "--keep", "--at", at, "--no-end", "--describe", "false", "-o", stills],
});
}
it("redoes one moment and keeps every other still", async () => {
await snapshot("0,0.5,1.5", "first PNG");
await snapshot("0.5", "redo PNG");
expect(readdirSync(stills).sort()).toEqual(["at-0.5s.png", "at-0s.png", "at-1.5s.png"]);
expect(readFileSync(join(stills, "at-0s.png"), "utf8")).toBe("first PNG");
expect(readFileSync(join(stills, "at-0.5s.png"), "utf8")).toBe("redo PNG");
expect(readFileSync(join(stills, "at-1.5s.png"), "utf8")).toBe("first PNG");
expect(existsSync(join(stills, "contact-sheet.jpg"))).toBe(false);
});
it("refuses two times that would share a still, before touching the folder", async () => {
await snapshot("1.5", "first PNG");
capture.screenshot.mockClear();
await expect(snapshot("1.5,1.5004", "second PNG")).rejects.toThrow(
"--keep would name both 1.5 and 1.5004 at-1.5s.png",
);
expect(capture.screenshot).not.toHaveBeenCalled();
expect(readFileSync(join(stills, "at-1.5s.png"), "utf8")).toBe("first PNG");
});
it("replaces a linked still without writing through the link", async () => {
mkdirSync(stills, { recursive: true });
const hardVictim = join(project, "hard-victim.png");
writeFileSync(hardVictim, "hard victim");
linkSync(hardVictim, join(stills, "at-0.5s.png"));
const softVictim = join(project, "soft-victim.png");
writeFileSync(softVictim, "soft victim");
if (process.platform !== "win32") symlinkSync(softVictim, join(stills, "at-1.5s.png"));
await snapshot("0.5,1.5", "new PNG");
expect(readFileSync(hardVictim, "utf8")).toBe("hard victim");
expect(readFileSync(softVictim, "utf8")).toBe("soft victim");
for (const still of ["at-0.5s.png", "at-1.5s.png"]) {
expect(lstatSync(join(stills, still)).isFile()).toBe(true);
expect(readFileSync(join(stills, still), "utf8")).toBe("new PNG");
}
});
});
+6 -7
View File
@@ -48,6 +48,10 @@ describe("formatSnapshotTimestamp", () => {
it.each([
[1.12, "1.12s"],
[0.30000000000000004, "0.3s"],
[0, "0s"],
[1.5, "1.5s"],
[12.25, "12.25s"],
[1.5000001, "1.5s"],
])("formats %s without discarding useful precision", (time, expected) => {
expect(formatSnapshotTimestamp(time)).toBe(expected);
});
@@ -76,9 +80,7 @@ describe("tailFrameTime", () => {
describe("transparent snapshot capture", () => {
it("asks Chrome to retain the alpha channel in review PNGs", () => {
const source = readFileSync(new URL("./snapshot.ts", import.meta.url), "utf8");
expect(source).toContain(
'page.screenshot({ path: framePath, type: "png", omitBackground: true })',
);
expect(source).toContain('page.screenshot({ type: "png", omitBackground: true })');
});
it("exposes --proxy/--no-proxy and forwards the override to the static server", () => {
@@ -931,10 +933,7 @@ describe("snapshot composite recapture", () => {
const injection = source.indexOf("await injectVideoFramesBatch(page, updates)");
const visibility = source.indexOf("await syncVideoFrameVisibility(", injection);
const recapture = source.indexOf("await recaptureSnapshotComposite(page)", visibility);
const finalCapture = source.indexOf(
'page.screenshot({ path: framePath, type: "png"',
recapture,
);
const finalCapture = source.indexOf('page.screenshot({ type: "png"', recapture);
expect(injection).toBeGreaterThan(-1);
expect(visibility).toBeGreaterThan(injection);
expect(recapture).toBeGreaterThan(visibility);
+47 -13
View File
@@ -49,6 +49,7 @@ import { formatLintFindings } from "../utils/lintFormat.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
import { c } from "../ui/colors.js";
import { writeCaptureFileSync } from "../capture/captureFile.js";
import { runCancellableProcess } from "../utils/cancellableProcess.js";
import { findFFmpeg, findFFprobe, getFFmpegInstallHint } from "../browser/ffmpeg.js";
import { parseAngle, type Camera } from "./motionShotLayout.js";
@@ -100,6 +101,21 @@ export function formatSnapshotTimestamp(time: number): string {
return `${Number(time.toFixed(3))}s`;
}
/** --keep names a still by its millisecond label, so two distinct times may not share one. */
function assertDistinctKeepNames(times: number[]): void {
const timeByLabel = new Map<string, number>();
for (const time of times) {
const label = formatSnapshotTimestamp(time);
const other = timeByLabel.get(label);
if (other !== undefined && other !== time) {
throw new Error(
`--keep would name both ${other} and ${time} at-${label}.png; pass times at least 1 ms apart`,
);
}
timeByLabel.set(label, time);
}
}
/** Shows media by the runtime's visibility rule. A clip held at the composition end, or past the end of a source
* shorter than its slot, samples one nominal 30fps frame inside the source; FFmpeg has no frame at or past its end. */
export function resolveSnapshotVideoFrameTime(input: {
@@ -466,6 +482,8 @@ async function captureSnapshots(
browserGpuMode?: BrowserGpuMode;
/** Reference video: save its frame at each captured time plus a render|reference pair. */
against?: string;
/** Add to outputDir without clearing it; name each still by its time, not its place in the run. */
keep?: boolean;
},
): Promise<string[]> {
const { bundleWithLocalizedFonts } = await import("../utils/bundleWithLocalizedFonts.js");
@@ -604,7 +622,7 @@ async function captureSnapshots(
mkdirSync(snapshotDir, { recursive: true });
try {
const { readdirSync } = await import("node:fs");
for (const file of readdirSync(snapshotDir)) {
for (const file of opts.keep ? [] : readdirSync(snapshotDir)) {
if (/\.(png|jpg|jpeg)$/i.test(file)) {
const path = resolve(snapshotDir, file);
replaced.push(path);
@@ -819,8 +837,10 @@ async function captureSnapshots(
await recaptureSnapshotComposite(page);
const timeLabel = formatSnapshotTimestamp(time);
const index = String(i).padStart(2, "0");
const filename = `frame-${index}-at-${timeLabel}.png`;
const slot = opts.keep
? `at-${timeLabel}`
: `${String(i).padStart(2, "0")}-at-${timeLabel}`;
const filename = opts.keep ? `${slot}.png` : `frame-${slot}.png`;
const framePath = join(snapshotDir, filename);
let crop: { x: number; y: number; width: number; height: number } | undefined;
@@ -845,9 +865,12 @@ async function captureSnapshots(
region,
opts.zoomScale ?? DEFAULT_ZOOM_SCALE,
);
writeFileSync(framePath, buffer);
writeCaptureFileSync(framePath, buffer);
} else {
await page.screenshot({ path: framePath, type: "png", omitBackground: true });
writeCaptureFileSync(
framePath,
await page.screenshot({ type: "png", omitBackground: true }),
);
}
artifacts.push({
id: `${invocation}-${i}`,
@@ -871,9 +894,9 @@ async function captureSnapshots(
` ${c.warn("⚠")} --against has no frame at ${timeLabel} — reference pair skipped`,
);
} else {
const refPath = join(snapshotDir, `ref-${index}-at-${timeLabel}.png`);
writeFileSync(refPath, refPng);
const pairPath = join(snapshotDir, `pair-${index}-at-${timeLabel}.jpg`);
const refPath = join(snapshotDir, `ref-${slot}.png`);
writeCaptureFileSync(refPath, refPng);
const pairPath = join(snapshotDir, `pair-${slot}.jpg`);
const { createContactSheet } = await import("../capture/contactSheet.js");
await createContactSheet([framePath, refPath], pairPath, {
cols: 2,
@@ -998,6 +1021,12 @@ 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,
},
keep: {
type: "boolean",
description:
"Add to --output instead of clearing it, naming each still at-<seconds>s.png (at-0s, at-1.5s, at-12.25s: no trailing zeros), so snapshotting one moment again replaces only that still. Times less than 1 ms apart share a name, so one run may not pass two of them. No contact sheet.",
default: false,
},
zoom: {
type: "string",
description:
@@ -1074,6 +1103,7 @@ export default defineCommand({
.map((s) => parseFloat(s.trim()))
.filter((n) => !isNaN(n))
: undefined;
if (args.keep === true && atTimestamps) assertDistinctKeepNames(atTimestamps);
// Gemini frame analysis runs by default (silently skipped if
// GEMINI_API_KEY is not set). `--describe "custom question"` overrides
// the default prompt with a targeted question. `--describe false` opts
@@ -1122,6 +1152,7 @@ export default defineCommand({
autoProxy: args.proxy as boolean | undefined,
browserGpuMode: resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined),
against,
keep: args.keep === true,
});
reportArtifacts(
"snapshot",
@@ -1146,13 +1177,16 @@ export default defineCommand({
);
}
// Generate contact sheet for quick AI review
// Generate contact sheet for quick AI review; a --keep folder mixes runs, so it gets none.
try {
const { createSnapshotContactSheet } = await import("../capture/contactSheet.js");
const sheets = await createSnapshotContactSheet(
snapshotDir,
join(snapshotDir, "contact-sheet.jpg"),
);
const sheets =
args.keep === true
? []
: await createSnapshotContactSheet(
snapshotDir,
join(snapshotDir, "contact-sheet.jpg"),
);
reportArtifacts("snapshot", "contact-sheet", sheets);
for (const sheet of sheets) {
console.log(` ${resolve(sheet)} (grid view for AI review)`);