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

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

Also fixes:
- Replace basename() with sanitizeFilename() in all AI tool routes
  (prevents double-extension attacks and adds length truncation)
- Add UUID format validation for clientJobId field
- Fix missing filename sanitization in noise-removal (was using raw
  user-supplied filename with zero sanitization)
- Remove em dash from error message in use-tool-processor
This commit is contained in:
SnapOtter
2026-05-01 00:11:03 +08:00
parent 4900d8a4fe
commit d12b1c0fc6
18 changed files with 818 additions and 628 deletions
+87 -66
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto"; import { 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,21 +78,23 @@ 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}` });
} }
let settings: z.infer<typeof settingsSchema>;
try { try {
let settings: z.infer<typeof settingsSchema>; const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
try { const result = settingsSchema.safeParse(parsed);
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; if (!result.success) {
const result = settingsSchema.safeParse(parsed); return reply
if (!result.success) { .status(400)
return reply .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
} }
settings = result.data;
} catch {
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;
request.log.info(
{
toolId: "blur-faces",
imageSize: fileBuffer.length,
blurRadius,
sensitivity,
},
"Starting face blur",
);
fileBuffer = await autoOrient(fileBuffer); fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "blur-faces" }, "Input decoding failed");
return reply.status(422).send({
error: "Face blur failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const jobId = randomUUID(); const originalSize = fileBuffer.length;
const workspacePath = await createWorkspace(jobId); const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
// Save input let workspacePath: string;
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: "blur-faces" }, "Workspace creation failed");
return reply.status(422).send({
error: "Face blur failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
// Process const log = request.log;
const jobIdForProgress = clientJobId; log.info(
const onProgress = jobIdForProgress { toolId: "blur-faces", imageSize: originalSize, blurRadius, sensitivity },
? (percent: number, stage: string) => { "Starting face blur",
updateSingleFileProgress({ );
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
});
}
: undefined;
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
const result = await blurFaces( 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: {
} jobId,
downloadUrl,
originalSize,
processedSize: outputBuffer.length,
facesDetected: result.facesDetected,
faces: result.faces,
...(result.facesDetected === 0 && {
warning: "No faces detected in this image. Try increasing detection sensitivity.",
}),
},
});
return reply.send({ log.info({ toolId: "blur-faces", jobId, downloadUrl }, "Face blur complete");
jobId, })().catch((err) => {
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`, log.error({ err, toolId: "blur-faces" }, "Face blur failed");
originalSize: fileBuffer.length, updateSingleFileProgress({
processedSize: outputBuffer.length, jobId: progressJobId,
facesDetected: result.facesDetected, phase: "failed",
faces: result.faces, percent: 0,
...(result.facesDetected === 0 && { error: err instanceof Error ? err.message : "Face blur failed",
warning: "No faces detected in this image. Try increasing detection sensitivity.",
}),
}); });
} 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
+86 -61
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,28 +82,23 @@ 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}` });
} }
let settings: z.infer<typeof settingsSchema>;
try { try {
let settings: z.infer<typeof settingsSchema>; const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
try { const result = settingsSchema.safeParse(parsed);
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; if (!result.success) {
const result = settingsSchema.safeParse(parsed); return reply
if (!result.success) { .status(400)
return reply .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
} }
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const { intensity, model } = settings; const { intensity, model } = settings;
request.log.info(
{ toolId: "colorize", imageSize: fileBuffer.length, intensity, model },
"Starting colorization",
);
try {
// 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 jobId = randomUUID(); const originalSize = fileBuffer.length;
const workspacePath = await createWorkspace(jobId); const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
// Save input let workspacePath: string;
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",
updateSingleFileProgress({ );
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
});
}
: undefined;
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
// Process with Python sidecar // 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)}`;
jobId, updateSingleFileProgress({
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`, jobId: progressJobId,
previewUrl, phase: "complete",
originalSize: fileBuffer.length, percent: 100,
processedSize: outputBuffer.length, result: {
width: result.width, jobId,
height: result.height, downloadUrl,
method: result.method, previewUrl,
originalSize,
processedSize: outputBuffer.length,
width: result.width,
height: result.height,
method: result.method,
},
}); });
} catch (err) {
request.log.error({ err, toolId: "colorize" }, "Colorization failed"); log.info({ toolId: "colorize", jobId, downloadUrl }, "Colorize complete");
return reply.status(422).send({ })().catch((err) => {
error: "Colorization failed", log.error({ err, toolId: "colorize" }, "Colorization failed");
details: err instanceof Error ? err.message : "Unknown error", updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Colorization failed",
}); });
} });
}); });
// Register in the pipeline/batch registry // Register in the pipeline/batch registry
+86 -60
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,27 +79,23 @@ 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}` });
} }
let settings: z.infer<typeof settingsSchema>;
try { try {
let settings: z.infer<typeof settingsSchema>; const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
try { const result = settingsSchema.safeParse(parsed);
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; if (!result.success) {
const result = settingsSchema.safeParse(parsed); return reply
if (!result.success) { .status(400)
return reply .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
} }
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
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 jobId = randomUUID(); const originalSize = fileBuffer.length;
const workspacePath = await createWorkspace(jobId); const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
// Save input let workspacePath: string;
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",
updateSingleFileProgress({ );
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
});
}
: undefined;
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
const result = await enhanceFaces( 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)}`;
jobId, updateSingleFileProgress({
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`, jobId: progressJobId,
previewUrl, phase: "complete",
originalSize: fileBuffer.length, percent: 100,
processedSize: result.buffer.length, result: {
facesDetected: result.facesDetected, jobId,
faces: result.faces, downloadUrl,
model: result.model, previewUrl,
originalSize,
processedSize: result.buffer.length,
facesDetected: result.facesDetected,
faces: result.faces,
model: result.model,
},
}); });
} catch (err) {
request.log.error({ err, toolId: "enhance-faces" }, "Face enhancement failed"); log.info({ toolId: "enhance-faces", jobId, downloadUrl }, "Face enhancement complete");
return reply.status(422).send({ })().catch((err) => {
error: "Face enhancement failed", log.error({ err, toolId: "enhance-faces" }, "Face enhancement failed");
details: err instanceof Error ? err.message : "Unknown error", updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Face enhancement failed",
}); });
} });
}); });
// Register in the pipeline/batch registry so this tool can be used // Register in the pipeline/batch registry so this tool can be used
+93 -68
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto"; import { 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,36 +113,26 @@ 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}` });
} }
// Validate format and quality via Zod
const settingsResult = settingsSchema.safeParse({ format, quality });
if (!settingsResult.success) {
return reply.status(400).send({
error: "Invalid settings",
details: settingsResult.error.issues
.map((i) => (i.path.length > 0 ? `${i.path.join(".")}: ${i.message}` : i.message))
.join("; "),
});
}
format = settingsResult.data.format;
quality = settingsResult.data.quality;
if (format === "auto") {
const detected = await resolveOutputFormat(imageBuffer, filename);
format = detected.format === "jpeg" ? "jpg" : detected.format;
quality = detected.quality;
}
try { try {
// Validate format and quality via Zod
const settingsResult = settingsSchema.safeParse({ format, quality });
if (!settingsResult.success) {
return reply.status(400).send({
error: "Invalid settings",
details: settingsResult.error.issues
.map((i) => (i.path.length > 0 ? `${i.path.join(".")}: ${i.message}` : i.message))
.join("; "),
});
}
format = settingsResult.data.format;
quality = settingsResult.data.quality;
if (format === "auto") {
const detected = await resolveOutputFormat(imageBuffer, filename);
format = detected.format === "jpeg" ? "jpg" : detected.format;
quality = detected.quality;
}
request.log.info(
{
toolId: "erase-object",
imageSize: imageBuffer.length,
maskSize: maskBuffer.length,
format,
},
"Starting object erasure",
);
// Decode HEIC/HEIF input via system decoder // 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 jobId = randomUUID(); const originalSize = imageBuffer.length;
const workspacePath = await createWorkspace(jobId); const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
// Save input let workspacePath: string;
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",
updateSingleFileProgress({ imageSize: originalSize,
jobId: jobIdForProgress, maskSize: maskBuffer.length,
phase: "processing", format,
stage, },
percent, "Starting object erasure",
}); );
}
: undefined;
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
const resultBuffer = await inpaint( 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: {
} jobId,
downloadUrl,
previewUrl,
originalSize,
processedSize: outputBuffer.length,
},
});
return reply.send({ log.info({ toolId: "erase-object", jobId, downloadUrl }, "Object erasure complete");
jobId, })().catch((err) => {
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`, log.error({ err, toolId: "erase-object" }, "Object erasing failed");
previewUrl, updateSingleFileProgress({
originalSize: imageBuffer.length, jobId: progressJobId,
processedSize: outputBuffer.length, phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Object erasing failed",
}); });
} catch (err) { });
request.log.error({ err, toolId: "erase-object" }, "Object erasing failed");
return reply.status(422).send({
error: "Object erasing failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
}); });
} }
+76 -57
View File
@@ -9,6 +9,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js"; import { 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,55 +83,68 @@ 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}` });
} }
let parsed: z.infer<typeof settingsSchema>;
try { try {
let parsed: z.infer<typeof settingsSchema>; const raw = settingsRaw ? JSON.parse(settingsRaw) : {};
try { const result = settingsSchema.safeParse(raw);
const raw = settingsRaw ? JSON.parse(settingsRaw) : {}; if (!result.success) {
const result = settingsSchema.safeParse(raw); return reply
if (!result.success) { .status(400)
return reply .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
parsed = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
} }
parsed = result.data;
} catch {
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 jobId = randomUUID(); const originalSize = fileBuffer.length;
const workspacePath = await createWorkspace(jobId); const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
} catch (err) {
request.log.error({ err, toolId: "noise-removal" }, "Workspace creation failed");
return reply.status(422).send({
error: "Noise removal failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
// Progress callback const 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",
updateSingleFileProgress({ );
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
});
}
: undefined;
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
(async () => {
const result = await noiseRemoval( 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: {
} jobId,
downloadUrl,
originalSize,
processedSize: result.buffer.length,
},
});
return reply.send({ log.info({ toolId: "noise-removal", jobId, downloadUrl }, "Noise removal complete");
jobId, })().catch((err) => {
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`, log.error({ err, toolId: "noise-removal" }, "Noise removal failed");
originalSize: fileBuffer.length, updateSingleFileProgress({
processedSize: result.buffer.length, 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
+84 -63
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,21 +80,23 @@ 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}` });
} }
let settings: z.infer<typeof settingsSchema>;
try { try {
let settings: z.infer<typeof settingsSchema>; const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
try { const result = settingsSchema.safeParse(parsed);
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; if (!result.success) {
const result = settingsSchema.safeParse(parsed); return reply
if (!result.success) { .status(400)
return reply .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
} }
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const { sensitivity, strength, format: outputFormat, quality } = settings;
try {
if (validation.format === "heif") { 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;
request.log.info(
{
toolId: "red-eye-removal",
imageSize: fileBuffer.length,
sensitivity,
strength,
},
"Starting red eye removal",
);
fileBuffer = await autoOrient(fileBuffer); fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "red-eye-removal" }, "Input decoding failed");
return reply.status(422).send({
error: "Red eye removal failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const jobId = randomUUID(); const originalSize = fileBuffer.length;
const workspacePath = await createWorkspace(jobId); const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
// Save input let workspacePath: string;
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: "red-eye-removal" }, "Workspace creation failed");
return reply.status(422).send({
error: "Red eye removal failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
// Process const log = request.log;
const jobIdForProgress = clientJobId; log.info(
const onProgress = jobIdForProgress { toolId: "red-eye-removal", imageSize: originalSize, sensitivity, strength },
? (percent: number, stage: string) => { "Starting red eye removal",
updateSingleFileProgress({ );
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
});
}
: undefined;
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
const result = await removeRedEye( 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: {
} jobId,
downloadUrl,
originalSize,
processedSize: result.buffer.length,
facesDetected: result.facesDetected,
eyesCorrected: result.eyesCorrected,
},
});
return reply.send({ log.info({ toolId: "red-eye-removal", jobId, downloadUrl }, "Red eye removal complete");
jobId, })().catch((err) => {
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`, log.error({ err, toolId: "red-eye-removal" }, "Red eye removal failed");
originalSize: fileBuffer.length, updateSingleFileProgress({
processedSize: result.buffer.length, jobId: progressJobId,
facesDetected: result.facesDetected, phase: "failed",
eyesCorrected: result.eyesCorrected, percent: 0,
error: err instanceof Error ? err.message : "Red eye removal failed",
}); });
} catch (err) { });
request.log.error({ err, toolId: "red-eye-removal" }, "Red eye removal failed");
return reply.status(422).send({
error: "Red eye removal failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
}, },
); );
+89 -59
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto"; import { 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,21 +96,21 @@ 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}` });
} }
let settings: z.infer<typeof settingsSchema>;
try { try {
let settings: z.infer<typeof settingsSchema>; const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
try { const result = settingsSchema.safeParse(parsed);
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; if (!result.success) {
const result = settingsSchema.safeParse(parsed); return reply
if (!result.success) { .status(400)
return reply .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
} }
settings = result.data;
} catch {
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 }, const jobId = randomUUID();
"Starting background removal", const progressJobId = clientJobId || jobId;
); let workspacePath: string;
const jobId = randomUUID(); try {
const workspacePath = await createWorkspace(jobId); workspacePath = await createWorkspace(jobId);
// Save input
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",
updateSingleFileProgress({ );
jobId: jobIdForProgress,
phase: "processing",
stage,
percent: Math.min(percent, 95),
});
}
: undefined;
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent: Math.min(percent, 95),
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
// Phase 1: AI background removal -> transparent PNG // 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)}`;
updateSingleFileProgress({ const maskUrl = `/api/v1/download/${jobId}/${encodeURIComponent(maskFilename)}`;
jobId: clientJobId, const originalUrl = `/api/v1/download/${jobId}/${encodeURIComponent(originalFilename)}`;
phase: "complete",
percent: 100,
});
}
return reply.send({ updateSingleFileProgress({
jobId, jobId: progressJobId,
// The mask (transparent PNG) is the main preview phase: "complete",
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(maskFilename)}`, percent: 100,
// Separate URLs for frontend CSS preview compositing result: {
maskUrl: `/api/v1/download/${jobId}/${encodeURIComponent(maskFilename)}`, jobId,
originalUrl: `/api/v1/download/${jobId}/${encodeURIComponent(originalFilename)}`, downloadUrl,
originalSize: fileBuffer.length, maskUrl,
processedSize: transparentResult.length, originalUrl,
filename, originalSize,
model: settings.model, processedSize: transparentResult.length,
filename,
model: settings.model,
},
}); });
} catch (err) {
request.log.error({ err, toolId: "remove-background" }, "Background removal failed"); log.info(
return reply.status(422).send({ { toolId: "remove-background", jobId, downloadUrl },
error: "Background removal failed", "Background removal complete",
details: err instanceof Error ? err.message : "Unknown error", );
})().catch((err) => {
log.error({ err, toolId: "remove-background" }, "Background removal failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Background removal failed",
}); });
} });
}, },
); );
+88 -63
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto"; import { 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,26 +86,21 @@ 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}` });
} }
let settings: z.infer<typeof settingsSchema>;
try { try {
let settings: z.infer<typeof settingsSchema>; const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
try { const result = settingsSchema.safeParse(parsed);
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {}; if (!result.success) {
const result = settingsSchema.safeParse(parsed); return reply
if (!result.success) { .status(400)
return reply .send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
} }
settings = result.data;
} catch {
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 jobId = randomUUID(); const originalSize = fileBuffer.length;
const workspacePath = await createWorkspace(jobId); const jobId = randomUUID();
const progressJobId = clientJobId || jobId;
// Save input let workspacePath: string;
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",
updateSingleFileProgress({ );
jobId: jobIdForProgress,
phase: "processing",
stage,
percent,
});
}
: undefined;
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
// Process with Python sidecar // Process with Python sidecar
const result = await restorePhoto( const result = await restorePhoto(
fileBuffer, fileBuffer,
@@ -182,35 +205,37 @@ 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: {
} jobId,
downloadUrl,
previewUrl,
originalSize,
processedSize: outputBuffer.length,
width: result.width,
height: result.height,
steps: result.steps,
scratchCoverage: result.scratchCoverage,
facesEnhanced: result.facesEnhanced,
isGrayscale: result.isGrayscale,
colorized: result.colorized,
},
});
return reply.send({ log.info({ toolId: "restore-photo", jobId, downloadUrl }, "Photo restoration complete");
jobId, })().catch((err) => {
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`, log.error({ err, toolId: "restore-photo" }, "Photo restoration failed");
previewUrl, updateSingleFileProgress({
originalSize: fileBuffer.length, jobId: progressJobId,
processedSize: outputBuffer.length, phase: "failed",
width: result.width, percent: 0,
height: result.height, error: err instanceof Error ? err.message : "Photo restoration failed",
steps: result.steps,
scratchCoverage: result.scratchCoverage,
facesEnhanced: result.facesEnhanced,
isGrayscale: result.isGrayscale,
colorized: result.colorized,
}); });
} 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) ─────────────────────────────────