mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user