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