fix: harden all AI tools against proxy timeouts and filename attacks

Convert all 9 AI tool routes (colorize, restore-photo, remove-background,
enhance-faces, blur-faces, red-eye-removal, erase-object, noise-removal,
upscale) to async 202 processing so none are vulnerable to proxy
connection timeouts.

Also fixes:
- Replace basename() with sanitizeFilename() in all AI tool routes
  (prevents double-extension attacks and adds length truncation)
- Add UUID format validation for clientJobId field
- Fix missing filename sanitization in noise-removal (was using raw
  user-supplied filename with zero sanitization)
- Remove em dash from error message in use-tool-processor
This commit is contained in:
SnapOtter
2026-05-01 00:11:03 +08:00
parent 4900d8a4fe
commit d12b1c0fc6
18 changed files with 818 additions and 628 deletions
+87 -66
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import { blurFaces } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
@@ -10,6 +10,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic, ensureSharpCompat } from "../../lib/heic-converter.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
@@ -51,11 +52,14 @@ export function registerBlurFaces(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw;
}
}
}
} catch (err) {
@@ -74,21 +78,23 @@ export function registerBlurFaces(app: FastifyInstance) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
let settings: z.infer<typeof settingsSchema>;
try {
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const { blurRadius, sensitivity } = settings;
try {
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
}
@@ -98,39 +104,52 @@ export function registerBlurFaces(app: FastifyInstance) {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
}
const { blurRadius, sensitivity } = settings;
request.log.info(
{
toolId: "blur-faces",
imageSize: fileBuffer.length,
blurRadius,
sensitivity,
},
"Starting face blur",
);
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "blur-faces" }, "Input decoding failed");
return reply.status(422).send({
error: "Face blur failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const originalSize = fileBuffer.length;
const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "blur-faces" }, "Workspace creation failed");
return reply.status(422).send({
error: "Face blur failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
// Process
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
});
}
: undefined;
const log = request.log;
log.info(
{ toolId: "blur-faces", imageSize: originalSize, blurRadius, sensitivity },
"Starting face blur",
);
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
const result = await blurFaces(
fileBuffer,
join(workspacePath, "output"),
@@ -155,32 +174,34 @@ export function registerBlurFaces(app: FastifyInstance) {
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, outputBuffer);
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
originalSize,
processedSize: outputBuffer.length,
facesDetected: result.facesDetected,
faces: result.faces,
...(result.facesDetected === 0 && {
warning: "No faces detected in this image. Try increasing detection sensitivity.",
}),
},
});
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
originalSize: fileBuffer.length,
processedSize: outputBuffer.length,
facesDetected: result.facesDetected,
faces: result.faces,
...(result.facesDetected === 0 && {
warning: "No faces detected in this image. Try increasing detection sensitivity.",
}),
log.info({ toolId: "blur-faces", jobId, downloadUrl }, "Face blur complete");
})().catch((err) => {
log.error({ err, toolId: "blur-faces" }, "Face blur failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Face blur failed",
});
} catch (err) {
request.log.error({ err, toolId: "blur-faces" }, "Face blur failed");
return reply.status(422).send({
error: "Face blur failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
});
// Register in the pipeline/batch registry so this tool can be used
+86 -61
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import { colorize } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
@@ -10,6 +10,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
@@ -55,11 +56,14 @@ export function registerColorize(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw;
}
}
}
} catch (err) {
@@ -78,28 +82,23 @@ export function registerColorize(app: FastifyInstance) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
let settings: z.infer<typeof settingsSchema>;
try {
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const { intensity, model } = settings;
request.log.info(
{ toolId: "colorize", imageSize: fileBuffer.length, intensity, model },
"Starting colorization",
);
const { intensity, model } = settings;
try {
// Decode HEIC/HEIF input
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
@@ -112,27 +111,51 @@ export function registerColorize(app: FastifyInstance) {
// Auto-orient to fix EXIF rotation
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "colorize" }, "Input decoding failed");
return reply.status(422).send({
error: "Colorization failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const originalSize = fileBuffer.length;
const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "colorize" }, "Workspace creation failed");
return reply.status(422).send({
error: "Colorization failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
// Progress callback
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
});
}
: undefined;
const log = request.log;
log.info(
{ toolId: "colorize", imageSize: originalSize, intensity, model },
"Starting colorization",
);
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
// Process with Python sidecar
const result = await colorize(
fileBuffer,
@@ -172,38 +195,40 @@ export function registerColorize(app: FastifyInstance) {
}
}
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
if (model !== "auto" && result.method !== model) {
request.log.warn(
log.warn(
{ toolId: "colorize", requested: model, actual: result.method },
`Colorize model mismatch: requested ${model} but used ${result.method}`,
);
}
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
previewUrl,
originalSize: fileBuffer.length,
processedSize: outputBuffer.length,
width: result.width,
height: result.height,
method: result.method,
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
previewUrl,
originalSize,
processedSize: outputBuffer.length,
width: result.width,
height: result.height,
method: result.method,
},
});
} catch (err) {
request.log.error({ err, toolId: "colorize" }, "Colorization failed");
return reply.status(422).send({
error: "Colorization failed",
details: err instanceof Error ? err.message : "Unknown error",
log.info({ toolId: "colorize", jobId, downloadUrl }, "Colorize complete");
})().catch((err) => {
log.error({ err, toolId: "colorize" }, "Colorization failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Colorization failed",
});
}
});
});
// Register in the pipeline/batch registry
+86 -60
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import { enhanceFaces } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
@@ -10,6 +10,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -52,11 +53,14 @@ export function registerEnhanceFaces(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw;
}
}
}
} catch (err) {
@@ -75,27 +79,23 @@ export function registerEnhanceFaces(app: FastifyInstance) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
let settings: z.infer<typeof settingsSchema>;
try {
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const { model, strength, onlyCenterFace, sensitivity } = settings;
request.log.info(
{ toolId: "enhance-faces", imageSize: fileBuffer.length, model, strength },
"Starting face enhancement",
);
const { model, strength, onlyCenterFace, sensitivity } = settings;
try {
// Decode HEIC/HEIF input via system decoder
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
@@ -108,27 +108,51 @@ export function registerEnhanceFaces(app: FastifyInstance) {
// Auto-orient to fix EXIF rotation before face detection
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "enhance-faces" }, "Input decoding failed");
return reply.status(422).send({
error: "Face enhancement failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const originalSize = fileBuffer.length;
const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "enhance-faces" }, "Workspace creation failed");
return reply.status(422).send({
error: "Face enhancement failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
// Process
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
});
}
: undefined;
const log = request.log;
log.info(
{ toolId: "enhance-faces", imageSize: originalSize, model, strength },
"Starting face enhancement",
);
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
const result = await enhanceFaces(
fileBuffer,
join(workspacePath, "output"),
@@ -152,38 +176,40 @@ export function registerEnhanceFaces(app: FastifyInstance) {
// Non-fatal - frontend will show fallback
}
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
if (model !== "auto" && result.model !== model) {
request.log.warn(
log.warn(
{ toolId: "enhance-faces", requested: model, actual: result.model },
`Face enhance model mismatch: requested ${model} but used ${result.model}`,
);
}
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
previewUrl,
originalSize: fileBuffer.length,
processedSize: result.buffer.length,
facesDetected: result.facesDetected,
faces: result.faces,
model: result.model,
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
previewUrl,
originalSize,
processedSize: result.buffer.length,
facesDetected: result.facesDetected,
faces: result.faces,
model: result.model,
},
});
} catch (err) {
request.log.error({ err, toolId: "enhance-faces" }, "Face enhancement failed");
return reply.status(422).send({
error: "Face enhancement failed",
details: err instanceof Error ? err.message : "Unknown error",
log.info({ toolId: "enhance-faces", jobId, downloadUrl }, "Face enhancement complete");
})().catch((err) => {
log.error({ err, toolId: "enhance-faces" }, "Face enhancement failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Face enhancement failed",
});
}
});
});
// Register in the pipeline/batch registry so this tool can be used
+93 -68
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import { inpaint } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
@@ -9,6 +9,7 @@ import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
@@ -74,10 +75,13 @@ export function registerEraseObject(app: FastifyInstance) {
maskBuffer = buf;
} else {
imageBuffer = buf;
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
}
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw;
}
} else if (part.fieldname === "format") {
format = (part.value as string) || "png";
} else if (part.fieldname === "quality") {
@@ -109,36 +113,26 @@ export function registerEraseObject(app: FastifyInstance) {
return reply.status(400).send({ error: `Invalid mask: ${maskValidation.reason}` });
}
// Validate format and quality via Zod
const settingsResult = settingsSchema.safeParse({ format, quality });
if (!settingsResult.success) {
return reply.status(400).send({
error: "Invalid settings",
details: settingsResult.error.issues
.map((i) => (i.path.length > 0 ? `${i.path.join(".")}: ${i.message}` : i.message))
.join("; "),
});
}
format = settingsResult.data.format;
quality = settingsResult.data.quality;
if (format === "auto") {
const detected = await resolveOutputFormat(imageBuffer, filename);
format = detected.format === "jpeg" ? "jpg" : detected.format;
quality = detected.quality;
}
try {
// Validate format and quality via Zod
const settingsResult = settingsSchema.safeParse({ format, quality });
if (!settingsResult.success) {
return reply.status(400).send({
error: "Invalid settings",
details: settingsResult.error.issues
.map((i) => (i.path.length > 0 ? `${i.path.join(".")}: ${i.message}` : i.message))
.join("; "),
});
}
format = settingsResult.data.format;
quality = settingsResult.data.quality;
if (format === "auto") {
const detected = await resolveOutputFormat(imageBuffer, filename);
format = detected.format === "jpeg" ? "jpg" : detected.format;
quality = detected.quality;
}
request.log.info(
{
toolId: "erase-object",
imageSize: imageBuffer.length,
maskSize: maskBuffer.length,
format,
},
"Starting object erasure",
);
// Decode HEIC/HEIF input via system decoder
if (imageValidation.format === "heif") {
imageBuffer = await decodeHeic(imageBuffer);
@@ -151,27 +145,56 @@ export function registerEraseObject(app: FastifyInstance) {
// Auto-orient to fix EXIF rotation
imageBuffer = await autoOrient(imageBuffer);
} catch (err) {
request.log.error({ err, toolId: "erase-object" }, "Input decoding failed");
return reply.status(422).send({
error: "Object erasing failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const originalSize = imageBuffer.length;
const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, imageBuffer);
} catch (err) {
request.log.error({ err, toolId: "erase-object" }, "Workspace creation failed");
return reply.status(422).send({
error: "Object erasing failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
// Process
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
});
}
: undefined;
const log = request.log;
log.info(
{
toolId: "erase-object",
imageSize: originalSize,
maskSize: maskBuffer.length,
format,
},
"Starting object erasure",
);
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
const resultBuffer = await inpaint(
imageBuffer,
maskBuffer,
@@ -232,27 +255,29 @@ export function registerEraseObject(app: FastifyInstance) {
}
}
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
previewUrl,
originalSize,
processedSize: outputBuffer.length,
},
});
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
previewUrl,
originalSize: imageBuffer.length,
processedSize: outputBuffer.length,
log.info({ toolId: "erase-object", jobId, downloadUrl }, "Object erasure complete");
})().catch((err) => {
log.error({ err, toolId: "erase-object" }, "Object erasing failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Object erasing failed",
});
} catch (err) {
request.log.error({ err, toolId: "erase-object" }, "Object erasing failed");
return reply.status(422).send({
error: "Object erasing failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
});
}
+76 -57
View File
@@ -9,6 +9,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -56,11 +57,14 @@ export function registerNoiseRemoval(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = part.filename ?? "image";
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw;
}
}
}
} catch (err) {
@@ -79,55 +83,68 @@ export function registerNoiseRemoval(app: FastifyInstance) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
let parsed: z.infer<typeof settingsSchema>;
try {
let parsed: z.infer<typeof settingsSchema>;
try {
const raw = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(raw);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
parsed = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
const raw = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(raw);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
parsed = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
request.log.info(
{ toolId: "noise-removal", imageSize: fileBuffer.length, tier: parsed.tier },
"Starting noise removal",
);
// Decode HEIC/HEIF input via system decoder
try {
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
}
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
if (needsCliDecode(validation.format)) {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
}
// Auto-orient to fix EXIF rotation before processing
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "noise-removal" }, "Input decoding failed");
return reply.status(422).send({
error: "Noise removal failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const originalSize = fileBuffer.length;
const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
} catch (err) {
request.log.error({ err, toolId: "noise-removal" }, "Workspace creation failed");
return reply.status(422).send({
error: "Noise removal failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
// Progress callback
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
});
}
: undefined;
const log = request.log;
log.info(
{ toolId: "noise-removal", imageSize: originalSize, tier: parsed.tier },
"Starting noise removal",
);
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
(async () => {
const result = await noiseRemoval(
fileBuffer,
join(workspacePath, "output"),
@@ -147,27 +164,29 @@ export function registerNoiseRemoval(app: FastifyInstance) {
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, result.buffer);
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
originalSize,
processedSize: result.buffer.length,
},
});
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
originalSize: fileBuffer.length,
processedSize: result.buffer.length,
log.info({ toolId: "noise-removal", jobId, downloadUrl }, "Noise removal complete");
})().catch((err) => {
log.error({ err, toolId: "noise-removal" }, "Noise removal failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Noise removal failed",
});
} catch (err) {
request.log.error({ err, toolId: "noise-removal" }, "Noise removal failed");
return reply.status(422).send({
error: "Noise removal failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
});
// Register in the pipeline/batch registry so this tool can be used
+84 -63
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import { removeRedEye } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
@@ -9,6 +9,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic, ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -53,11 +54,14 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw;
}
}
}
} catch (err) {
@@ -76,21 +80,23 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
let settings: z.infer<typeof settingsSchema>;
try {
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const { sensitivity, strength, format: outputFormat, quality } = settings;
try {
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
}
@@ -100,39 +106,52 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
}
const { sensitivity, strength, format: outputFormat, quality } = settings;
request.log.info(
{
toolId: "red-eye-removal",
imageSize: fileBuffer.length,
sensitivity,
strength,
},
"Starting red eye removal",
);
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "red-eye-removal" }, "Input decoding failed");
return reply.status(422).send({
error: "Red eye removal failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const originalSize = fileBuffer.length;
const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "red-eye-removal" }, "Workspace creation failed");
return reply.status(422).send({
error: "Red eye removal failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
// Process
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
});
}
: undefined;
const log = request.log;
log.info(
{ toolId: "red-eye-removal", imageSize: originalSize, sensitivity, strength },
"Starting red eye removal",
);
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
const result = await removeRedEye(
fileBuffer,
join(workspacePath, "output"),
@@ -151,29 +170,31 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, result.buffer);
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
originalSize,
processedSize: result.buffer.length,
facesDetected: result.facesDetected,
eyesCorrected: result.eyesCorrected,
},
});
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
originalSize: fileBuffer.length,
processedSize: result.buffer.length,
facesDetected: result.facesDetected,
eyesCorrected: result.eyesCorrected,
log.info({ toolId: "red-eye-removal", jobId, downloadUrl }, "Red eye removal complete");
})().catch((err) => {
log.error({ err, toolId: "red-eye-removal" }, "Red eye removal failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Red eye removal failed",
});
} catch (err) {
request.log.error({ err, toolId: "red-eye-removal" }, "Red eye removal failed");
return reply.status(422).send({
error: "Red eye removal failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
},
);
+89 -59
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import { removeBackground } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
@@ -10,6 +10,7 @@ import { applyEffects } from "../../lib/bg-effects.js";
import { formatZodErrors } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace, getWorkspacePath } from "../../lib/workspace.js";
@@ -69,11 +70,14 @@ export function registerRemoveBackground(app: FastifyInstance) {
const chunks: Buffer[] = [];
for await (const chunk of part.file) chunks.push(chunk);
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw;
}
}
}
} catch (err) {
@@ -92,21 +96,21 @@ export function registerRemoveBackground(app: FastifyInstance) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
let settings: z.infer<typeof settingsSchema>;
try {
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
try {
// Decode HEIC/HEIF before processing
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
@@ -123,31 +127,51 @@ export function registerRemoveBackground(app: FastifyInstance) {
// Auto-orient to fix EXIF rotation
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "remove-background" }, "Input decoding failed");
return reply.status(422).send({
error: "Background removal failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
request.log.info(
{ toolId: "remove-background", imageSize: fileBuffer.length, model: settings.model },
"Starting background removal",
);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const originalSize = fileBuffer.length;
const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "remove-background" }, "Workspace creation failed");
return reply.status(422).send({
error: "Background removal failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
// Progress callback
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: jobIdForProgress,
phase: "processing",
stage,
percent: Math.min(percent, 95),
});
}
: undefined;
const log = request.log;
log.info(
{ toolId: "remove-background", imageSize: originalSize, model: settings.model },
"Starting background removal",
);
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent: Math.min(percent, 95),
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
// Phase 1: AI background removal -> transparent PNG
const transparentResult = await removeBackground(
fileBuffer,
@@ -162,33 +186,39 @@ export function registerRemoveBackground(app: FastifyInstance) {
await writeFile(join(workspacePath, "output", maskFilename), transparentResult);
await writeFile(join(workspacePath, "output", originalFilename), fileBuffer);
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(maskFilename)}`;
const maskUrl = `/api/v1/download/${jobId}/${encodeURIComponent(maskFilename)}`;
const originalUrl = `/api/v1/download/${jobId}/${encodeURIComponent(originalFilename)}`;
return reply.send({
jobId,
// The mask (transparent PNG) is the main preview
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(maskFilename)}`,
// Separate URLs for frontend CSS preview compositing
maskUrl: `/api/v1/download/${jobId}/${encodeURIComponent(maskFilename)}`,
originalUrl: `/api/v1/download/${jobId}/${encodeURIComponent(originalFilename)}`,
originalSize: fileBuffer.length,
processedSize: transparentResult.length,
filename,
model: settings.model,
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
maskUrl,
originalUrl,
originalSize,
processedSize: transparentResult.length,
filename,
model: settings.model,
},
});
} catch (err) {
request.log.error({ err, toolId: "remove-background" }, "Background removal failed");
return reply.status(422).send({
error: "Background removal failed",
details: err instanceof Error ? err.message : "Unknown error",
log.info(
{ toolId: "remove-background", jobId, downloadUrl },
"Background removal complete",
);
})().catch((err) => {
log.error({ err, toolId: "remove-background" }, "Background removal failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Background removal failed",
});
}
});
},
);
+88 -63
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import { restorePhoto } from "@snapotter/ai";
import { getBundleForTool } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
@@ -10,6 +10,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
@@ -59,11 +60,14 @@ export function registerRestorePhoto(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw;
}
}
}
} catch (err) {
@@ -82,26 +86,21 @@ export function registerRestorePhoto(app: FastifyInstance) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
let settings: z.infer<typeof settingsSchema>;
try {
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
request.log.info(
{ toolId: "restore-photo", imageSize: fileBuffer.length, mode: settings.mode },
"Starting photo restoration",
);
try {
// Decode HEIC/HEIF input
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
@@ -114,27 +113,51 @@ export function registerRestorePhoto(app: FastifyInstance) {
// Auto-orient to fix EXIF rotation
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "restore-photo" }, "Input decoding failed");
return reply.status(422).send({
error: "Photo restoration failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save input
const originalSize = fileBuffer.length;
const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "restore-photo" }, "Workspace creation failed");
return reply.status(422).send({
error: "Photo restoration failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
// Progress callback
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
? (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
});
}
: undefined;
const log = request.log;
log.info(
{ toolId: "restore-photo", imageSize: originalSize, mode: settings.mode },
"Starting photo restoration",
);
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
// Process with Python sidecar
const result = await restorePhoto(
fileBuffer,
@@ -182,35 +205,37 @@ export function registerRestorePhoto(app: FastifyInstance) {
}
}
if (clientJobId) {
updateSingleFileProgress({
jobId: clientJobId,
phase: "complete",
percent: 100,
});
}
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
previewUrl,
originalSize,
processedSize: outputBuffer.length,
width: result.width,
height: result.height,
steps: result.steps,
scratchCoverage: result.scratchCoverage,
facesEnhanced: result.facesEnhanced,
isGrayscale: result.isGrayscale,
colorized: result.colorized,
},
});
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
previewUrl,
originalSize: fileBuffer.length,
processedSize: outputBuffer.length,
width: result.width,
height: result.height,
steps: result.steps,
scratchCoverage: result.scratchCoverage,
facesEnhanced: result.facesEnhanced,
isGrayscale: result.isGrayscale,
colorized: result.colorized,
log.info({ toolId: "restore-photo", jobId, downloadUrl }, "Photo restoration complete");
})().catch((err) => {
log.error({ err, toolId: "restore-photo" }, "Photo restoration failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Photo restoration failed",
});
} catch (err) {
request.log.error({ err, toolId: "restore-photo" }, "Photo restoration failed");
return reply.status(422).send({
error: "Photo restoration failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
});
});
// Register in the pipeline/batch registry
+7 -3
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { join } from "node:path";
import { upscale } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
@@ -10,6 +10,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
@@ -58,11 +59,14 @@ export function registerUpscale(app: FastifyInstance) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = basename(part.filename ?? "image");
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
const raw = part.value as string;
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
clientJobId = raw;
}
}
}
} catch (err) {
+1 -1
View File
@@ -301,7 +301,7 @@ export function useToolProcessor(toolId: string) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setError("Processing was interrupted \u2014 retry when reconnected");
setError("Processing was interrupted. Retry when reconnected.");
setProcessing(false);
setProgress(IDLE_PROGRESS);
};
+15 -15
View File
@@ -33,7 +33,7 @@ afterAll(async () => {
describe("blur-faces", () => {
// ── Processing (sidecar-dependent) ────────────────────────────────
it("responds to the route (200 or 501)", async () => {
it("responds to the route (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
@@ -46,10 +46,10 @@ describe("blur-faces", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("processes with default settings (200 or 501)", async () => {
it("processes with default settings (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
]);
@@ -61,15 +61,15 @@ describe("blur-faces", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
if (res.statusCode === 200) {
const json = JSON.parse(res.body);
expect(json.jobId).toBeDefined();
expect(json.downloadUrl).toBeDefined();
expect([202, 501]).toContain(res.statusCode);
if (res.statusCode === 202) {
const result = JSON.parse(res.body);
expect(result.jobId).toBeDefined();
expect(result.async).toBe(true);
}
}, 60_000);
it("accepts explicit blurRadius and sensitivity (200 or 501)", async () => {
it("accepts explicit blurRadius and sensitivity (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
@@ -85,10 +85,10 @@ describe("blur-faces", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts minimum settings values (200 or 501)", async () => {
it("accepts minimum settings values (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
@@ -104,10 +104,10 @@ describe("blur-faces", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("handles HEIC input (200 or 501)", async () => {
it("handles HEIC input (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.heic", contentType: "image/heic", content: HEIC },
{ name: "settings", content: JSON.stringify({}) },
@@ -120,7 +120,7 @@ describe("blur-faces", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("handles 1x1 pixel input (200, 422, or 501)", async () => {
@@ -137,7 +137,7 @@ describe("blur-faces", () => {
});
// 200 = processed, 422 = processing error, 501 = sidecar not installed
expect([200, 422, 501]).toContain(res.statusCode);
expect([202, 422, 501]).toContain(res.statusCode);
}, 60_000);
// ── Validation (always testable) ──────────────────────────────────
+19 -20
View File
@@ -33,7 +33,7 @@ afterAll(async () => {
describe("colorize", () => {
// ── Processing (sidecar-dependent) ────────────────────────────────
it("responds to the route (200 or 501)", async () => {
it("responds to the route (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
@@ -46,10 +46,10 @@ describe("colorize", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("processes with default settings (200 or 501)", async () => {
it("processes with default settings (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
]);
@@ -61,16 +61,15 @@ describe("colorize", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
if (res.statusCode === 200) {
const json = JSON.parse(res.body);
expect(json.jobId).toBeDefined();
expect(json.downloadUrl).toBeDefined();
expect(json.method).toBeDefined();
expect([202, 501]).toContain(res.statusCode);
if (res.statusCode === 202) {
const result = JSON.parse(res.body);
expect(result.jobId).toBeDefined();
expect(result.async).toBe(true);
}
}, 60_000);
it("accepts explicit intensity and model=auto (200 or 501)", async () => {
it("accepts explicit intensity and model=auto (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
@@ -86,10 +85,10 @@ describe("colorize", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts model=ddcolor (200 or 501)", async () => {
it("accepts model=ddcolor (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
@@ -105,10 +104,10 @@ describe("colorize", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts model=opencv (200 or 501)", async () => {
it("accepts model=opencv (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
@@ -124,10 +123,10 @@ describe("colorize", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts minimum intensity of 0 (200 or 501)", async () => {
it("accepts minimum intensity of 0 (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
@@ -143,10 +142,10 @@ describe("colorize", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("handles HEIC input (200 or 501)", async () => {
it("handles HEIC input (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.heic", contentType: "image/heic", content: HEIC },
{ name: "settings", content: JSON.stringify({}) },
@@ -159,7 +158,7 @@ describe("colorize", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("handles 1x1 pixel input (200, 422, or 501)", async () => {
@@ -175,7 +174,7 @@ describe("colorize", () => {
body,
});
expect([200, 422, 501]).toContain(res.statusCode);
expect([202, 422, 501]).toContain(res.statusCode);
}, 60_000);
// ── Validation (always testable) ──────────────────────────────────
+19 -20
View File
@@ -33,7 +33,7 @@ afterAll(async () => {
describe("enhance-faces", () => {
// ── Processing (sidecar-dependent) ────────────────────────────────
it("responds to the route (200 or 501)", async () => {
it("responds to the route (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
@@ -46,10 +46,10 @@ describe("enhance-faces", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("processes with default settings (200 or 501)", async () => {
it("processes with default settings (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
]);
@@ -61,16 +61,15 @@ describe("enhance-faces", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
if (res.statusCode === 200) {
const json = JSON.parse(res.body);
expect(json.jobId).toBeDefined();
expect(json.downloadUrl).toBeDefined();
expect(json.model).toBeDefined();
expect([202, 501]).toContain(res.statusCode);
if (res.statusCode === 202) {
const result = JSON.parse(res.body);
expect(result.jobId).toBeDefined();
expect(result.async).toBe(true);
}
}, 60_000);
it("accepts model=gfpgan with explicit strength (200 or 501)", async () => {
it("accepts model=gfpgan with explicit strength (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
@@ -86,10 +85,10 @@ describe("enhance-faces", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts model=codeformer (200 or 501)", async () => {
it("accepts model=codeformer (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
@@ -105,10 +104,10 @@ describe("enhance-faces", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts onlyCenterFace=true (200 or 501)", async () => {
it("accepts onlyCenterFace=true (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
@@ -124,10 +123,10 @@ describe("enhance-faces", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts minimum setting values (200 or 501)", async () => {
it("accepts minimum setting values (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
@@ -143,10 +142,10 @@ describe("enhance-faces", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("handles HEIC input (200 or 501)", async () => {
it("handles HEIC input (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.heic", contentType: "image/heic", content: HEIC },
{ name: "settings", content: JSON.stringify({}) },
@@ -159,7 +158,7 @@ describe("enhance-faces", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("handles 1x1 pixel input (200, 422, or 501)", async () => {
@@ -175,7 +174,7 @@ describe("enhance-faces", () => {
body,
});
expect([200, 422, 501]).toContain(res.statusCode);
expect([202, 422, 501]).toContain(res.statusCode);
}, 60_000);
// ── Validation (always testable) ──────────────────────────────────
+17 -18
View File
@@ -35,7 +35,7 @@ afterAll(async () => {
describe("erase-object", () => {
// ── Processing (sidecar-dependent) ────────────────────────────────
it("responds to the route with image and mask (200 or 501)", async () => {
it("responds to the route with image and mask (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "mask", filename: "mask.png", contentType: "image/png", content: MASK },
@@ -48,10 +48,10 @@ describe("erase-object", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("processes with default format and quality (200 or 501)", async () => {
it("processes with default format and quality (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "mask", filename: "mask.png", contentType: "image/png", content: MASK },
@@ -64,16 +64,15 @@ describe("erase-object", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
if (res.statusCode === 200) {
const json = JSON.parse(res.body);
expect(json.jobId).toBeDefined();
expect(json.downloadUrl).toBeDefined();
expect(json.processedSize).toBeGreaterThan(0);
expect([202, 501]).toContain(res.statusCode);
if (res.statusCode === 202) {
const result = JSON.parse(res.body);
expect(result.jobId).toBeDefined();
expect(result.async).toBe(true);
}
}, 60_000);
it("accepts explicit format=jpg and quality=80 (200 or 501)", async () => {
it("accepts explicit format=jpg and quality=80 (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "mask", filename: "mask.png", contentType: "image/png", content: MASK },
@@ -88,10 +87,10 @@ describe("erase-object", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts format=webp (200 or 501)", async () => {
it("accepts format=webp (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "mask", filename: "mask.png", contentType: "image/png", content: MASK },
@@ -105,10 +104,10 @@ describe("erase-object", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("handles HEIC image input (200 or 501)", async () => {
it("handles HEIC image input (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.heic", contentType: "image/heic", content: HEIC },
{ name: "mask", filename: "mask.png", contentType: "image/png", content: MASK },
@@ -121,7 +120,7 @@ describe("erase-object", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("handles 1x1 pixel image input (200, 422, or 501)", async () => {
@@ -137,7 +136,7 @@ describe("erase-object", () => {
body,
});
expect([200, 422, 501]).toContain(res.statusCode);
expect([202, 422, 501]).toContain(res.statusCode);
}, 60_000);
// ── Validation (always testable) ──────────────────────────────────
@@ -232,7 +231,7 @@ describe("erase-object", () => {
expect(res.statusCode).toBe(401);
});
it("accepts format=avif (200 or 501)", async () => {
it("accepts format=avif (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "mask", filename: "mask.png", contentType: "image/png", content: MASK },
@@ -246,6 +245,6 @@ describe("erase-object", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
});
+19 -20
View File
@@ -33,7 +33,7 @@ afterAll(async () => {
describe("noise-removal", () => {
// ── Processing (sidecar-dependent) ────────────────────────────────
it("responds to the route (200 or 501)", async () => {
it("responds to the route (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
@@ -46,10 +46,10 @@ describe("noise-removal", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("processes with default settings (200 or 501)", async () => {
it("processes with default settings (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
]);
@@ -61,16 +61,15 @@ describe("noise-removal", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
if (res.statusCode === 200) {
const json = JSON.parse(res.body);
expect(json.jobId).toBeDefined();
expect(json.downloadUrl).toBeDefined();
expect(json.processedSize).toBeGreaterThan(0);
expect([202, 501]).toContain(res.statusCode);
if (res.statusCode === 202) {
const result = JSON.parse(res.body);
expect(result.jobId).toBeDefined();
expect(result.async).toBe(true);
}
}, 60_000);
it("accepts tier=quick (200 or 501)", async () => {
it("accepts tier=quick (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
@@ -86,10 +85,10 @@ describe("noise-removal", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts tier=quality with explicit strength (200 or 501)", async () => {
it("accepts tier=quality with explicit strength (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
@@ -105,10 +104,10 @@ describe("noise-removal", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts tier=maximum (200 or 501)", async () => {
it("accepts tier=maximum (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
@@ -124,10 +123,10 @@ describe("noise-removal", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts all explicit settings (200 or 501)", async () => {
it("accepts all explicit settings (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
@@ -150,10 +149,10 @@ describe("noise-removal", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("handles HEIC input (200 or 501)", async () => {
it("handles HEIC input (202 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.heic", contentType: "image/heic", content: HEIC },
{ name: "settings", content: JSON.stringify({}) },
@@ -166,7 +165,7 @@ describe("noise-removal", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("handles 1x1 pixel input (200, 422, or 501)", async () => {
@@ -182,7 +181,7 @@ describe("noise-removal", () => {
body,
});
expect([200, 422, 501]).toContain(res.statusCode);
expect([202, 422, 501]).toContain(res.statusCode);
}, 60_000);
// ── Validation (always testable) ──────────────────────────────────
+10 -10
View File
@@ -51,7 +51,7 @@ describe("Red Eye Removal", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts default settings", async () => {
@@ -70,12 +70,12 @@ describe("Red Eye Removal", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
if (res.statusCode === 200) {
if (res.statusCode === 202) {
const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined();
expect(result.processedSize).toBeGreaterThan(0);
expect(result.jobId).toBeDefined();
expect(result.async).toBe(true);
}
if (res.statusCode === 501) {
@@ -103,7 +103,7 @@ describe("Red Eye Removal", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts explicit format and quality", async () => {
@@ -125,7 +125,7 @@ describe("Red Eye Removal", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("processes JPEG input", async () => {
@@ -144,7 +144,7 @@ describe("Red Eye Removal", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("handles HEIC input", async () => {
@@ -163,7 +163,7 @@ describe("Red Eye Removal", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("handles 1x1 pixel input", async () => {
@@ -183,7 +183,7 @@ describe("Red Eye Removal", () => {
});
// AI tool may return 200, 501 (not installed), or 422 (processing error on tiny image)
expect([200, 422, 501]).toContain(res.statusCode);
expect([202, 422, 501]).toContain(res.statusCode);
}, 60_000);
// ── Validation (always testable) ─────────────────────────────────
+11 -13
View File
@@ -52,7 +52,7 @@ describe("Remove Background", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts default settings", async () => {
@@ -71,14 +71,12 @@ describe("Remove Background", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
if (res.statusCode === 200) {
if (res.statusCode === 202) {
const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined();
expect(result.maskUrl).toBeDefined();
expect(result.originalUrl).toBeDefined();
expect(result.processedSize).toBeGreaterThan(0);
expect(result.jobId).toBeDefined();
expect(result.async).toBe(true);
}
if (res.statusCode === 501) {
@@ -106,7 +104,7 @@ describe("Remove Background", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts color background with blur and shadow settings", async () => {
@@ -135,7 +133,7 @@ describe("Remove Background", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts gradient background settings", async () => {
@@ -162,7 +160,7 @@ describe("Remove Background", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("processes JPEG input", async () => {
@@ -181,7 +179,7 @@ describe("Remove Background", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("handles HEIC input", async () => {
@@ -200,7 +198,7 @@ describe("Remove Background", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("handles 1x1 pixel input", async () => {
@@ -219,7 +217,7 @@ describe("Remove Background", () => {
body,
});
expect([200, 422, 501]).toContain(res.statusCode);
expect([202, 422, 501]).toContain(res.statusCode);
}, 60_000);
// ── Phase 2: Effects sub-route ───────────────────────────────────
+11 -11
View File
@@ -50,7 +50,7 @@ describe("Restore Photo", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts default settings", async () => {
@@ -69,12 +69,12 @@ describe("Restore Photo", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
if (res.statusCode === 200) {
if (res.statusCode === 202) {
const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined();
expect(result.processedSize).toBeGreaterThan(0);
expect(result.jobId).toBeDefined();
expect(result.async).toBe(true);
}
if (res.statusCode === 501) {
@@ -108,7 +108,7 @@ describe("Restore Photo", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts heavy mode with colorize enabled", async () => {
@@ -134,7 +134,7 @@ describe("Restore Photo", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts light mode with features disabled", async () => {
@@ -161,7 +161,7 @@ describe("Restore Photo", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("processes JPEG input", async () => {
@@ -180,7 +180,7 @@ describe("Restore Photo", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("handles HEIC input", async () => {
@@ -199,7 +199,7 @@ describe("Restore Photo", () => {
body,
});
expect([200, 501]).toContain(res.statusCode);
expect([202, 501]).toContain(res.statusCode);
}, 60_000);
it("handles 1x1 pixel input", async () => {
@@ -218,7 +218,7 @@ describe("Restore Photo", () => {
body,
});
expect([200, 422, 501]).toContain(res.statusCode);
expect([202, 422, 501]).toContain(res.statusCode);
}, 60_000);
// ── Validation (always testable) ─────────────────────────────────