fix(image-tools): surface Sharp encode failures instead of "Error: Error" (#534)

Wrap convert and gif-tools process functions so a Sharp .toBuffer() failure carries an authored SafeError title (and the original as cause) rather than a scrubbed "Error: Error".
This commit is contained in:
SnapOtter
2026-07-16 19:26:11 +08:00
committed by GitHub
parent a2cb1a8261
commit 9cccbc9576
4 changed files with 312 additions and 190 deletions
+40
View File
@@ -0,0 +1,40 @@
import { isSafeMessageError, isToolInputError, SafeError } from "@snapotter/shared";
import type { ToolProcessCtx } from "../routes/tool-factory.js";
type ImageProcess<T> = (
inputBuffer: Buffer,
settings: T,
filename: string,
ctx?: ToolProcessCtx,
) => Promise<{ buffer: Buffer; filename: string; contentType: string }>;
/**
* Wrap an image tool's process function so an otherwise-opaque failure (most
* often a Sharp `.toBuffer()` that throws an empty-message Error) surfaces a
* safe, authored title instead of "Error: Error".
*
* The API's Sentry scrubber replaces any non-SafeError message with a type-only
* value (see `rebuildErrorValue`), so a bare Sharp failure is undiagnosable.
* Re-throwing as a SafeError makes the title survive while the original error is
* kept as `cause`, preserving its stack and exact location. Errors we already
* author (SafeError) or that flag bad user input (ToolInputError) pass through
* untouched so their class is not masked.
*/
export function withImageEncodeContext<T>(
message: string,
codeOf: (settings: T) => string,
process: ImageProcess<T>,
): ImageProcess<T> {
return async (inputBuffer, settings, filename, ctx) => {
try {
return await process(inputBuffer, settings, filename, ctx);
} catch (err) {
if (isSafeMessageError(err) || isToolInputError(err)) throw err;
throw new SafeError(message, {
kind: "bug",
code: codeOf(settings),
cause: err instanceof Error ? err : new Error(String(err)),
});
}
};
}
+58 -53
View File
@@ -19,6 +19,7 @@ import {
encodeTga, encodeTga,
} from "../../lib/format-encoders.js"; } from "../../lib/format-encoders.js";
import { encodeHeic } from "../../lib/heic-converter.js"; import { encodeHeic } from "../../lib/heic-converter.js";
import { withImageEncodeContext } from "../../lib/image-error.js";
import { isSvgBuffer } from "../../lib/svg-sanitize.js"; import { isSvgBuffer } from "../../lib/svg-sanitize.js";
import { createToolRoute } from "../tool-factory.js"; import { createToolRoute } from "../tool-factory.js";
@@ -104,62 +105,66 @@ export function registerConvert(app: FastifyInstance) {
createToolRoute(app, { createToolRoute(app, {
toolId: "convert", toolId: "convert",
settingsSchema, settingsSchema,
process: async (inputBuffer, settings, filename) => { process: withImageEncodeContext<z.infer<typeof settingsSchema>>(
// CLI-encoded formats bypass Sharp entirely "Image conversion failed",
const cliEncoder = CLI_ENCODERS[settings.format]; (s) => s.format,
if (cliEncoder) { async (inputBuffer, settings, filename) => {
const outputBuffer = await cliEncoder(inputBuffer, settings.quality); // CLI-encoded formats bypass Sharp entirely
const cliEncoder = CLI_ENCODERS[settings.format];
if (cliEncoder) {
const outputBuffer = await cliEncoder(inputBuffer, settings.quality);
const ext = extname(filename);
const baseName = ext ? filename.slice(0, -ext.length) : filename;
const contentType = FORMAT_CONTENT_TYPES[settings.format] || "application/octet-stream";
return {
buffer: outputBuffer,
filename: `${baseName}.${settings.format}`,
contentType,
};
}
const inputExt = extname(filename).toLowerCase().replace(".", "");
const sharpOpts: SharpOptions = isSvgBuffer(inputBuffer) ? { density: 300 } : {};
// Preserve animation frames when both input and output are animatable formats
if (ANIMATABLE_FORMATS.has(inputExt) && ANIMATABLE_FORMATS.has(settings.format)) {
sharpOpts.animated = true;
}
const image = sharp(inputBuffer, sharpOpts);
let buffer: Buffer;
if (settings.format === "psd") {
const pngBuffer = await image.png().toBuffer();
const id = randomUUID();
const inputPath = join(tmpdir(), `psd-enc-in-${id}.png`);
const outputPath = join(tmpdir(), `psd-enc-out-${id}.psd`);
try {
await writeFile(inputPath, pngBuffer);
const cmd = await findMagickCmd();
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `psd:${outputPath}`]), {
timeout: 120_000,
});
buffer = await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
}
} else if (settings.format === "heic" || settings.format === "heif") {
const pngBuffer = await image.png().toBuffer();
buffer = await encodeHeic(pngBuffer, settings.quality);
} else {
const result = await convert(image, settings as Parameters<typeof convert>[1]);
buffer = await result.toBuffer();
}
// Change filename extension to match the output format
const ext = extname(filename); const ext = extname(filename);
const baseName = ext ? filename.slice(0, -ext.length) : filename; const baseName = ext ? filename.slice(0, -ext.length) : filename;
const outputFilename = `${baseName}.${settings.format}`;
const contentType = FORMAT_CONTENT_TYPES[settings.format] || "application/octet-stream"; const contentType = FORMAT_CONTENT_TYPES[settings.format] || "application/octet-stream";
return {
buffer: outputBuffer,
filename: `${baseName}.${settings.format}`,
contentType,
};
}
const inputExt = extname(filename).toLowerCase().replace(".", ""); return { buffer, filename: outputFilename, contentType };
const sharpOpts: SharpOptions = isSvgBuffer(inputBuffer) ? { density: 300 } : {}; },
// Preserve animation frames when both input and output are animatable formats ),
if (ANIMATABLE_FORMATS.has(inputExt) && ANIMATABLE_FORMATS.has(settings.format)) {
sharpOpts.animated = true;
}
const image = sharp(inputBuffer, sharpOpts);
let buffer: Buffer;
if (settings.format === "psd") {
const pngBuffer = await image.png().toBuffer();
const id = randomUUID();
const inputPath = join(tmpdir(), `psd-enc-in-${id}.png`);
const outputPath = join(tmpdir(), `psd-enc-out-${id}.psd`);
try {
await writeFile(inputPath, pngBuffer);
const cmd = await findMagickCmd();
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `psd:${outputPath}`]), {
timeout: 120_000,
});
buffer = await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
}
} else if (settings.format === "heic" || settings.format === "heif") {
const pngBuffer = await image.png().toBuffer();
buffer = await encodeHeic(pngBuffer, settings.quality);
} else {
const result = await convert(image, settings as Parameters<typeof convert>[1]);
buffer = await result.toBuffer();
}
// Change filename extension to match the output format
const ext = extname(filename);
const baseName = ext ? filename.slice(0, -ext.length) : filename;
const outputFilename = `${baseName}.${settings.format}`;
const contentType = FORMAT_CONTENT_TYPES[settings.format] || "application/octet-stream";
return { buffer, filename: outputFilename, contentType };
},
}); });
} }
+142 -137
View File
@@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { zipSync } from "fflate"; import { zipSync } from "fflate";
import sharp from "sharp"; import sharp from "sharp";
import { z } from "zod"; import { z } from "zod";
import { withImageEncodeContext } from "../../lib/image-error.js";
import { createToolRoute } from "../tool-factory.js"; import { createToolRoute } from "../tool-factory.js";
/** /**
@@ -146,163 +147,167 @@ export function registerGifTools(app: FastifyInstance) {
createToolRoute(app, { createToolRoute(app, {
toolId: "gif-tools", toolId: "gif-tools",
settingsSchema, settingsSchema,
process: async (inputBuffer, settings, filename) => { process: withImageEncodeContext<z.infer<typeof settingsSchema>>(
const baseName = filename.replace(/\.[^.]+$/, ""); "GIF processing failed",
const loop = settings.loop; (s) => s.mode,
async (inputBuffer, settings, filename) => {
const baseName = filename.replace(/\.[^.]+$/, "");
const loop = settings.loop;
switch (settings.mode) { switch (settings.mode) {
case "resize": { case "resize": {
const image = sharp(inputBuffer, { animated: true }); const image = sharp(inputBuffer, { animated: true });
if (settings.percentage) { if (settings.percentage) {
const meta = await image.metadata(); const meta = await image.metadata();
const w = Math.round(((meta.width ?? 0) * settings.percentage) / 100); const w = Math.round(((meta.width ?? 0) * settings.percentage) / 100);
const h = Math.round( const h = Math.round(
((meta.pageHeight ?? meta.height ?? 0) * settings.percentage) / 100, ((meta.pageHeight ?? meta.height ?? 0) * settings.percentage) / 100,
); );
image.resize(w || undefined, h || undefined, { fit: "inside" }); image.resize(w || undefined, h || undefined, { fit: "inside" });
} else if (settings.width || settings.height) { } else if (settings.width || settings.height) {
image.resize(settings.width, settings.height, { fit: "inside" }); image.resize(settings.width, settings.height, { fit: "inside" });
} }
const buffer = await image.gif({ loop }).toBuffer(); const buffer = await image.gif({ loop }).toBuffer();
return { buffer, filename, contentType: "image/gif" };
}
case "optimize": {
const buffer = await sharp(inputBuffer, { animated: true })
.gif({
effort: settings.effort,
colours: settings.colors,
dither: settings.dither,
loop,
})
.toBuffer();
return { buffer, filename, contentType: "image/gif" };
}
case "speed": {
const meta = await sharp(inputBuffer, { animated: true }).metadata();
const origDelays = meta.delay ?? Array(meta.pages ?? 1).fill(100);
const newDelays = origDelays.map((d: number) =>
Math.max(20, Math.round(d / settings.speedFactor)),
);
const buffer = await sharp(inputBuffer, { animated: true })
.gif({ delay: newDelays, loop })
.toBuffer();
return { buffer, filename, contentType: "image/gif" };
}
case "reverse": {
const meta = await sharp(inputBuffer, { animated: true }).metadata();
const pageCount = meta.pages ?? 1;
const delays = [...(meta.delay ?? Array(pageCount).fill(100))];
if (pageCount <= 1) {
const buffer = await sharp(inputBuffer).gif({ loop }).toBuffer();
return { buffer, filename, contentType: "image/gif" }; return { buffer, filename, contentType: "image/gif" };
} }
delays.reverse(); case "optimize": {
const buffer = await sharp(inputBuffer, { animated: true })
// Apply optional speed adjustment (used when "Also adjust speed" is checked) .gif({
if (settings.speedFactor !== 1.0) { effort: settings.effort,
for (let i = 0; i < delays.length; i++) { colours: settings.colors,
delays[i] = Math.max(20, Math.round(delays[i] / settings.speedFactor)); dither: settings.dither,
} loop,
} })
// Extract each frame as a single-frame GIF with the correct delay,
// then combine into a multi-frame GIF at the binary level.
// This avoids going through raw pixel data, which loses the
// page-height metadata that sharp/libvips needs for animation.
const frameGifs: Buffer[] = [];
for (let i = pageCount - 1; i >= 0; i--) {
const frameBuf = await sharp(inputBuffer, { page: i })
.gif({ delay: [delays[pageCount - 1 - i]], loop })
.toBuffer(); .toBuffer();
frameGifs.push(frameBuf); return { buffer, filename, contentType: "image/gif" };
} }
const buffer = assembleAnimatedGif(frameGifs, loop); case "speed": {
return { buffer, filename, contentType: "image/gif" }; const meta = await sharp(inputBuffer, { animated: true }).metadata();
} const origDelays = meta.delay ?? Array(meta.pages ?? 1).fill(100);
const newDelays = origDelays.map((d: number) =>
Math.max(20, Math.round(d / settings.speedFactor)),
);
const buffer = await sharp(inputBuffer, { animated: true })
.gif({ delay: newDelays, loop })
.toBuffer();
return { buffer, filename, contentType: "image/gif" };
}
case "reverse": {
const meta = await sharp(inputBuffer, { animated: true }).metadata();
const pageCount = meta.pages ?? 1;
const delays = [...(meta.delay ?? Array(pageCount).fill(100))];
if (pageCount <= 1) {
const buffer = await sharp(inputBuffer).gif({ loop }).toBuffer();
return { buffer, filename, contentType: "image/gif" };
}
delays.reverse();
// Apply optional speed adjustment (used when "Also adjust speed" is checked)
if (settings.speedFactor !== 1.0) {
for (let i = 0; i < delays.length; i++) {
delays[i] = Math.max(20, Math.round(delays[i] / settings.speedFactor));
}
}
// Extract each frame as a single-frame GIF with the correct delay,
// then combine into a multi-frame GIF at the binary level.
// This avoids going through raw pixel data, which loses the
// page-height metadata that sharp/libvips needs for animation.
const frameGifs: Buffer[] = [];
for (let i = pageCount - 1; i >= 0; i--) {
const frameBuf = await sharp(inputBuffer, { page: i })
.gif({ delay: [delays[pageCount - 1 - i]], loop })
.toBuffer();
frameGifs.push(frameBuf);
}
const buffer = assembleAnimatedGif(frameGifs, loop);
return { buffer, filename, contentType: "image/gif" };
}
case "extract": {
if (settings.extractMode === "single") {
const frame = sharp(inputBuffer, { page: settings.frameNumber });
const ext = settings.extractFormat;
const buffer =
ext === "webp" ? await frame.webp().toBuffer() : await frame.png().toBuffer();
const outName = `${baseName}_frame${settings.frameNumber}.${ext}`;
return {
buffer,
filename: outName,
contentType: ext === "webp" ? "image/webp" : "image/png",
};
}
// Range or All
const meta = await sharp(inputBuffer).metadata();
const pageCount = meta.pages ?? 1;
const start = settings.extractMode === "all" ? 0 : settings.frameStart;
const end =
settings.extractMode === "all"
? pageCount - 1
: Math.min(settings.frameEnd ?? pageCount - 1, pageCount - 1);
case "extract": {
if (settings.extractMode === "single") {
const frame = sharp(inputBuffer, { page: settings.frameNumber });
const ext = settings.extractFormat; const ext = settings.extractFormat;
const buffer = const files: Record<string, Uint8Array> = {};
ext === "webp" ? await frame.webp().toBuffer() : await frame.png().toBuffer();
const outName = `${baseName}_frame${settings.frameNumber}.${ext}`; for (let i = start; i <= end; i++) {
const frame = sharp(inputBuffer, { page: i });
const buf =
ext === "webp" ? await frame.webp().toBuffer() : await frame.png().toBuffer();
files[`frame_${String(i).padStart(4, "0")}.${ext}`] = new Uint8Array(buf);
}
const zipData = zipSync(files);
const zipBuffer = Buffer.from(zipData);
return { return {
buffer, buffer: zipBuffer,
filename: outName, filename: `${baseName}_frames.zip`,
contentType: ext === "webp" ? "image/webp" : "image/png", contentType: "application/zip",
}; };
} }
// Range or All case "rotate": {
const meta = await sharp(inputBuffer).metadata(); const meta = await sharp(inputBuffer, { animated: true }).metadata();
const pageCount = meta.pages ?? 1; const pageCount = meta.pages ?? 1;
const start = settings.extractMode === "all" ? 0 : settings.frameStart; const delays = meta.delay ?? Array(pageCount).fill(100);
const end =
settings.extractMode === "all"
? pageCount - 1
: Math.min(settings.frameEnd ?? pageCount - 1, pageCount - 1);
const ext = settings.extractFormat; // Sharp cannot rotate multi-page images directly, so process
const files: Record<string, Uint8Array> = {}; // each frame individually and reassemble the animation.
const frameGifs: Buffer[] = [];
for (let i = 0; i < pageCount; i++) {
let frame = sharp(inputBuffer, { page: i });
if (settings.angle) {
frame = frame.rotate(settings.angle);
}
if (settings.flipV) {
frame = frame.flip();
}
if (settings.flipH) {
frame = frame.flop();
}
const frameBuf = await frame.gif({ delay: [delays[i]], loop }).toBuffer();
frameGifs.push(frameBuf);
}
for (let i = start; i <= end; i++) { const buffer = pageCount > 1 ? assembleAnimatedGif(frameGifs, loop) : frameGifs[0];
const frame = sharp(inputBuffer, { page: i }); return { buffer, filename, contentType: "image/gif" };
const buf =
ext === "webp" ? await frame.webp().toBuffer() : await frame.png().toBuffer();
files[`frame_${String(i).padStart(4, "0")}.${ext}`] = new Uint8Array(buf);
} }
const zipData = zipSync(files); default: {
const zipBuffer = Buffer.from(zipData); const buffer = await sharp(inputBuffer, { animated: true }).gif({ loop }).toBuffer();
return { return { buffer, filename, contentType: "image/gif" };
buffer: zipBuffer,
filename: `${baseName}_frames.zip`,
contentType: "application/zip",
};
}
case "rotate": {
const meta = await sharp(inputBuffer, { animated: true }).metadata();
const pageCount = meta.pages ?? 1;
const delays = meta.delay ?? Array(pageCount).fill(100);
// Sharp cannot rotate multi-page images directly, so process
// each frame individually and reassemble the animation.
const frameGifs: Buffer[] = [];
for (let i = 0; i < pageCount; i++) {
let frame = sharp(inputBuffer, { page: i });
if (settings.angle) {
frame = frame.rotate(settings.angle);
}
if (settings.flipV) {
frame = frame.flip();
}
if (settings.flipH) {
frame = frame.flop();
}
const frameBuf = await frame.gif({ delay: [delays[i]], loop }).toBuffer();
frameGifs.push(frameBuf);
} }
const buffer = pageCount > 1 ? assembleAnimatedGif(frameGifs, loop) : frameGifs[0];
return { buffer, filename, contentType: "image/gif" };
} }
},
default: { ),
const buffer = await sharp(inputBuffer, { animated: true }).gif({ loop }).toBuffer();
return { buffer, filename, contentType: "image/gif" };
}
}
},
}); });
} }
+72
View File
@@ -0,0 +1,72 @@
import { isSafeMessageError, markToolInputError, SafeError } from "@snapotter/shared";
import { describe, expect, it } from "vitest";
import { withImageEncodeContext } from "../../../apps/api/src/lib/image-error.js";
interface Settings {
format: string;
}
const settings: Settings = { format: "webp" };
const input = Buffer.from("");
describe("withImageEncodeContext", () => {
it("returns the process result unchanged when it succeeds", async () => {
const wrapped = withImageEncodeContext(
"Image conversion failed",
(s: Settings) => s.format,
async () => ({ buffer: Buffer.from("ok"), filename: "out.webp", contentType: "image/webp" }),
);
const result = await wrapped(input, settings, "in.png");
expect(result.filename).toBe("out.webp");
});
it("wraps an opaque encode failure in a SafeError with the target format as code", async () => {
// Sharp .toBuffer() failures throw an Error whose message is scrubbed to
// type-only ("Error: Error") in Sentry; the wrapper must author a title.
const sharpErr = new Error("");
const wrapped = withImageEncodeContext(
"Image conversion failed",
(s: Settings) => s.format,
async () => {
throw sharpErr;
},
);
let caught: unknown;
try {
await wrapped(input, settings, "in.png");
} catch (e) {
caught = e;
}
expect(isSafeMessageError(caught)).toBe(true);
expect((caught as SafeError).message).toBe("Image conversion failed");
expect((caught as SafeError).kind).toBe("bug");
expect((caught as SafeError).code).toBe("webp");
// Original error kept so its stack/location survives in the cause chain.
expect((caught as SafeError).cause).toBe(sharpErr);
});
it("passes an already-authored SafeError through unchanged (no double-wrap)", async () => {
const inner = new SafeError("Process killed (out of memory)", { kind: "operational" });
const wrapped = withImageEncodeContext(
"Image conversion failed",
() => "webp",
async () => {
throw inner;
},
);
await expect(wrapped(input, settings, "in.png")).rejects.toBe(inner);
});
it("passes a ToolInputError through unchanged (stays a 400, not a masked bug)", async () => {
const inputErr = markToolInputError(new Error("Unsupported input"));
const wrapped = withImageEncodeContext(
"Image conversion failed",
() => "webp",
async () => {
throw inputErr;
},
);
await expect(wrapped(input, settings, "in.png")).rejects.toBe(inputErr);
});
});