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>
This commit is contained in:
ezra21997-cell
2026-10-25 23:12:14 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent b9ee13d8ce
commit f76fa5c91c
4 changed files with 195 additions and 14 deletions
@@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process";
import { runCommand } from "citty";
import {
existsSync,
@@ -51,6 +52,7 @@ vi.mock("@hyperframes/engine", () => ({
import snapshotCommand, { SNAPSHOT_IMAGE_NAME } from "./snapshot.js";
import { planReview, reviewFrames } from "./snapshotReview.js";
import { findFFmpeg, findFFprobe } from "../browser/ffmpeg.js";
import { historyDeps } from "../utils/historyOwner.js";
import { openProjectHistory } from "@hyperframes/studio-server/history";
@@ -209,6 +211,86 @@ describe("snapshot contact sheet failures", () => {
},
);
describe("--against when the pair sheet cannot be made", () => {
const ffmpeg = findFFmpeg();
const noFfmpeg = !ffmpeg || !findFFprobe();
const sharpBlocked = [
'Could not load the "sharp" module using the win32-x64 runtime',
"ERR_DLOPEN_FAILED: An Application Control policy has blocked this file.",
"Possible solutions:",
].join("\n");
function againstArgs(reference: string) {
return {
rawArgs: [
project,
...["--at", "0.5,1", "--no-end", "--describe", "false"],
...["--against", reference, "--output", join(project, "snapshots")],
],
};
}
function makeReference(): string {
const reference = join(project, "reference.mp4");
execFileSync(ffmpeg!, [
...["-hide_banner", "-loglevel", "error", "-f", "lavfi"],
...["-i", "testsrc=d=2:r=24:s=160x90", "-pix_fmt", "yuv420p", reference],
]);
return reference;
}
it.skipIf(noFfmpeg)(
"warns once when sharp cannot load and keeps every frame and reference",
async () => {
const reference = makeReference();
const pairSheet = vi.fn(async () => {
throw new Error(sharpBlocked);
});
vi.doMock("../capture/contactSheet.js", () => ({
createContactSheet: pairSheet,
createSnapshotContactSheet: vi.fn(async () => {
throw new Error(sharpBlocked);
}),
}));
await expect(runCommand(snapshotCommand, againstArgs(reference))).resolves.toBeDefined();
const out = join(project, "snapshots");
for (const slot of ["00-at-0.5s", "01-at-1s"]) {
expect(readFileSync(join(out, `frame-${slot}.png`))).toEqual(Buffer.from("captured PNG"));
expect(existsSync(join(out, `ref-${slot}.png`))).toBe(true);
}
expect(readdirSync(out).filter((name) => name.startsWith("pair-"))).toEqual([]);
expect(pairSheet).toHaveBeenCalledOnce();
expect(console.error).toHaveBeenCalledOnce();
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining("Reference pair sheets skipped: Windows App Control"),
);
const logged = vi.mocked(console.log).mock.calls.map((call) => String(call[0]));
expect(logged.some((line) => line.includes("ref-*.png"))).toBe(true);
expect(logged.some((line) => line.includes("pair-*.jpg"))).toBe(false);
},
);
it.skipIf(noFfmpeg)("still fails the snapshot for any other pair sheet error", async () => {
const reference = makeReference();
vi.doMock("../capture/contactSheet.js", () => ({
createContactSheet: vi.fn(async () => {
throw new Error("Unable to decode image for pair sheet");
}),
}));
await expect(runCommand(snapshotCommand, againstArgs(reference))).rejects.toThrow(
"Command failed",
);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining("Snapshot failed: Unable to decode image for pair sheet"),
);
expect(capture.closeBrowser).toHaveBeenCalledOnce();
expect(capture.closeServer).toHaveBeenCalledOnce();
});
});
it("keeps and records the captured frames when another process holds history", async () => {
const args = {
rawArgs: [
+34 -14
View File
@@ -60,6 +60,7 @@ import {
} from "../utils/lintProject.js";
import { formatLintFindings } from "../utils/lintFormat.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { describeSharpLoadFailure } from "../utils/sharpLoadFailure.js";
import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
import { c } from "../ui/colors.js";
import { writeCaptureFileSync } from "../capture/captureFile.js";
@@ -595,6 +596,7 @@ async function captureSnapshots(
const server = await serveCapturePage(projectDir, html, opts.autoProxy);
const savedPaths: string[] = [];
let pairSheetFailure: string | null = null;
try {
const {
@@ -659,7 +661,7 @@ async function captureSnapshots(
});
if (duration <= 0 && !opts.at?.length) {
return { paths: [], warnings };
return { paths: [], warnings, pairSheetsSkipped: false };
}
const readTiming = () =>
@@ -1019,14 +1021,24 @@ async function captureSnapshots(
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,
maxImages: 2,
cellWidth: 960,
labelMode: "custom",
labels: ["render", "reference"],
});
if (!pairSheetFailure) {
try {
const { createContactSheet } = await import("../capture/contactSheet.js");
await createContactSheet([framePath, refPath], pairPath, {
cols: 2,
maxImages: 2,
cellWidth: 960,
labelMode: "custom",
labels: ["render", "reference"],
});
} catch (err) {
pairSheetFailure = describeSharpLoadFailure(err);
if (!pairSheetFailure) throw err;
console.error(
` ${c.warn("⚠")} Reference pair sheets skipped: ${pairSheetFailure}`,
);
}
}
}
}
// Only the capture itself is a "snapshot": the reference frame and the
@@ -1061,6 +1073,7 @@ async function captureSnapshots(
return {
paths: savedPaths,
warnings,
pairSheetsSkipped: pairSheetFailure !== null,
...(review && reviewList
? {
review: {
@@ -1080,6 +1093,8 @@ interface CapturedSnapshots {
paths: string[];
/** Why a frame may differ from the composition: shown on the console and in `--json`. */
warnings: string[];
/** --against wrote no pair sheets because sharp could not load. */
pairSheetsSkipped: boolean;
review?: { plan: ReviewPlan; frames: Array<ReviewFrame & { path: string }> };
}
@@ -1431,7 +1446,9 @@ export default defineCommand({
}
if (against) {
log(
` ${c.dim("ref-*.png + pair-*.jpg")} beside each frame (reference frame, render | reference sheet)`,
captured.pairSheetsSkipped
? ` ${c.dim("ref-*.png")} beside each frame (reference frame)`
: ` ${c.dim("ref-*.png + pair-*.jpg")} beside each frame (reference frame, render | reference sheet)`,
);
}
@@ -1465,7 +1482,7 @@ export default defineCommand({
}
} catch (err) {
console.warn(
` ${c.warn("⚠")} Contact sheet skipped: ${normalizeErrorMessage(err)}. Individual snapshot PNGs are still available.`,
` ${c.warn("⚠")} Contact sheet skipped: ${describeSharpLoadFailure(err) ?? normalizeErrorMessage(err)}. Individual snapshot PNGs are still available.`,
);
}
@@ -1502,10 +1519,11 @@ export default defineCommand({
// limit. Full 1920×1080 PNGs are typically 3-6 MB. Use sharp if it is
// already there (never installed for this); otherwise skip files over the limit.
let sharp: ReturnType<typeof loadInstalledSharp> = null;
let sharpFailure: string | null = null;
try {
sharp = loadInstalledSharp("--describe");
} catch {
/* a copy that cannot load — fall back to size check */
} catch (err) {
sharpFailure = describeSharpLoadFailure(err);
}
const results = await Promise.allSettled(
@@ -1523,7 +1541,9 @@ export default defineCommand({
if (raw.length > 3_800_000)
return {
filename,
desc: "file too large for Gemini — install sharp to enable auto-resize",
desc: sharpFailure
? "file too large for Gemini — sharp failed to load, so it was not resized"
: "file too large for Gemini — install sharp to enable auto-resize",
};
imageData = raw;
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { describeSharpLoadFailure } from "./sharpLoadFailure.js";
// The shape of the error sharp throws when no native binding loads (sharp/dist/sharp.mjs).
function sharpLoadError(...causes: string[]): Error {
return new Error(
[
'Could not load the "sharp" module using the win32-x64 runtime',
...causes,
"Possible solutions:",
"- Ensure optional dependencies can be installed:",
" npm install --include=optional sharp",
].join("\n"),
);
}
describe("describeSharpLoadFailure", () => {
it("names Windows App Control when Code Integrity blocked the DLL", () => {
const err = sharpLoadError(
"ERR_DLOPEN_FAILED: \\\\?\\C:\\npm\\node_modules\\@img\\sharp-win32-x64\\sharp.node: An Application Control policy has blocked this file.",
);
expect(describeSharpLoadFailure(err)).toMatch(/^Windows App Control \(Smart App Control/);
});
it("keeps sharp's own first cause for any other failed load", () => {
const err = sharpLoadError("ERR_DLOPEN_FAILED: The specified module could not be found.");
expect(describeSharpLoadFailure(err)).toBe(
'Could not load the "sharp" module using the win32-x64 runtime (ERR_DLOPEN_FAILED: The specified module could not be found.)',
);
});
it("drops the carriage return Windows error text ends its lines with", () => {
const err = new Error(
'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:',
);
expect(describeSharpLoadFailure(err)).toBe(
'Could not load the "sharp" module using the win32-x64 runtime (ERR_DLOPEN_FAILED: %1 is not a valid Win32 application.)',
);
});
it("falls back to sharp's header when every cause was a missing package", () => {
expect(describeSharpLoadFailure(sharpLoadError())).toBe(
'Could not load the "sharp" module using the win32-x64 runtime',
);
});
it("finds sharp's error inside the CLI's own could-not-load wrapper", () => {
const err = new Error(
`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`,
);
expect(describeSharpLoadFailure(err)).toBe(
'Could not load the "sharp" module using the win32-x64 runtime (ERR_DLOPEN_FAILED: The specified module could not be found.)',
);
});
it("ignores errors that are not a failed sharp load", () => {
expect(describeSharpLoadFailure(new Error("ENOENT: no such file"))).toBeNull();
expect(describeSharpLoadFailure("Input file is missing")).toBeNull();
});
});
@@ -0,0 +1,19 @@
import { normalizeErrorMessage } from "./errorMessage.js";
const SHARP_LOAD_FAILURE = 'Could not load the "sharp" module';
// Win32 error 4551, raised for Smart App Control and App Control for Business policies alike.
const APPLICATION_CONTROL_BLOCK = /An Application Control policy has blocked this file/i;
/** Why sharp's native library failed to load (also inside loadSharp's wrapper), as one line, or null. */
export function describeSharpLoadFailure(error: unknown): string | null {
const full = normalizeErrorMessage(error);
const start = full.indexOf(SHARP_LOAD_FAILURE);
if (start < 0) return null;
const message = full.slice(start);
if (APPLICATION_CONTROL_BLOCK.test(message)) {
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";
}
const [header = message, cause = ""] = message.split(/\r?\n/);
const reason = cause.trim();
return reason && !reason.startsWith("Possible solutions") ? `${header} (${reason})` : header;
}