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