Skip to content

Commit f76fa5c

Browse files
fix(cli): snapshot --against survives a sharp that cannot load (#5199)
On Windows 11 with Smart App Control on, Code Integrity refuses sharp's unsigned libvips DLL. With `--against`, the per-frame render | reference pair sheet called sharp unguarded, so a blocked sharp failed the whole snapshot with sharp's multi-line error dump, after the first frame. Guard the pair sheet: a sharp load failure prints one warning, later frames skip their pair sheet without retrying, and every frame and reference frame is still written. Any other pair-sheet error still fails the snapshot. captureSnapshots reports the skip, so the summary lists only ref-*.png when no pair sheets were made. describeSharpLoadFailure turns sharp's load error into one line and names Windows App Control for Win32 error 4551 ("An Application Control policy has blocked this file"). The contact-sheet warning keeps warning on every failure and uses it to shorten sharp's message, and --describe no longer tells the user to install a sharp that is installed but blocked. Refs #4845 Claude-Session: https://claude.ai/code/session_01JSZqP4FbpqviXu9v2LjBwE Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent b9ee13d commit f76fa5c

4 files changed

Lines changed: 195 additions & 14 deletions

File tree

‎packages/cli/src/commands/snapshot-contactSheet.test.ts‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { execFileSync } from "node:child_process";
12
import { runCommand } from "citty";
23
import {
34
existsSync,
@@ -51,6 +52,7 @@ vi.mock("@hyperframes/engine", () => ({
5152

5253
import snapshotCommand, { SNAPSHOT_IMAGE_NAME } from "./snapshot.js";
5354
import { planReview, reviewFrames } from "./snapshotReview.js";
55+
import { findFFmpeg, findFFprobe } from "../browser/ffmpeg.js";
5456
import { historyDeps } from "../utils/historyOwner.js";
5557
import { openProjectHistory } from "@hyperframes/studio-server/history";
5658

@@ -209,6 +211,86 @@ describe("snapshot contact sheet failures", () => {
209211
},
210212
);
211213

214+
describe("--against when the pair sheet cannot be made", () => {
215+
const ffmpeg = findFFmpeg();
216+
const noFfmpeg = !ffmpeg || !findFFprobe();
217+
const sharpBlocked = [
218+
'Could not load the "sharp" module using the win32-x64 runtime',
219+
"ERR_DLOPEN_FAILED: An Application Control policy has blocked this file.",
220+
"Possible solutions:",
221+
].join("\n");
222+
223+
function againstArgs(reference: string) {
224+
return {
225+
rawArgs: [
226+
project,
227+
...["--at", "0.5,1", "--no-end", "--describe", "false"],
228+
...["--against", reference, "--output", join(project, "snapshots")],
229+
],
230+
};
231+
}
232+
233+
function makeReference(): string {
234+
const reference = join(project, "reference.mp4");
235+
execFileSync(ffmpeg!, [
236+
...["-hide_banner", "-loglevel", "error", "-f", "lavfi"],
237+
...["-i", "testsrc=d=2:r=24:s=160x90", "-pix_fmt", "yuv420p", reference],
238+
]);
239+
return reference;
240+
}
241+
242+
it.skipIf(noFfmpeg)(
243+
"warns once when sharp cannot load and keeps every frame and reference",
244+
async () => {
245+
const reference = makeReference();
246+
const pairSheet = vi.fn(async () => {
247+
throw new Error(sharpBlocked);
248+
});
249+
vi.doMock("../capture/contactSheet.js", () => ({
250+
createContactSheet: pairSheet,
251+
createSnapshotContactSheet: vi.fn(async () => {
252+
throw new Error(sharpBlocked);
253+
}),
254+
}));
255+
256+
await expect(runCommand(snapshotCommand, againstArgs(reference))).resolves.toBeDefined();
257+
258+
const out = join(project, "snapshots");
259+
for (const slot of ["00-at-0.5s", "01-at-1s"]) {
260+
expect(readFileSync(join(out, `frame-${slot}.png`))).toEqual(Buffer.from("captured PNG"));
261+
expect(existsSync(join(out, `ref-${slot}.png`))).toBe(true);
262+
}
263+
expect(readdirSync(out).filter((name) => name.startsWith("pair-"))).toEqual([]);
264+
expect(pairSheet).toHaveBeenCalledOnce();
265+
expect(console.error).toHaveBeenCalledOnce();
266+
expect(console.error).toHaveBeenCalledWith(
267+
expect.stringContaining("Reference pair sheets skipped: Windows App Control"),
268+
);
269+
const logged = vi.mocked(console.log).mock.calls.map((call) => String(call[0]));
270+
expect(logged.some((line) => line.includes("ref-*.png"))).toBe(true);
271+
expect(logged.some((line) => line.includes("pair-*.jpg"))).toBe(false);
272+
},
273+
);
274+
275+
it.skipIf(noFfmpeg)("still fails the snapshot for any other pair sheet error", async () => {
276+
const reference = makeReference();
277+
vi.doMock("../capture/contactSheet.js", () => ({
278+
createContactSheet: vi.fn(async () => {
279+
throw new Error("Unable to decode image for pair sheet");
280+
}),
281+
}));
282+
283+
await expect(runCommand(snapshotCommand, againstArgs(reference))).rejects.toThrow(
284+
"Command failed",
285+
);
286+
expect(console.error).toHaveBeenCalledWith(
287+
expect.stringContaining("Snapshot failed: Unable to decode image for pair sheet"),
288+
);
289+
expect(capture.closeBrowser).toHaveBeenCalledOnce();
290+
expect(capture.closeServer).toHaveBeenCalledOnce();
291+
});
292+
});
293+
212294
it("keeps and records the captured frames when another process holds history", async () => {
213295
const args = {
214296
rawArgs: [

‎packages/cli/src/commands/snapshot.ts‎

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import {
6060
} from "../utils/lintProject.js";
6161
import { formatLintFindings } from "../utils/lintFormat.js";
6262
import { normalizeErrorMessage } from "../utils/errorMessage.js";
63+
import { describeSharpLoadFailure } from "../utils/sharpLoadFailure.js";
6364
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
6465
import { c } from "../ui/colors.js";
6566
import { writeCaptureFileSync } from "../capture/captureFile.js";
@@ -595,6 +596,7 @@ async function captureSnapshots(
595596
const server = await serveCapturePage(projectDir, html, opts.autoProxy);
596597

597598
const savedPaths: string[] = [];
599+
let pairSheetFailure: string | null = null;
598600

599601
try {
600602
const {
@@ -659,7 +661,7 @@ async function captureSnapshots(
659661
});
660662

661663
if (duration <= 0 && !opts.at?.length) {
662-
return { paths: [], warnings };
664+
return { paths: [], warnings, pairSheetsSkipped: false };
663665
}
664666

665667
const readTiming = () =>
@@ -1019,14 +1021,24 @@ async function captureSnapshots(
10191021
const refPath = join(snapshotDir, `ref-${slot}.png`);
10201022
writeCaptureFileSync(refPath, refPng);
10211023
const pairPath = join(snapshotDir, `pair-${slot}.jpg`);
1022-
const { createContactSheet } = await import("../capture/contactSheet.js");
1023-
await createContactSheet([framePath, refPath], pairPath, {
1024-
cols: 2,
1025-
maxImages: 2,
1026-
cellWidth: 960,
1027-
labelMode: "custom",
1028-
labels: ["render", "reference"],
1029-
});
1024+
if (!pairSheetFailure) {
1025+
try {
1026+
const { createContactSheet } = await import("../capture/contactSheet.js");
1027+
await createContactSheet([framePath, refPath], pairPath, {
1028+
cols: 2,
1029+
maxImages: 2,
1030+
cellWidth: 960,
1031+
labelMode: "custom",
1032+
labels: ["render", "reference"],
1033+
});
1034+
} catch (err) {
1035+
pairSheetFailure = describeSharpLoadFailure(err);
1036+
if (!pairSheetFailure) throw err;
1037+
console.error(
1038+
` ${c.warn("⚠")} Reference pair sheets skipped: ${pairSheetFailure}`,
1039+
);
1040+
}
1041+
}
10301042
}
10311043
}
10321044
// Only the capture itself is a "snapshot": the reference frame and the
@@ -1061,6 +1073,7 @@ async function captureSnapshots(
10611073
return {
10621074
paths: savedPaths,
10631075
warnings,
1076+
pairSheetsSkipped: pairSheetFailure !== null,
10641077
...(review && reviewList
10651078
? {
10661079
review: {
@@ -1080,6 +1093,8 @@ interface CapturedSnapshots {
10801093
paths: string[];
10811094
/** Why a frame may differ from the composition: shown on the console and in `--json`. */
10821095
warnings: string[];
1096+
/** --against wrote no pair sheets because sharp could not load. */
1097+
pairSheetsSkipped: boolean;
10831098
review?: { plan: ReviewPlan; frames: Array<ReviewFrame & { path: string }> };
10841099
}
10851100

@@ -1431,7 +1446,9 @@ export default defineCommand({
14311446
}
14321447
if (against) {
14331448
log(
1434-
` ${c.dim("ref-*.png + pair-*.jpg")} beside each frame (reference frame, render | reference sheet)`,
1449+
captured.pairSheetsSkipped
1450+
? ` ${c.dim("ref-*.png")} beside each frame (reference frame)`
1451+
: ` ${c.dim("ref-*.png + pair-*.jpg")} beside each frame (reference frame, render | reference sheet)`,
14351452
);
14361453
}
14371454

@@ -1465,7 +1482,7 @@ export default defineCommand({
14651482
}
14661483
} catch (err) {
14671484
console.warn(
1468-
` ${c.warn("⚠")} Contact sheet skipped: ${normalizeErrorMessage(err)}. Individual snapshot PNGs are still available.`,
1485+
` ${c.warn("⚠")} Contact sheet skipped: ${describeSharpLoadFailure(err) ?? normalizeErrorMessage(err)}. Individual snapshot PNGs are still available.`,
14691486
);
14701487
}
14711488

@@ -1502,10 +1519,11 @@ export default defineCommand({
15021519
// limit. Full 1920×1080 PNGs are typically 3-6 MB. Use sharp if it is
15031520
// already there (never installed for this); otherwise skip files over the limit.
15041521
let sharp: ReturnType<typeof loadInstalledSharp> = null;
1522+
let sharpFailure: string | null = null;
15051523
try {
15061524
sharp = loadInstalledSharp("--describe");
1507-
} catch {
1508-
/* a copy that cannot load — fall back to size check */
1525+
} catch (err) {
1526+
sharpFailure = describeSharpLoadFailure(err);
15091527
}
15101528

15111529
const results = await Promise.allSettled(
@@ -1523,7 +1541,9 @@ export default defineCommand({
15231541
if (raw.length > 3_800_000)
15241542
return {
15251543
filename,
1526-
desc: "file too large for Gemini — install sharp to enable auto-resize",
1544+
desc: sharpFailure
1545+
? "file too large for Gemini — sharp failed to load, so it was not resized"
1546+
: "file too large for Gemini — install sharp to enable auto-resize",
15271547
};
15281548
imageData = raw;
15291549
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, expect, it } from "vitest";
2+
import { describeSharpLoadFailure } from "./sharpLoadFailure.js";
3+
4+
// The shape of the error sharp throws when no native binding loads (sharp/dist/sharp.mjs).
5+
function sharpLoadError(...causes: string[]): Error {
6+
return new Error(
7+
[
8+
'Could not load the "sharp" module using the win32-x64 runtime',
9+
...causes,
10+
"Possible solutions:",
11+
"- Ensure optional dependencies can be installed:",
12+
" npm install --include=optional sharp",
13+
].join("\n"),
14+
);
15+
}
16+
17+
describe("describeSharpLoadFailure", () => {
18+
it("names Windows App Control when Code Integrity blocked the DLL", () => {
19+
const err = sharpLoadError(
20+
"ERR_DLOPEN_FAILED: \\\\?\\C:\\npm\\node_modules\\@img\\sharp-win32-x64\\sharp.node: An Application Control policy has blocked this file.",
21+
);
22+
expect(describeSharpLoadFailure(err)).toMatch(/^Windows App Control \(Smart App Control/);
23+
});
24+
25+
it("keeps sharp's own first cause for any other failed load", () => {
26+
const err = sharpLoadError("ERR_DLOPEN_FAILED: The specified module could not be found.");
27+
expect(describeSharpLoadFailure(err)).toBe(
28+
'Could not load the "sharp" module using the win32-x64 runtime (ERR_DLOPEN_FAILED: The specified module could not be found.)',
29+
);
30+
});
31+
32+
it("drops the carriage return Windows error text ends its lines with", () => {
33+
const err = new Error(
34+
'Could not load the "sharp" module using the win32-x64 runtime\r\nERR_DLOPEN_FAILED: %1 is not a valid Win32 application.\r\nPossible solutions:',
35+
);
36+
expect(describeSharpLoadFailure(err)).toBe(
37+
'Could not load the "sharp" module using the win32-x64 runtime (ERR_DLOPEN_FAILED: %1 is not a valid Win32 application.)',
38+
);
39+
});
40+
41+
it("falls back to sharp's header when every cause was a missing package", () => {
42+
expect(describeSharpLoadFailure(sharpLoadError())).toBe(
43+
'Could not load the "sharp" module using the win32-x64 runtime',
44+
);
45+
});
46+
47+
it("finds sharp's error inside the CLI's own could-not-load wrapper", () => {
48+
const err = new Error(
49+
`contact sheets needs sharp, which is installed but could not load (${sharpLoadError("ERR_DLOPEN_FAILED: The specified module could not be found.").message}). Reinstall hyperframes with optional dependencies enabled, or run: npm i sharp`,
50+
);
51+
expect(describeSharpLoadFailure(err)).toBe(
52+
'Could not load the "sharp" module using the win32-x64 runtime (ERR_DLOPEN_FAILED: The specified module could not be found.)',
53+
);
54+
});
55+
56+
it("ignores errors that are not a failed sharp load", () => {
57+
expect(describeSharpLoadFailure(new Error("ENOENT: no such file"))).toBeNull();
58+
expect(describeSharpLoadFailure("Input file is missing")).toBeNull();
59+
});
60+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { normalizeErrorMessage } from "./errorMessage.js";
2+
3+
const SHARP_LOAD_FAILURE = 'Could not load the "sharp" module';
4+
// Win32 error 4551, raised for Smart App Control and App Control for Business policies alike.
5+
const APPLICATION_CONTROL_BLOCK = /An Application Control policy has blocked this file/i;
6+
7+
/** Why sharp's native library failed to load (also inside loadSharp's wrapper), as one line, or null. */
8+
export function describeSharpLoadFailure(error: unknown): string | null {
9+
const full = normalizeErrorMessage(error);
10+
const start = full.indexOf(SHARP_LOAD_FAILURE);
11+
if (start < 0) return null;
12+
const message = full.slice(start);
13+
if (APPLICATION_CONTROL_BLOCK.test(message)) {
14+
return "Windows App Control (Smart App Control or an App Control policy) blocked sharp's unsigned native library. Smart App Control cannot allow a single file";
15+
}
16+
const [header = message, cause = ""] = message.split(/\r?\n/);
17+
const reason = cause.trim();
18+
return reason && !reason.startsWith("Possible solutions") ? `${header} (${reason})` : header;
19+
}

0 commit comments

Comments
 (0)