fix(media): tolerate missing image optimizer for in-limit images

This commit is contained in:
Paul Frederiksen
2026-05-04 05:06:26 +00:00
committed by Peter Steinberger
parent 361737d1f1
commit ac09ec00e8
2 changed files with 83 additions and 1 deletions

View File

@@ -163,6 +163,53 @@ describe("loadWebMedia", () => {
);
});
async function withUnavailableImageOptimizer<T>(fn: () => Promise<T>): Promise<T> {
vi.resetModules();
vi.doMock("./image-ops.js", () => ({
convertHeicToJpeg: vi.fn(async (buffer: Buffer) => buffer),
hasAlphaChannel: vi.fn(async () => {
throw new Error(
"Optional dependency sharp is required for image attachment processing | Cannot find package 'sharp' imported from image-ops.js",
);
}),
optimizeImageToPng: vi.fn(async () => {
throw new Error("should not optimize png");
}),
resizeToJpeg: vi.fn(async () => {
throw new Error("should not resize jpeg");
}),
}));
try {
return await fn();
} finally {
vi.doUnmock("./image-ops.js");
vi.resetModules();
}
}
it("sends an in-limit original image when optional sharp optimization is unavailable", async () => {
await withUnavailableImageOptimizer(async () => {
const { loadWebMedia: loadWebMediaWithMissingOptimizer } = await import("./web-media.js");
const result = await loadWebMediaWithMissingOptimizer(
tinyPngFile,
createLocalWebMediaOptions(),
);
expect(result.kind).toBe("image");
expect(result.contentType).toBe("image/png");
expect(result.fileName).toBe("tiny.png");
expect(result.buffer.equals(Buffer.from(TINY_PNG_BASE64, "base64"))).toBe(true);
});
});
it("does not bypass the size cap when optional sharp optimization is unavailable", async () => {
await withUnavailableImageOptimizer(async () => {
const { loadWebMedia: loadWebMediaWithMissingOptimizer } = await import("./web-media.js");
await expect(
loadWebMediaWithMissingOptimizer(tinyPngFile, { maxBytes: 8, localRoots: [fixtureRoot] }),
).rejects.toThrow(/Optional dependency sharp is required/);
});
});
it("resolves relative local media paths against the provided workspace directory", async () => {
const result = await loadWebMedia("chart.png", {
maxBytes: 1024 * 1024,

View File

@@ -205,6 +205,23 @@ function formatCapReduce(label: string, cap: number, size: number): string {
return `${label} could not be reduced below ${formatMb(cap, 0)}MB (got ${formatMb(size)}MB)`;
}
function isOptionalImageOptimizerUnavailable(err: unknown): boolean {
const messages: string[] = [];
let current: unknown = err;
while (current instanceof Error) {
messages.push(current.message);
current = current.cause;
}
const detail = messages.join("\n").toLowerCase();
return (
detail.includes("optional dependency sharp is required") ||
detail.includes("cannot find package 'sharp'") ||
detail.includes('cannot find package "sharp"') ||
detail.includes("cannot find module 'sharp'") ||
detail.includes('cannot find module "sharp"')
);
}
function isHeicSource(opts: { contentType?: string; fileName?: string }): boolean {
if (opts.contentType && HEIC_MIME_RE.test(opts.contentType.trim())) {
return true;
@@ -392,7 +409,25 @@ async function loadWebMediaInternal(
meta?: { contentType?: string; fileName?: string },
) => {
const originalSize = buffer.length;
const optimized = await optimizeImageWithFallback({ buffer, cap, meta });
let optimized: OptimizedImage;
try {
optimized = await optimizeImageWithFallback({ buffer, cap, meta });
} catch (err) {
if (isOptionalImageOptimizerUnavailable(err) && buffer.length <= cap) {
if (shouldLogVerbose()) {
logVerbose(
`Image optimizer unavailable; sending original ${formatMb(buffer.length)}MB media without optimization`,
);
}
return {
buffer,
contentType: meta?.contentType,
kind: "image" as const,
fileName: meta?.fileName,
};
}
throw err;
}
logOptimizedImage({ originalSize, optimized });
if (optimized.buffer.length > cap) {