From 8c808c2ee03278c4645b97c1cd42fdc8ff1292dc Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 23 Oct 2026 23:14:57 -0400 Subject: [PATCH 1/2] fix(cli): snapshot --output clears only the frames it wrote --- .../commands/snapshot-contactSheet.test.ts | 74 ++++++++++++++++++- packages/cli/src/commands/snapshot.ts | 6 +- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/snapshot-contactSheet.test.ts b/packages/cli/src/commands/snapshot-contactSheet.test.ts index 61e46a9675a..b130a36158f 100644 --- a/packages/cli/src/commands/snapshot-contactSheet.test.ts +++ b/packages/cli/src/commands/snapshot-contactSheet.test.ts @@ -48,7 +48,8 @@ vi.mock("@hyperframes/engine", () => ({ extractMediaMetadata: vi.fn(), })); -import snapshotCommand from "./snapshot.js"; +import snapshotCommand, { SNAPSHOT_IMAGE_NAME } from "./snapshot.js"; +import { planReview, reviewFrames } from "./snapshotReview.js"; import { historyDeps } from "../utils/historyOwner.js"; import { openProjectHistory } from "@hyperframes/studio-server/history"; @@ -307,3 +308,74 @@ describe("snapshot --keep", () => { } }); }); + +describe("snapshot --output", () => { + let project: string; + + const USER_IMAGES = ["hero.png", "logo.jpg", "Notes.PNG", "frame-hero.png"]; + const PREVIOUS_RUN = [ + "frame-00-at-0.25s.png", + "frame-07-at-12.5s.png", + "at-3s.png", + "ref-00-at-0.25s.png", + "pair-00-at-0.25s.jpg", + "contact-sheet-2.jpg", + "beat-01-at-0.3s.png", + "seam-01-3-at-6s.png", + "review-beats.jpg", + "review-seam-01.jpg", + "review-phone-2.jpg", + ]; + + beforeEach(() => { + project = mkdtempSync(join(tmpdir(), "hf-snapshot-output-")); + writeFileSync(join(project, "index.html"), PROJECT_HTML); + capture.screenshot.mockImplementation(async () => Buffer.from("captured PNG")); + primePage(2); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + rmSync(project, { recursive: true, force: true }); + }); + + it.each([ + ["a snapshots folder", "snapshots"], + ["the project folder", "."], + ["the assets folder", "assets"], + ])("replaces the previous run's images in %s and keeps every other image", async (_, sub) => { + const output = join(project, sub); + mkdirSync(output, { recursive: true }); + for (const name of [...USER_IMAGES, ...PREVIOUS_RUN]) writeFileSync(join(output, name), name); + + await runCommand(snapshotCommand, { + rawArgs: [project, ...["--at", "0.5,1", "--no-end", "--describe", "false", "-o", output]], + }); + + for (const name of USER_IMAGES) expect(readFileSync(join(output, name), "utf8")).toBe(name); + for (const name of PREVIOUS_RUN) expect(existsSync(join(output, name))).toBe(false); + for (const name of ["frame-00-at-0.5s.png", "frame-01-at-1s.png"]) { + expect(readFileSync(join(output, name), "utf8")).toBe("captured PNG"); + } + }); + + it("names every review still with a name the next run clears", () => { + const plan = planReview( + { + window: [0, 10], + scenes: [ + { id: "a", start: 0, end: 4 }, + { id: "b", start: 4, end: 10 }, + ], + beats: [], + notes: [], + }, + 10, + ); + const files = reviewFrames(plan).map((frame) => frame.file); + expect(files.some((file) => file.startsWith("seam-"))).toBe(true); + for (const file of files) expect(file).toMatch(SNAPSHOT_IMAGE_NAME); + }); +}); diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 170f00dfcca..b669a04fcd6 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -120,6 +120,10 @@ export function formatSnapshotTimestamp(time: number): string { return `${Number(time.toFixed(3))}s`; } +/** Every image name a snapshot run writes into its folder; a run clears only these. */ +export const SNAPSHOT_IMAGE_NAME = + /^(?:(?:frame-\d+-|ref-(?:\d+-)?)?at-[\d.]+s\.png|pair-(?:\d+-)?at-[\d.]+s\.jpg|(?:beat-\d+|seam-\d+-\d+)-at-[\d.]+s\.png|(?:contact-sheet|review-beats|review-phone)(?:-\d+)?\.jpg|review-seam-\d+\.jpg)$/; + /** --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(); @@ -683,7 +687,7 @@ async function captureSnapshots( try { const { readdirSync } = await import("node:fs"); for (const file of opts.keep ? [] : readdirSync(snapshotDir)) { - if (/\.(png|jpg|jpeg)$/i.test(file)) { + if (SNAPSHOT_IMAGE_NAME.test(file)) { const path = resolve(snapshotDir, file); replaced.push(path); rmSync(path, { force: true }); From 35929f406f2b62da93171137295a29148d78fcb4 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 23 Oct 2026 23:18:21 -0400 Subject: [PATCH 2/2] fix(cli): drop the comment the snapshot name pattern does not need --- packages/cli/src/commands/snapshot.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index b669a04fcd6..9b2f94642a5 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -120,7 +120,6 @@ export function formatSnapshotTimestamp(time: number): string { return `${Number(time.toFixed(3))}s`; } -/** Every image name a snapshot run writes into its folder; a run clears only these. */ export const SNAPSHOT_IMAGE_NAME = /^(?:(?:frame-\d+-|ref-(?:\d+-)?)?at-[\d.]+s\.png|pair-(?:\d+-)?at-[\d.]+s\.jpg|(?:beat-\d+|seam-\d+-\d+)-at-[\d.]+s\.png|(?:contact-sheet|review-beats|review-phone)(?:-\d+)?\.jpg|review-seam-\d+\.jpg)$/;