Skip to content

Commit f4f5ccc

Browse files
fix(cli): snapshot --against survives a sharp that cannot load
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 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JSZqP4FbpqviXu9v2LjBwE
1 parent da4386f commit f4f5ccc

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,
@@ -50,6 +51,7 @@ vi.mock("@hyperframes/engine", () => ({
5051

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

@@ -208,6 +210,86 @@ describe("snapshot contact sheet failures", () => {
208210
},
209211
);
210212

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

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

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
} from "../utils/lintProject.js";
5555
import { formatLintFindings } from "../utils/lintFormat.js";
5656
import { normalizeErrorMessage } from "../utils/errorMessage.js";
57+
import { describeSharpLoadFailure } from "../utils/sharpLoadFailure.js";
5758
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
5859
import { c } from "../ui/colors.js";
5960
import { writeCaptureFileSync } from "../capture/captureFile.js";
@@ -554,6 +555,7 @@ async function captureSnapshots(
554555
const server = await serveCapturePage(projectDir, html, opts.autoProxy);
555556

556557
const savedPaths: string[] = [];
558+
let pairSheetFailure: string | null = null;
557559

558560
try {
559561
const {
@@ -617,7 +619,7 @@ async function captureSnapshots(
617619
});
618620

619621
if (duration <= 0 && !opts.at?.length) {
620-
return { paths: [], warnings };
622+
return { paths: [], warnings, pairSheetsSkipped: false };
621623
}
622624

623625
const readTiming = () =>
@@ -976,14 +978,24 @@ async function captureSnapshots(
976978
const refPath = join(snapshotDir, `ref-${slot}.png`);
977979
writeCaptureFileSync(refPath, refPng);
978980
const pairPath = join(snapshotDir, `pair-${slot}.jpg`);
979-
const { createContactSheet } = await import("../capture/contactSheet.js");
980-
await createContactSheet([framePath, refPath], pairPath, {
981-
cols: 2,
982-
maxImages: 2,
983-
cellWidth: 960,
984-
labelMode: "custom",
985-
labels: ["render", "reference"],
986-
});
981+
if (!pairSheetFailure) {
982+
try {
983+
const { createContactSheet } = await import("../capture/contactSheet.js");
984+
await createContactSheet([framePath, refPath], pairPath, {
985+
cols: 2,
986+
maxImages: 2,
987+
cellWidth: 960,
988+
labelMode: "custom",
989+
labels: ["render", "reference"],
990+
});
991+
} catch (err) {
992+
pairSheetFailure = describeSharpLoadFailure(err);
993+
if (!pairSheetFailure) throw err;
994+
console.error(
995+
` ${c.warn("⚠")} Reference pair sheets skipped: ${pairSheetFailure}`,
996+
);
997+
}
998+
}
987999
}
9881000
}
9891001
// Only the capture itself is a "snapshot": the reference frame and the
@@ -1018,6 +1030,7 @@ async function captureSnapshots(
10181030
return {
10191031
paths: savedPaths,
10201032
warnings,
1033+
pairSheetsSkipped: pairSheetFailure !== null,
10211034
...(review && reviewList
10221035
? {
10231036
review: {
@@ -1037,6 +1050,8 @@ interface CapturedSnapshots {
10371050
paths: string[];
10381051
/** Why a frame may differ from the composition: shown on the console and in `--json`. */
10391052
warnings: string[];
1053+
/** --against wrote no pair sheets because sharp could not load. */
1054+
pairSheetsSkipped: boolean;
10401055
review?: { plan: ReviewPlan; frames: Array<ReviewFrame & { path: string }> };
10411056
}
10421057

@@ -1388,7 +1403,9 @@ export default defineCommand({
13881403
}
13891404
if (against) {
13901405
log(
1391-
` ${c.dim("ref-*.png + pair-*.jpg")} beside each frame (reference frame, render | reference sheet)`,
1406+
captured.pairSheetsSkipped
1407+
? ` ${c.dim("ref-*.png")} beside each frame (reference frame)`
1408+
: ` ${c.dim("ref-*.png + pair-*.jpg")} beside each frame (reference frame, render | reference sheet)`,
13921409
);
13931410
}
13941411

@@ -1422,7 +1439,7 @@ export default defineCommand({
14221439
}
14231440
} catch (err) {
14241441
console.warn(
1425-
` ${c.warn("⚠")} Contact sheet skipped: ${normalizeErrorMessage(err)}. Individual snapshot PNGs are still available.`,
1442+
` ${c.warn("⚠")} Contact sheet skipped: ${describeSharpLoadFailure(err) ?? normalizeErrorMessage(err)}. Individual snapshot PNGs are still available.`,
14261443
);
14271444
}
14281445

@@ -1459,10 +1476,11 @@ export default defineCommand({
14591476
// limit. Full 1920×1080 PNGs are typically 3-6 MB. Use sharp if it is
14601477
// already there (never installed for this); otherwise skip files over the limit.
14611478
let sharp: ReturnType<typeof loadInstalledSharp> = null;
1479+
let sharpFailure: string | null = null;
14621480
try {
14631481
sharp = loadInstalledSharp("--describe");
1464-
} catch {
1465-
/* a copy that cannot load — fall back to size check */
1482+
} catch (err) {
1483+
sharpFailure = describeSharpLoadFailure(err);
14661484
}
14671485

14681486
const results = await Promise.allSettled(
@@ -1480,7 +1498,9 @@ export default defineCommand({
14801498
if (raw.length > 3_800_000)
14811499
return {
14821500
filename,
1483-
desc: "file too large for Gemini — install sharp to enable auto-resize",
1501+
desc: sharpFailure
1502+
? "file too large for Gemini — sharp failed to load, so it was not resized"
1503+
: "file too large for Gemini — install sharp to enable auto-resize",
14841504
};
14851505
imageData = raw;
14861506
}
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)