feat(jobs)!: SnapOtter 2.0 phase 2 job spine: async queues, worker pools, object storage, admin dashboard (#217)

This commit is contained in:
SnapOtter
2026-06-13 10:17:13 +08:00
parent 1c724d5d21
commit c451b939c7
130 changed files with 10438 additions and 3592 deletions
+152 -200
View File
@@ -1,39 +1,26 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { outpaint } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { receiveUpload } from "../../lib/upload-stream.js";
import { registerToolProcessFn } from "../tool-factory.js";
const EXT_MAP: Record<string, string> = {
jpeg: "jpg",
jpg: "jpg",
png: "png",
webp: "webp",
tiff: "tiff",
gif: "gif",
avif: "avif",
heic: "heic",
heif: "heif",
jxl: "jxl",
};
const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif", "bmp"]);
const settingsSchema = z.object({
extendTop: z.number().int().min(0).default(0),
extendRight: z.number().int().min(0).default(0),
@@ -48,6 +35,99 @@ const settingsSchema = z.object({
type Settings = z.infer<typeof settingsSchema>;
// ── AI job handler ────────────────────────────────────────────────
registerAiJobHandler("ai-canvas-expand", async (input, data, ctx) => {
const settings = settingsSchema.parse(data.settings);
let format: string = settings.format;
let quality = settings.quality;
if (format === "auto") {
const detected = await resolveOutputFormat(input, data.filename);
format = detected.format === "jpeg" ? "jpg" : detected.format;
quality = detected.quality;
}
const resultBuffer = await outpaint(
input,
{
extendTop: settings.extendTop,
extendRight: settings.extendRight,
extendBottom: settings.extendBottom,
extendLeft: settings.extendLeft,
tier: settings.tier,
},
ctx.scratchDir,
(percent, stage) => ctx.report(percent, stage),
);
// Convert to requested output format
const needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format);
let outputBuffer: Buffer;
let finalFormat = format;
if (needsNodeConversion) {
if (format === "heic" || format === "heif") {
outputBuffer = await encodeHeic(resultBuffer, quality);
finalFormat = format;
} else if (format === "jxl") {
outputBuffer = await encodeJxl(resultBuffer, quality);
finalFormat = "jxl";
} else {
outputBuffer = await sharp(resultBuffer).avif({ quality }).toBuffer();
finalFormat = "avif";
}
} else if (format === "jpg" || format === "jpeg") {
outputBuffer = await sharp(resultBuffer).jpeg({ quality }).toBuffer();
finalFormat = "jpg";
} else if (format === "webp") {
outputBuffer = await sharp(resultBuffer).webp({ quality }).toBuffer();
finalFormat = "webp";
} else if (format === "tiff") {
outputBuffer = await sharp(resultBuffer).tiff({ quality }).toBuffer();
finalFormat = "tiff";
} else if (format === "gif") {
outputBuffer = await sharp(resultBuffer).gif().toBuffer();
finalFormat = "gif";
} else {
outputBuffer = resultBuffer;
finalFormat = "png";
}
const EXT_MAP: Record<string, string> = {
jpeg: "jpg",
jpg: "jpg",
png: "png",
webp: "webp",
tiff: "tiff",
gif: "gif",
avif: "avif",
heic: "heic",
heif: "heif",
jxl: "jxl",
};
const ext = EXT_MAP[finalFormat] || "png";
const outputFilename = `${data.filename.replace(/\.[^.]+$/, "")}_extended.${ext}`;
const CONTENT_TYPES: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
webp: "image/webp",
tiff: "image/tiff",
gif: "image/gif",
avif: "image/avif",
heic: "image/heic",
heif: "image/heif",
jxl: "image/jxl",
};
return {
buffer: outputBuffer,
filename: outputFilename,
contentType: CONTENT_TYPES[finalFormat] || "image/png",
};
});
export function registerAiCanvasExpand(app: FastifyInstance) {
app.post(
"/api/v1/tools/ai-canvas-expand",
@@ -64,21 +144,20 @@ export function registerAiCanvasExpand(app: FastifyInstance) {
});
}
const jobId = randomUUID();
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let inputKey: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = sanitizeFilename(part.filename ?? "image");
const upload = await receiveUpload(part, jobId);
inputKey = upload.key;
filename = upload.filename;
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
@@ -91,14 +170,16 @@ export function registerAiCanvasExpand(app: FastifyInstance) {
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
fileBuffer = await getObjectBuffer(inputKey);
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
@@ -131,216 +212,87 @@ export function registerAiCanvasExpand(app: FastifyInstance) {
});
}
let format: string = settings.format;
let quality = settings.quality;
if (format === "auto") {
const detected = await resolveOutputFormat(fileBuffer, filename);
format = detected.format === "jpeg" ? "jpg" : detected.format;
quality = detected.quality;
}
try {
// Decode HEIC/HEIF input
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
}
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
if (needsCliDecode(validation.format)) {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
}
// Auto-orient to fix EXIF rotation
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "ai-canvas-expand" }, "Input decoding failed");
return reply.status(422).send({
error: "AI canvas expand failed",
details: err instanceof Error ? err.message : "Unknown error",
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
});
}
const originalSize = fileBuffer.length;
const jobId = randomUUID();
const decodedKey = `uploads/${jobId}/${filename}`;
if (decodedKey !== inputKey) {
await putObject(decodedKey, fileBuffer);
inputKey = decodedKey;
} else {
await putObject(inputKey, fileBuffer);
}
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "ai-canvas-expand" }, "Workspace creation failed");
return reply.status(422).send({
error: "AI canvas expand failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const log = request.log;
log.info(
{
toolId: "ai-canvas-expand",
imageSize: originalSize,
extendTop: settings.extendTop,
extendRight: settings.extendRight,
extendBottom: settings.extendBottom,
extendLeft: settings.extendLeft,
tier: settings.tier,
format,
},
"Starting AI canvas expand",
);
// 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 outpaint(
fileBuffer,
{
extendTop: settings.extendTop,
extendRight: settings.extendRight,
extendBottom: settings.extendBottom,
extendLeft: settings.extendLeft,
tier: settings.tier,
},
join(workspacePath, "output"),
onProgress,
);
// Convert to the requested output format using Sharp
const needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format);
let outputBuffer: Buffer;
let finalFormat = format;
if (needsNodeConversion) {
if (format === "heic" || format === "heif") {
outputBuffer = await encodeHeic(resultBuffer, quality);
finalFormat = format;
} else if (format === "jxl") {
outputBuffer = await encodeJxl(resultBuffer, quality);
finalFormat = "jxl";
} else {
outputBuffer = await sharp(resultBuffer).avif({ quality }).toBuffer();
finalFormat = "avif";
}
} else if (format === "jpg" || format === "jpeg") {
outputBuffer = await sharp(resultBuffer).jpeg({ quality }).toBuffer();
finalFormat = "jpg";
} else if (format === "webp") {
outputBuffer = await sharp(resultBuffer).webp({ quality }).toBuffer();
finalFormat = "webp";
} else if (format === "tiff") {
outputBuffer = await sharp(resultBuffer).tiff({ quality }).toBuffer();
finalFormat = "tiff";
} else if (format === "gif") {
outputBuffer = await sharp(resultBuffer).gif().toBuffer();
finalFormat = "gif";
} else {
outputBuffer = resultBuffer;
finalFormat = "png";
}
// Save output
const ext = EXT_MAP[finalFormat] || "png";
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_extended.${ext}`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, outputBuffer);
// Generate browser-compatible preview for non-previewable formats
let previewUrl: string | undefined;
if (!BROWSER_PREVIEWABLE.has(finalFormat)) {
try {
const previewInput =
finalFormat === "heic" || finalFormat === "heif"
? await decodeHeic(outputBuffer)
: outputBuffer;
const previewBuffer = await sharp(previewInput).webp({ quality: 80 }).toBuffer();
const previewPath = join(workspacePath, "output", "preview.webp");
await writeFile(previewPath, previewBuffer);
previewUrl = `/api/v1/download/${jobId}/preview.webp`;
} catch {
// Non-fatal - frontend will show fallback
}
}
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
previewUrl,
originalSize,
processedSize: outputBuffer.length,
},
});
log.info({ toolId: "ai-canvas-expand", jobId, downloadUrl }, "AI canvas expand complete");
})().catch((err) => {
log.error({ err, toolId: "ai-canvas-expand" }, "AI canvas expand failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "AI canvas expand failed",
});
await enqueueToolJob({
jobId,
toolId,
userId: null,
pool: "ai",
inputRefs: [inputKey],
filename,
settings,
clientJobId: clientJobId ?? undefined,
kind: "ai-tool",
});
return reply.status(202).send({ jobId: progressJobId, async: true });
},
);
// Register in the pipeline/batch registry so this tool can be used
// as a step in automation pipelines (without progress callbacks).
// Register in the pipeline/batch registry
registerToolProcessFn({
toolId: "ai-canvas-expand",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
process: async (inputBuffer, settings, filename, ctx) => {
const s = settings as Settings;
// Decode HEIC/HEIF for pipeline/batch mode
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
let buf = inputBuffer;
if (["heic", "heif", "hif"].includes(ext)) {
buf = await decodeHeic(buf);
}
// Decode CLI-decoded formats for pipeline/batch mode
const cliCheck = await validateImageBuffer(inputBuffer, filename);
if (cliCheck.valid && needsCliDecode(cliCheck.format)) {
buf = await decodeToSharpCompat(inputBuffer, cliCheck.format);
}
const orientedBuffer = await autoOrient(buf);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID());
const needsCleanup = !ctx?.scratchDir;
if (needsCleanup) await mkdir(scratchDir, { recursive: true });
try {
const resultBuffer = await outpaint(
orientedBuffer,
{
extendTop: s.extendTop,
extendRight: s.extendRight,
extendBottom: s.extendBottom,
extendLeft: s.extendLeft,
tier: s.tier,
},
scratchDir,
);
const resultBuffer = await outpaint(
orientedBuffer,
{
extendTop: s.extendTop,
extendRight: s.extendRight,
extendBottom: s.extendBottom,
extendLeft: s.extendLeft,
tier: s.tier,
},
join(workspacePath, "output"),
);
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_extended.png`;
return { buffer: resultBuffer, filename: outputFilename, contentType: "image/png" };
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_extended.png`;
return { buffer: resultBuffer, filename: outputFilename, contentType: "image/png" };
} finally {
if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
}
},
});
}
+3 -8
View File
@@ -1,6 +1,4 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
@@ -11,8 +9,8 @@ import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { putObject } from "../../lib/object-storage.js";
import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js";
import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
tryHarder: z.boolean().default(true),
@@ -233,25 +231,22 @@ export function registerBarcodeRead(app: FastifyInstance) {
// --- Generate annotated image ---
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
// Save original input
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
await putObject(`uploads/${jobId}/${filename}`, fileBuffer);
// Build SVG overlay with bounding boxes
const overlaySvg = buildOverlaySvg(width, height, barcodes);
const stem = filename.replace(/\.[^.]+$/, "");
const outputFilename = `annotated-${stem}.png`;
const outputPath = join(workspacePath, "output", outputFilename);
const annotatedBuffer = await sharp(fileBuffer)
.composite([{ input: Buffer.from(overlaySvg), top: 0, left: 0 }])
.png()
.toBuffer();
await writeFile(outputPath, annotatedBuffer);
await putObject(`outputs/${jobId}/${outputFilename}`, annotatedBuffer);
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
+2 -6
View File
@@ -1,6 +1,4 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { autoOrient } from "../../lib/auto-orient.js";
@@ -23,8 +21,8 @@ import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { putObject } from "../../lib/object-storage.js";
import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js";
import { createWorkspace } from "../../lib/workspace.js";
import { registerToolProcessFn } from "../tool-factory.js";
const ALPHA_FORMATS = new Set(["png", "webp", "avif"]);
@@ -308,9 +306,7 @@ export function registerBeautify(app: FastifyInstance) {
const outFilename = resolveOutputFilename(filename, settings);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", outFilename);
await writeFile(outputPath, outputBuf);
await putObject(`outputs/${jobId}/${outFilename}`, outputBuf);
return reply.send({
jobId,
+100 -127
View File
@@ -1,21 +1,23 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { blurFaces } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { receiveUpload } from "../../lib/upload-stream.js";
import { registerToolProcessFn } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -23,6 +25,43 @@ const settingsSchema = z.object({
sensitivity: z.number().min(0).max(1).default(0.5),
});
// ── AI job handler (runs inside the BullMQ worker) ────────────────
registerAiJobHandler("blur-faces", async (input, data, ctx) => {
const settings = settingsSchema.parse(data.settings);
const { blurRadius, sensitivity } = settings;
const result = await blurFaces(
input,
ctx.scratchDir,
{ blurRadius, sensitivity },
(percent, stage) => ctx.report(percent, stage),
);
const outputFormat = await resolveOutputFormat(input, data.filename);
let outputBuffer = result.buffer;
if (outputFormat.format !== "png") {
outputBuffer = await sharp(result.buffer)
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
}
const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format;
const outputFilename = `${data.filename.replace(/\.[^.]+$/, "")}_blurred.${ext}`;
return {
buffer: outputBuffer,
filename: outputFilename,
contentType: outputFormat.contentType,
resultPayload: {
facesDetected: result.facesDetected,
faces: result.faces,
...(result.facesDetected === 0 && {
warning: "No faces detected in this image. Try increasing detection sensitivity.",
}),
},
};
});
/** Face detection and blurring route. */
export function registerBlurFaces(app: FastifyInstance) {
app.post("/api/v1/tools/blur-faces", async (request: FastifyRequest, reply: FastifyReply) => {
@@ -38,21 +77,20 @@ export function registerBlurFaces(app: FastifyInstance) {
});
}
const jobId = randomUUID();
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let inputKey: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = sanitizeFilename(part.filename ?? "image");
const upload = await receiveUpload(part, jobId);
inputKey = upload.key;
filename = upload.filename;
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
@@ -65,14 +103,16 @@ export function registerBlurFaces(app: FastifyInstance) {
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
fileBuffer = await getObjectBuffer(inputKey);
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
@@ -92,127 +132,55 @@ export function registerBlurFaces(app: FastifyInstance) {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const { blurRadius, sensitivity } = settings;
try {
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
}
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
if (needsCliDecode(validation.format)) {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
}
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",
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
});
}
const originalSize = fileBuffer.length;
const jobId = randomUUID();
const decodedKey = `uploads/${jobId}/${filename}`;
if (decodedKey !== inputKey) {
await putObject(decodedKey, fileBuffer);
inputKey = decodedKey;
} else {
await putObject(inputKey, fileBuffer);
}
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "blur-faces" }, "Workspace creation failed");
return reply.status(422).send({
error: "Face blur failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const log = request.log;
log.info(
{ toolId: "blur-faces", imageSize: originalSize, blurRadius, sensitivity },
"Starting face blur",
);
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
const result = await blurFaces(
fileBuffer,
join(workspacePath, "output"),
{
blurRadius,
sensitivity,
},
onProgress,
);
// Resolve output format to match input
const outputFormat = await resolveOutputFormat(fileBuffer, filename);
let outputBuffer = result.buffer;
if (outputFormat.format !== "png") {
outputBuffer = await sharp(result.buffer)
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
}
const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format;
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_blurred.${ext}`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, outputBuffer);
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
originalSize,
processedSize: outputBuffer.length,
facesDetected: result.facesDetected,
faces: result.faces,
...(result.facesDetected === 0 && {
warning: "No faces detected in this image. Try increasing detection sensitivity.",
}),
},
});
log.info({ toolId: "blur-faces", jobId, downloadUrl }, "Face blur complete");
})().catch((err) => {
log.error({ err, toolId: "blur-faces" }, "Face blur failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Face blur failed",
});
await enqueueToolJob({
jobId,
toolId,
userId: null,
pool: "ai",
inputRefs: [inputKey],
filename,
settings,
clientJobId: clientJobId ?? undefined,
kind: "ai-tool",
});
return reply.status(202).send({ jobId: progressJobId, async: true });
});
// Register in the pipeline/batch registry so this tool can be used
// as a step in automation pipelines (without progress callbacks).
// Register in the pipeline/batch registry
registerToolProcessFn({
toolId: "blur-faces",
settingsSchema: z.object({
blurRadius: z.number().min(1).max(100).default(30),
sensitivity: z.number().min(0).max(1).default(0.5),
}),
process: async (inputBuffer, settings, filename) => {
process: async (inputBuffer, settings, filename, ctx) => {
const s = settings as { blurRadius?: number; sensitivity?: number };
let decoded = inputBuffer;
const validation = await validateImageBuffer(decoded, filename);
@@ -227,26 +195,31 @@ export function registerBlurFaces(app: FastifyInstance) {
}
}
const orientedBuffer = await autoOrient(decoded);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const result = await blurFaces(orientedBuffer, join(workspacePath, "output"), {
blurRadius: s.blurRadius ?? 30,
sensitivity: s.sensitivity ?? 0.5,
});
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
let outputBuffer = result.buffer;
if (outputFormat.format !== "png") {
outputBuffer = await sharp(result.buffer)
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID());
const needsCleanup = !ctx?.scratchDir;
if (needsCleanup) await mkdir(scratchDir, { recursive: true });
try {
const result = await blurFaces(orientedBuffer, scratchDir, {
blurRadius: s.blurRadius ?? 30,
sensitivity: s.sensitivity ?? 0.5,
});
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
let outputBuffer = result.buffer;
if (outputFormat.format !== "png") {
outputBuffer = await sharp(result.buffer)
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
}
const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format;
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_blurred.${ext}`;
return {
buffer: outputBuffer,
filename: outputFilename,
contentType: outputFormat.contentType,
};
} finally {
if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
}
const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format;
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_blurred.${ext}`;
return {
buffer: outputBuffer,
filename: outputFilename,
contentType: outputFormat.contentType,
};
},
});
}
+2 -6
View File
@@ -1,6 +1,4 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
@@ -11,8 +9,8 @@ import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { putObject } from "../../lib/object-storage.js";
import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js";
import { createWorkspace } from "../../lib/workspace.js";
// ── Template definitions (mirrors the frontend) ─────────────────────
// We only need the grid proportions and cell definitions here.
@@ -694,10 +692,8 @@ export function registerCollage(app: FastifyInstance) {
const finalBuffer = outputExt === "jxl" ? await encodeJxl(result, settings.quality) : result;
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const filename = `collage.${outputExt}`;
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, finalBuffer);
await putObject(`outputs/${jobId}/${filename}`, finalBuffer);
return reply.send({
jobId,
+85 -137
View File
@@ -1,21 +1,23 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { colorize } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { receiveUpload } from "../../lib/upload-stream.js";
import { registerToolProcessFn } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -23,6 +25,40 @@ const settingsSchema = z.object({
model: z.enum(["auto", "ddcolor", "opencv"]).default("auto"),
});
// ── AI job handler ────────────────────────────────────────────────
registerAiJobHandler("colorize", async (input, data, ctx) => {
const settings = settingsSchema.parse(data.settings);
const result = await colorize(
input,
ctx.scratchDir,
{ intensity: settings.intensity, model: settings.model },
(percent, stage) => ctx.report(percent, stage),
);
const outputFormat = await resolveOutputFormat(input, data.filename);
let outputBuffer = result.buffer;
if (outputFormat.format !== "png") {
outputBuffer = await sharp(result.buffer)
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
}
const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format;
const outputFilename = `${data.filename.replace(/\.[^.]+$/, "")}_colorized.${ext}`;
return {
buffer: outputBuffer,
filename: outputFilename,
contentType: outputFormat.contentType,
resultPayload: {
width: result.width,
height: result.height,
method: result.method,
},
};
});
/**
* AI photo colorization route.
* Converts B&W / grayscale photos to full color using DDColor,
@@ -42,21 +78,20 @@ export function registerColorize(app: FastifyInstance) {
});
}
const jobId = randomUUID();
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let inputKey: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = sanitizeFilename(part.filename ?? "image");
const upload = await receiveUpload(part, jobId);
inputKey = upload.key;
filename = upload.filename;
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
@@ -69,14 +104,16 @@ export function registerColorize(app: FastifyInstance) {
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
fileBuffer = await getObjectBuffer(inputKey);
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
@@ -96,139 +133,45 @@ export function registerColorize(app: FastifyInstance) {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const { intensity, model } = settings;
try {
// Decode HEIC/HEIF input
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
}
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
if (needsCliDecode(validation.format)) {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
}
// Auto-orient to fix EXIF rotation
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",
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
});
}
const originalSize = fileBuffer.length;
const jobId = randomUUID();
const decodedKey = `uploads/${jobId}/${filename}`;
if (decodedKey !== inputKey) {
await putObject(decodedKey, fileBuffer);
inputKey = decodedKey;
} else {
await putObject(inputKey, fileBuffer);
}
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "colorize" }, "Workspace creation failed");
return reply.status(422).send({
error: "Colorization failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const log = request.log;
log.info(
{ toolId: "colorize", imageSize: originalSize, intensity, model },
"Starting colorization",
);
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
// Process with Python sidecar
const result = await colorize(
fileBuffer,
join(workspacePath, "output"),
{ intensity, model },
onProgress,
);
// Resolve output format to match input
const outputFormat = await resolveOutputFormat(fileBuffer, filename);
let outputBuffer = result.buffer;
// Convert from PNG (Python output) to target format
if (outputFormat.format !== "png") {
outputBuffer = await sharp(result.buffer)
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
}
// Save output
const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format;
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_colorized.${ext}`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, outputBuffer);
// Generate browser-compatible preview for non-previewable formats
const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif", "bmp"]);
let previewUrl: string | undefined;
if (!BROWSER_PREVIEWABLE.has(ext)) {
try {
const previewBuffer = await sharp(outputBuffer).webp({ quality: 80 }).toBuffer();
const previewPath = join(workspacePath, "output", "preview.webp");
await writeFile(previewPath, previewBuffer);
previewUrl = `/api/v1/download/${jobId}/preview.webp`;
} catch {
// Non-fatal
}
}
if (model !== "auto" && result.method !== model) {
log.warn(
{ toolId: "colorize", requested: model, actual: result.method },
`Colorize model mismatch: requested ${model} but used ${result.method}`,
);
}
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
previewUrl,
originalSize,
processedSize: outputBuffer.length,
width: result.width,
height: result.height,
method: result.method,
},
});
log.info({ toolId: "colorize", jobId, downloadUrl }, "Colorize complete");
})().catch((err) => {
log.error({ err, toolId: "colorize" }, "Colorization failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Colorization failed",
});
await enqueueToolJob({
jobId,
toolId,
userId: null,
pool: "ai",
inputRefs: [inputKey],
filename,
settings,
clientJobId: clientJobId ?? undefined,
kind: "ai-tool",
});
return reply.status(202).send({ jobId: progressJobId, async: true });
});
// Register in the pipeline/batch registry
@@ -238,16 +181,21 @@ export function registerColorize(app: FastifyInstance) {
intensity: z.number().min(0).max(1).default(1.0),
model: z.enum(["auto", "ddcolor", "opencv"]).default("auto"),
}),
process: async (inputBuffer, settings, filename) => {
process: async (inputBuffer, settings, filename, ctx) => {
const orientedBuffer = await autoOrient(inputBuffer);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const result = await colorize(orientedBuffer, join(workspacePath, "output"), {
intensity: (settings as { intensity?: number }).intensity ?? 1.0,
model: (settings as { model?: string }).model ?? "auto",
});
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_colorized.png`;
return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" };
const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID());
const needsCleanup = !ctx?.scratchDir;
if (needsCleanup) await mkdir(scratchDir, { recursive: true });
try {
const result = await colorize(orientedBuffer, scratchDir, {
intensity: (settings as { intensity?: number }).intensity ?? 1.0,
model: (settings as { model?: string }).model ?? "auto",
});
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_colorized.png`;
return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" };
} finally {
if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
}
},
});
}
+2 -6
View File
@@ -1,14 +1,12 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { autoOrient } from "../../lib/auto-orient.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { putObject } from "../../lib/object-storage.js";
import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js";
import { createWorkspace } from "../../lib/workspace.js";
/**
* Compare two images: compute a pixel-level diff and similarity score.
@@ -179,10 +177,8 @@ export function registerCompare(app: FastifyInstance) {
.toBuffer();
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const diffFilename = "diff.png";
const outputPath = join(workspacePath, "output", diffFilename);
await writeFile(outputPath, diffBuffer);
await putObject(`outputs/${jobId}/${diffFilename}`, diffBuffer);
return reply.send({
jobId,
+2 -6
View File
@@ -1,6 +1,4 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
@@ -10,8 +8,8 @@ import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { putObject } from "../../lib/object-storage.js";
import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js";
import { createWorkspace } from "../../lib/workspace.js";
async function decodeBuffer(inputBuffer: Buffer, filename: string): Promise<Buffer> {
const validation = await validateImageBuffer(inputBuffer, filename);
@@ -150,9 +148,7 @@ export function registerCompose(app: FastifyInstance) {
.toBuffer();
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, result);
await putObject(`outputs/${jobId}/${filename}`, result);
return reply.send({
jobId,
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { seamCarve } from "@snapotter/ai";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
@@ -10,7 +11,7 @@ import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
import { putObject } from "../../lib/object-storage.js";
import { registerToolProcessFn } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -127,35 +128,38 @@ export function registerContentAwareResize(app: FastifyInstance) {
fileBuffer = await autoOrient(fileBuffer);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const scratchDir = join(tmpdir(), "snapotter-scratch", jobId);
await mkdir(scratchDir, { recursive: true });
// Save input
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
try {
// Save input to object storage
await putObject(`uploads/${jobId}/${filename}`, fileBuffer);
// Process with caire
const result = await seamCarve(fileBuffer, join(workspacePath, "output"), {
width: settings.width,
height: settings.height,
protectFaces: settings.protectFaces,
blurRadius: settings.blurRadius,
sobelThreshold: settings.sobelThreshold,
square: settings.square,
});
// Process with caire
const result = await seamCarve(fileBuffer, scratchDir, {
width: settings.width,
height: settings.height,
protectFaces: settings.protectFaces,
blurRadius: settings.blurRadius,
sobelThreshold: settings.sobelThreshold,
square: settings.square,
});
// Save output
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_seam.png`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, result.buffer);
// Save output to object storage
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_seam.png`;
await putObject(`outputs/${jobId}/${outputFilename}`, result.buffer);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
originalSize: fileBuffer.length,
processedSize: result.buffer.length,
width: result.width,
height: result.height,
});
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
originalSize: fileBuffer.length,
processedSize: result.buffer.length,
width: result.width,
height: result.height,
});
} finally {
await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
}
} catch (err) {
request.log.error({ err, toolId: "content-aware-resize" }, "Content-aware resize failed");
return reply.status(422).send({
@@ -170,7 +174,7 @@ export function registerContentAwareResize(app: FastifyInstance) {
registerToolProcessFn({
toolId: "content-aware-resize",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
process: async (inputBuffer, settings, filename, ctx) => {
const s = settings as Settings;
// Decode HEIC/HEIF for pipeline/batch mode
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
@@ -184,18 +188,23 @@ export function registerContentAwareResize(app: FastifyInstance) {
buf = await decodeToSharpCompat(inputBuffer, cliCheck.format);
}
const orientedBuffer = await autoOrient(buf);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const result = await seamCarve(orientedBuffer, join(workspacePath, "output"), {
width: s.width,
height: s.height,
protectFaces: s.protectFaces,
blurRadius: s.blurRadius,
sobelThreshold: s.sobelThreshold,
square: s.square,
});
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_seam.png`;
return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" };
const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID());
const needsCleanup = !ctx?.scratchDir;
if (needsCleanup) await mkdir(scratchDir, { recursive: true });
try {
const result = await seamCarve(orientedBuffer, scratchDir, {
width: s.width,
height: s.height,
protectFaces: s.protectFaces,
blurRadius: s.blurRadius,
sobelThreshold: s.sobelThreshold,
square: s.square,
});
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_seam.png`;
return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" };
} finally {
if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
}
},
});
}
+4 -9
View File
@@ -1,6 +1,4 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
@@ -14,7 +12,7 @@ import {
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
import { putObject } from "../../lib/object-storage.js";
import { registerToolProcessFn } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -177,11 +175,9 @@ export function registerEditMetadata(app: FastifyInstance) {
// Determine content type from validated format
const contentType = MIME_BY_FORMAT[validation.format] ?? "image/jpeg";
// Create workspace and save output
// Save output to object storage
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, outputBuffer);
await putObject(`outputs/${jobId}/${filename}`, outputBuffer);
// Generate preview for non-browser-previewable formats (HEIF, TIFF)
let previewUrl: string | undefined;
@@ -192,8 +188,7 @@ export function registerEditMetadata(app: FastifyInstance) {
previewInput = await decodeHeic(outputBuffer);
}
const previewBuffer = await sharp(previewInput).webp({ quality: 80 }).toBuffer();
const previewPath = join(workspacePath, "output", "preview.webp");
await writeFile(previewPath, previewBuffer);
await putObject(`outputs/${jobId}/preview.webp`, previewBuffer);
previewUrl = `/api/v1/download/${jobId}/preview.webp`;
} catch {
// Non-fatal - frontend shows fallback
+84 -126
View File
@@ -1,20 +1,21 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { enhanceFaces } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { receiveUpload } from "../../lib/upload-stream.js";
import { registerToolProcessFn } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -24,6 +25,36 @@ const settingsSchema = z.object({
sensitivity: z.number().min(0).max(1).default(0.5),
});
// ── AI job handler ────────────────────────────────────────────────
registerAiJobHandler("enhance-faces", async (input, data, ctx) => {
const settings = settingsSchema.parse(data.settings);
const result = await enhanceFaces(
input,
ctx.scratchDir,
{
model: settings.model,
strength: settings.strength,
onlyCenterFace: settings.onlyCenterFace,
sensitivity: settings.sensitivity,
},
(percent, stage) => ctx.report(percent, stage),
);
const outputFilename = `${data.filename.replace(/\.[^.]+$/, "")}_enhanced.png`;
return {
buffer: result.buffer,
filename: outputFilename,
contentType: "image/png",
resultPayload: {
facesDetected: result.facesDetected,
faces: result.faces,
model: result.model,
},
};
});
/** Face enhancement route using GFPGAN/CodeFormer. */
export function registerEnhanceFaces(app: FastifyInstance) {
app.post("/api/v1/tools/enhance-faces", async (request: FastifyRequest, reply: FastifyReply) => {
@@ -39,21 +70,20 @@ export function registerEnhanceFaces(app: FastifyInstance) {
});
}
const jobId = randomUUID();
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let inputKey: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = sanitizeFilename(part.filename ?? "image");
const upload = await receiveUpload(part, jobId);
inputKey = upload.key;
filename = upload.filename;
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
@@ -66,14 +96,16 @@ export function registerEnhanceFaces(app: FastifyInstance) {
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
fileBuffer = await getObjectBuffer(inputKey);
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
@@ -93,127 +125,48 @@ export function registerEnhanceFaces(app: FastifyInstance) {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const { model, strength, onlyCenterFace, sensitivity } = settings;
try {
// Decode HEIC/HEIF input via system decoder
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
}
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
if (needsCliDecode(validation.format)) {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
}
// Auto-orient to fix EXIF rotation before face detection
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "enhance-faces" }, "Input decoding failed");
return reply.status(422).send({
error: "Face enhancement failed",
details: err instanceof Error ? err.message : "Unknown error",
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
});
}
const originalSize = fileBuffer.length;
const jobId = randomUUID();
const decodedKey = `uploads/${jobId}/${filename}`;
if (decodedKey !== inputKey) {
await putObject(decodedKey, fileBuffer);
inputKey = decodedKey;
} else {
await putObject(inputKey, fileBuffer);
}
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "enhance-faces" }, "Workspace creation failed");
return reply.status(422).send({
error: "Face enhancement failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const log = request.log;
log.info(
{ toolId: "enhance-faces", imageSize: originalSize, model, strength },
"Starting face enhancement",
);
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
const result = await enhanceFaces(
fileBuffer,
join(workspacePath, "output"),
{ model, strength, onlyCenterFace, sensitivity },
onProgress,
);
// Save output
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_enhanced.png`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, result.buffer);
// Generate webp preview for the frontend
let previewUrl: string | undefined;
try {
const previewBuffer = await sharp(result.buffer).webp({ quality: 80 }).toBuffer();
const previewPath = join(workspacePath, "output", "preview.webp");
await writeFile(previewPath, previewBuffer);
previewUrl = `/api/v1/download/${jobId}/preview.webp`;
} catch {
// Non-fatal - frontend will show fallback
}
if (model !== "auto" && result.model !== model) {
log.warn(
{ toolId: "enhance-faces", requested: model, actual: result.model },
`Face enhance model mismatch: requested ${model} but used ${result.model}`,
);
}
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
previewUrl,
originalSize,
processedSize: result.buffer.length,
facesDetected: result.facesDetected,
faces: result.faces,
model: result.model,
},
});
log.info({ toolId: "enhance-faces", jobId, downloadUrl }, "Face enhancement complete");
})().catch((err) => {
log.error({ err, toolId: "enhance-faces" }, "Face enhancement failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Face enhancement failed",
});
await enqueueToolJob({
jobId,
toolId,
userId: null,
pool: "ai",
inputRefs: [inputKey],
filename,
settings,
clientJobId: clientJobId ?? undefined,
kind: "ai-tool",
});
return reply.status(202).send({ jobId: progressJobId, async: true });
});
// Register in the pipeline/batch registry so this tool can be used
// as a step in automation pipelines (without progress callbacks).
// Register in the pipeline/batch registry
registerToolProcessFn({
toolId: "enhance-faces",
settingsSchema: z.object({
@@ -222,7 +175,7 @@ export function registerEnhanceFaces(app: FastifyInstance) {
onlyCenterFace: z.boolean().default(false),
sensitivity: z.number().min(0).max(1).default(0.5),
}),
process: async (inputBuffer, settings, filename) => {
process: async (inputBuffer, settings, filename, ctx) => {
const s = settings as {
model?: "auto" | "gfpgan" | "codeformer";
strength?: number;
@@ -230,16 +183,21 @@ export function registerEnhanceFaces(app: FastifyInstance) {
sensitivity?: number;
};
const orientedBuffer = await autoOrient(inputBuffer);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const result = await enhanceFaces(orientedBuffer, join(workspacePath, "output"), {
model: s.model ?? "auto",
strength: s.strength ?? 0.8,
onlyCenterFace: s.onlyCenterFace ?? false,
sensitivity: s.sensitivity ?? 0.5,
});
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_enhanced.png`;
return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" };
const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID());
const needsCleanup = !ctx?.scratchDir;
if (needsCleanup) await mkdir(scratchDir, { recursive: true });
try {
const result = await enhanceFaces(orientedBuffer, scratchDir, {
model: s.model ?? "auto",
strength: s.strength ?? 0.8,
onlyCenterFace: s.onlyCenterFace ?? false,
sensitivity: s.sensitivity ?? 0.5,
});
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_enhanced.png`;
return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" };
} finally {
if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
}
},
});
}
+128 -163
View File
@@ -1,36 +1,20 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { inpaint } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
const EXT_MAP: Record<string, string> = {
jpeg: "jpg",
jpg: "jpg",
png: "png",
webp: "webp",
tiff: "tiff",
gif: "gif",
avif: "avif",
heic: "heic",
heif: "heif",
jxl: "jxl",
};
const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif", "bmp"]);
import { receiveUpload } from "../../lib/upload-stream.js";
const settingsSchema = z.object({
format: z
@@ -42,6 +26,10 @@ const settingsSchema = z.object({
/**
* Object eraser / inpainting route.
* Accepts an image and a mask image, erases masked areas using LaMa.
*
* Enqueues with kind "ai-tool" and uses registerAiJobHandler for the
* worker. The mask is passed as the second entry in inputRefs and read
* via getObjectBuffer(data.inputRefs[1]) inside the handler.
*/
export function registerEraseObject(app: FastifyInstance) {
app.post("/api/v1/tools/erase-object", async (request: FastifyRequest, reply: FastifyReply) => {
@@ -57,27 +45,27 @@ export function registerEraseObject(app: FastifyInstance) {
});
}
const jobId = randomUUID();
let imageBuffer: Buffer | null = null;
let maskBuffer: Buffer | null = null;
let filename = "image";
let clientJobId: string | null = null;
let format = "png";
let quality = 95;
let imageKey: string | null = null;
let maskKey: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buf = Buffer.concat(chunks);
if (part.fieldname === "mask") {
maskBuffer = buf;
const upload = await receiveUpload(part, jobId);
maskKey = upload.key;
} else {
imageBuffer = buf;
filename = sanitizeFilename(part.filename ?? "image");
const upload = await receiveUpload(part, jobId);
imageKey = upload.key;
filename = upload.filename;
}
} else if (part.fieldname === "clientJobId") {
const raw = part.value as string;
@@ -93,19 +81,22 @@ export function registerEraseObject(app: FastifyInstance) {
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
}
if (!imageBuffer || imageBuffer.length === 0) {
if (!imageKey) {
return reply.status(400).send({ error: "No image file provided" });
}
if (!maskBuffer || maskBuffer.length === 0) {
if (!maskKey) {
return reply.status(400).send({
error: "No mask image provided. Upload a mask as a second file with fieldname 'mask'",
});
}
imageBuffer = await getObjectBuffer(imageKey);
maskBuffer = await getObjectBuffer(maskKey);
const imageValidation = await validateImageBuffer(imageBuffer, filename);
if (!imageValidation.valid) {
return reply.status(400).send({ error: `Invalid image: ${imageValidation.reason}` });
@@ -135,154 +126,128 @@ export function registerEraseObject(app: FastifyInstance) {
}
try {
// Decode HEIC/HEIF input via system decoder
if (imageValidation.format === "heif") {
imageBuffer = await decodeHeic(imageBuffer);
}
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
if (needsCliDecode(imageValidation.format)) {
imageBuffer = await decodeToSharpCompat(imageBuffer, imageValidation.format);
}
// Auto-orient to fix EXIF rotation
imageBuffer = await autoOrient(imageBuffer);
} catch (err) {
request.log.error({ err, toolId: "erase-object" }, "Input decoding failed");
return reply.status(422).send({
error: "Object erasing failed",
details: err instanceof Error ? err.message : "Unknown error",
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
});
}
const originalSize = imageBuffer.length;
const jobId = randomUUID();
// Write decoded image for the worker
const decodedKey = `uploads/${jobId}/${filename}`;
if (decodedKey !== imageKey) {
await putObject(decodedKey, imageBuffer);
imageKey = decodedKey;
} else {
await putObject(imageKey, imageBuffer);
}
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, imageBuffer);
} catch (err) {
request.log.error({ err, toolId: "erase-object" }, "Workspace creation failed");
return reply.status(422).send({
error: "Object erasing failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const log = request.log;
log.info(
{
toolId: "erase-object",
imageSize: originalSize,
maskSize: maskBuffer.length,
format,
},
"Starting object erasure",
);
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
const resultBuffer = await inpaint(
imageBuffer,
maskBuffer,
join(workspacePath, "output"),
onProgress,
);
// Convert to the requested output format using Sharp
const needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format);
let outputBuffer: Buffer;
let finalFormat = format;
if (needsNodeConversion) {
if (format === "heic" || format === "heif") {
outputBuffer = await encodeHeic(resultBuffer, quality);
finalFormat = format;
} else if (format === "jxl") {
outputBuffer = await encodeJxl(resultBuffer, quality);
finalFormat = "jxl";
} else {
outputBuffer = await sharp(resultBuffer).avif({ quality }).toBuffer();
finalFormat = "avif";
}
} else if (format === "jpg" || format === "jpeg") {
outputBuffer = await sharp(resultBuffer).jpeg({ quality }).toBuffer();
finalFormat = "jpg";
} else if (format === "webp") {
outputBuffer = await sharp(resultBuffer).webp({ quality }).toBuffer();
finalFormat = "webp";
} else if (format === "tiff") {
outputBuffer = await sharp(resultBuffer).tiff({ quality }).toBuffer();
finalFormat = "tiff";
} else if (format === "gif") {
outputBuffer = await sharp(resultBuffer).gif().toBuffer();
finalFormat = "gif";
} else {
outputBuffer = resultBuffer;
finalFormat = "png";
}
// Save output
const ext = EXT_MAP[finalFormat] || "png";
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_erased.${ext}`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, outputBuffer);
// Generate browser-compatible preview for non-previewable formats
let previewUrl: string | undefined;
if (!BROWSER_PREVIEWABLE.has(finalFormat)) {
try {
const previewInput =
finalFormat === "heic" || finalFormat === "heif"
? await decodeHeic(outputBuffer)
: outputBuffer;
const previewBuffer = await sharp(previewInput).webp({ quality: 80 }).toBuffer();
const previewPath = join(workspacePath, "output", "preview.webp");
await writeFile(previewPath, previewBuffer);
previewUrl = `/api/v1/download/${jobId}/preview.webp`;
} catch {
// Non-fatal - frontend will show fallback
}
}
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
previewUrl,
originalSize,
processedSize: outputBuffer.length,
},
});
log.info({ toolId: "erase-object", jobId, downloadUrl }, "Object erasure complete");
})().catch((err) => {
log.error({ err, toolId: "erase-object" }, "Object erasing failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Object erasing failed",
});
// Enqueue with both image and mask as inputRefs; the worker handler
// reads them via getObjectBuffer.
await enqueueToolJob({
jobId,
toolId,
userId: null,
pool: "ai",
inputRefs: [imageKey, maskKey],
filename,
settings: { format, quality },
clientJobId: clientJobId ?? undefined,
kind: "ai-tool",
});
return reply.status(202).send({ jobId: progressJobId, async: true });
});
}
// ── AI job handler (separate import for the worker) ───────────────
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
registerAiJobHandler("erase-object", async (input, data, ctx) => {
// Second inputRef is the mask
const maskBuffer = await getObjectBuffer(data.inputRefs[1]);
const settings = settingsSchema.parse(data.settings);
const format = settings.format;
const quality = settings.quality;
const resultBuffer = await inpaint(input, maskBuffer, ctx.scratchDir, (percent, stage) =>
ctx.report(percent, stage),
);
// Convert to requested output format
const needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format);
let outputBuffer: Buffer;
let finalFormat = format;
if (needsNodeConversion) {
if (format === "heic" || format === "heif") {
outputBuffer = await encodeHeic(resultBuffer, quality);
finalFormat = format;
} else if (format === "jxl") {
outputBuffer = await encodeJxl(resultBuffer, quality);
finalFormat = "jxl";
} else {
outputBuffer = await sharp(resultBuffer).avif({ quality }).toBuffer();
finalFormat = "avif";
}
} else if (format === "jpg" || format === "jpeg") {
outputBuffer = await sharp(resultBuffer).jpeg({ quality }).toBuffer();
finalFormat = "jpg";
} else if (format === "webp") {
outputBuffer = await sharp(resultBuffer).webp({ quality }).toBuffer();
finalFormat = "webp";
} else if (format === "tiff") {
outputBuffer = await sharp(resultBuffer).tiff({ quality }).toBuffer();
finalFormat = "tiff";
} else if (format === "gif") {
outputBuffer = await sharp(resultBuffer).gif().toBuffer();
finalFormat = "gif";
} else {
outputBuffer = resultBuffer;
finalFormat = "png";
}
const EXT_MAP: Record<string, string> = {
jpeg: "jpg",
jpg: "jpg",
png: "png",
webp: "webp",
tiff: "tiff",
gif: "gif",
avif: "avif",
heic: "heic",
heif: "heif",
jxl: "jxl",
};
const ext = EXT_MAP[finalFormat] || "png";
const outputFilename = `${data.filename.replace(/\.[^.]+$/, "")}_erased.${ext}`;
const CONTENT_TYPES: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
webp: "image/webp",
tiff: "image/tiff",
gif: "image/gif",
avif: "image/avif",
heic: "image/heic",
heif: "image/heif",
jxl: "image/jxl",
};
return {
buffer: outputBuffer,
filename: outputFilename,
contentType: CONTENT_TYPES[finalFormat] || "image/png",
};
});
+2 -5
View File
@@ -1,12 +1,10 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { captureHtml, capturePage, isBrowserAvailable } from "../../lib/browser-service.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { putObject } from "../../lib/object-storage.js";
import { validateFetchUrl } from "../../lib/ssrf.js";
import { createWorkspace } from "../../lib/workspace.js";
const DEVICE_PRESETS = {
desktop: { width: 1280, height: 720, isMobile: false },
@@ -95,10 +93,9 @@ export function registerHtmlToImage(app: FastifyInstance) {
: await capturePage(settings.url!, captureOpts);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const ext = settings.format;
const filename = `screenshot.${ext}`;
await writeFile(join(workspacePath, "output", filename), buffer);
await putObject(`outputs/${jobId}/${filename}`, buffer);
return reply.send({
jobId,
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { mkdir } from "node:fs/promises";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { noiseRemoval } from "@snapotter/ai";
import { analyzeImage, applyCorrections } from "@snapotter/image-engine";
@@ -12,7 +13,6 @@ import { validateImageBuffer } from "../../lib/file-validation.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { createWorkspace } from "../../lib/workspace.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -82,12 +82,10 @@ async function processImageEnhancement(
}
if (settings.deepEnhance && isToolInstalled("noise-removal")) {
const scratchDir = join(tmpdir(), "snapotter-scratch", randomUUID());
try {
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputDir = join(workspacePath, "output");
await mkdir(outputDir, { recursive: true });
const result = await noiseRemoval(buffer, outputDir, {
await mkdir(scratchDir, { recursive: true });
const result = await noiseRemoval(buffer, scratchDir, {
tier: "quality",
strength: 35,
detailPreservation: 70,
@@ -96,6 +94,8 @@ async function processImageEnhancement(
buffer = result.buffer;
} catch {
// SCUNet unavailable -- fall back to Sharp-only result
} finally {
await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
}
}
+19 -20
View File
@@ -1,7 +1,4 @@
import { randomUUID } from "node:crypto";
import { createWriteStream } from "node:fs";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import PDFDocument from "pdfkit";
@@ -13,8 +10,8 @@ import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js";
import { createWorkspace } from "../../lib/workspace.js";
const targetSizeSchema = z.object({
value: z.number().positive(),
@@ -272,8 +269,6 @@ export function registerImageToPdf(app: FastifyInstance) {
}
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputDir = join(workspacePath, "output");
const originalSize = files.reduce((s, f) => s + f.buffer.length, 0);
if (settings.collate) {
@@ -284,7 +279,7 @@ export function registerImageToPdf(app: FastifyInstance) {
}
const filename = "images.pdf";
await writeFile(join(outputDir, filename), pdfBuffer);
await putObject(`outputs/${jobId}/${filename}`, pdfBuffer);
return reply.send({
jobId,
@@ -297,30 +292,34 @@ export function registerImageToPdf(app: FastifyInstance) {
}
let totalProcessedSize = 0;
const pdfFilenames: string[] = [];
const pdfNames: string[] = [];
for (let i = 0; i < imageBuffers.length; i++) {
const pdfBuffer = await buildPdf([imageBuffers[i]]);
const baseName = files[i].filename.replace(/\.[^.]+$/, "");
const pdfName = `${baseName}.pdf`;
await writeFile(join(outputDir, pdfName), pdfBuffer);
pdfFilenames.push(pdfName);
await putObject(`outputs/${jobId}/${pdfName}`, pdfBuffer);
pdfNames.push(pdfName);
totalProcessedSize += pdfBuffer.length;
}
// Build ZIP by streaming each entry from object storage (O(1-entry) peak)
const zipFilename = "images.zip";
const zipPath = join(outputDir, zipFilename);
await new Promise<void>((resolve, reject) => {
const output = createWriteStream(zipPath);
const archive = archiver("zip", { zlib: { level: 5 } });
output.on("close", resolve);
const archive = archiver("zip", { zlib: { level: 5 } });
const zipChunks: Buffer[] = [];
archive.on("data", (chunk: Buffer) => zipChunks.push(chunk));
const zipDone = new Promise<void>((resolve, reject) => {
archive.on("end", resolve);
archive.on("error", reject);
archive.pipe(output);
for (const name of pdfFilenames) {
archive.file(join(outputDir, name), { name });
}
archive.finalize();
});
for (const name of pdfNames) {
const buf = await getObjectBuffer(`outputs/${jobId}/${name}`);
archive.append(buf, { name });
}
await archive.finalize();
await zipDone;
const zipBuffer = Buffer.concat(zipChunks);
await putObject(`outputs/${jobId}/${zipFilename}`, zipBuffer);
return reply.send({
jobId,
+2 -5
View File
@@ -1,6 +1,5 @@
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
@@ -11,8 +10,8 @@ import { validateImageBuffer } from "../../lib/file-validation.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { renderMemeTextSvg } from "../../lib/meme-text-renderer.js";
import { putObject } from "../../lib/object-storage.js";
import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js";
import { createWorkspace } from "../../lib/workspace.js";
import { registerToolProcessFn } from "../tool-factory.js";
// ---------------------------------------------------------------------------
@@ -331,9 +330,7 @@ export function registerMemeGenerator(app: FastifyInstance) {
const output = await processMeme(imageBuffer, settings, filename, templateTextBoxes);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", output.filename);
await writeFile(outputPath, output.buffer);
await putObject(`outputs/${jobId}/${output.filename}`, output.buffer);
return reply.send({
jobId,
+104 -112
View File
@@ -1,19 +1,21 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { noiseRemoval } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { receiveUpload } from "../../lib/upload-stream.js";
import { registerToolProcessFn } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -25,6 +27,42 @@ const settingsSchema = z.object({
quality: z.union([z.number(), z.string()]).transform(Number).default(90),
});
// ── AI job handler ────────────────────────────────────────────────
registerAiJobHandler("noise-removal", async (input, data, ctx) => {
const settings = settingsSchema.parse(data.settings);
const result = await noiseRemoval(
input,
ctx.scratchDir,
{
tier: settings.tier,
strength: settings.strength,
detailPreservation: settings.detailPreservation,
colorNoise: settings.colorNoise,
format: settings.format,
quality: settings.quality,
},
(percent, stage) => ctx.report(percent, stage),
);
const ext = result.format === "jpeg" ? "jpg" : result.format;
const outputFilename = `${data.filename.replace(/\.[^.]+$/, "")}_denoised.${ext}`;
const CONTENT_TYPES: Record<string, string> = {
png: "image/png",
jpeg: "image/jpeg",
jpg: "image/jpeg",
webp: "image/webp",
avif: "image/avif",
};
return {
buffer: result.buffer,
filename: outputFilename,
contentType: CONTENT_TYPES[result.format] || "image/png",
};
});
/**
* AI noise removal route.
* Uses the Python sidecar for multi-tier denoising.
@@ -43,21 +81,20 @@ export function registerNoiseRemoval(app: FastifyInstance) {
});
}
const jobId = randomUUID();
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let inputKey: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = sanitizeFilename(part.filename ?? "image");
const upload = await receiveUpload(part, jobId);
inputKey = upload.key;
filename = upload.filename;
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
@@ -70,14 +107,16 @@ export function registerNoiseRemoval(app: FastifyInstance) {
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
fileBuffer = await getObjectBuffer(inputKey);
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
@@ -109,88 +148,36 @@ export function registerNoiseRemoval(app: FastifyInstance) {
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",
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
});
}
const originalSize = fileBuffer.length;
const jobId = randomUUID();
const decodedKey = `uploads/${jobId}/${filename}`;
if (decodedKey !== inputKey) {
await putObject(decodedKey, fileBuffer);
inputKey = decodedKey;
} else {
await putObject(inputKey, fileBuffer);
}
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",
});
}
const log = request.log;
log.info(
{ toolId: "noise-removal", imageSize: originalSize, tier: parsed.tier },
"Starting noise removal",
);
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
(async () => {
const result = await noiseRemoval(
fileBuffer,
join(workspacePath, "output"),
{
tier: parsed.tier,
strength: parsed.strength,
detailPreservation: parsed.detailPreservation,
colorNoise: parsed.colorNoise,
format: parsed.format,
quality: parsed.quality,
},
onProgress,
);
const ext = result.format === "jpeg" ? "jpg" : result.format;
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_denoised.${ext}`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, result.buffer);
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
originalSize,
processedSize: result.buffer.length,
},
});
log.info({ toolId: "noise-removal", jobId, downloadUrl }, "Noise removal complete");
})().catch((err) => {
log.error({ err, toolId: "noise-removal" }, "Noise removal failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Noise removal failed",
});
await enqueueToolJob({
jobId,
toolId,
userId: null,
pool: "ai",
inputRefs: [inputKey],
filename,
settings: parsed,
clientJobId: clientJobId ?? undefined,
kind: "ai-tool",
});
return reply.status(202).send({ jobId: progressJobId, async: true });
});
// Register in the pipeline/batch registry so this tool can be used
// as a step in automation pipelines (without progress callbacks).
// Register in the pipeline/batch registry
registerToolProcessFn({
toolId: "noise-removal",
settingsSchema: z.object({
@@ -201,33 +188,38 @@ export function registerNoiseRemoval(app: FastifyInstance) {
format: z.enum(["original", "png", "jpeg", "webp", "avif", "jxl"]).default("original"),
quality: z.union([z.number(), z.string()]).transform(Number).default(90),
}),
process: async (inputBuffer, settings, filename) => {
process: async (inputBuffer, settings, filename, ctx) => {
const s = settings as z.infer<typeof settingsSchema>;
const orientedBuffer = await autoOrient(inputBuffer);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const result = await noiseRemoval(orientedBuffer, join(workspacePath, "output"), {
tier: s.tier,
strength: s.strength,
detailPreservation: s.detailPreservation,
colorNoise: s.colorNoise,
format: s.format,
quality: s.quality,
});
const ext = result.format === "jpeg" ? "jpg" : result.format;
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_denoised.${ext}`;
const CONTENT_TYPES: Record<string, string> = {
png: "image/png",
jpeg: "image/jpeg",
jpg: "image/jpeg",
webp: "image/webp",
avif: "image/avif",
};
return {
buffer: result.buffer,
filename: outputFilename,
contentType: CONTENT_TYPES[result.format] || "image/png",
};
const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID());
const needsCleanup = !ctx?.scratchDir;
if (needsCleanup) await mkdir(scratchDir, { recursive: true });
try {
const result = await noiseRemoval(orientedBuffer, scratchDir, {
tier: s.tier,
strength: s.strength,
detailPreservation: s.detailPreservation,
colorNoise: s.colorNoise,
format: s.format,
quality: s.quality,
});
const ext = result.format === "jpeg" ? "jpg" : result.format;
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_denoised.${ext}`;
const CONTENT_TYPES: Record<string, string> = {
png: "image/png",
jpeg: "image/jpeg",
jpg: "image/jpeg",
webp: "image/webp",
avif: "image/avif",
};
return {
buffer: result.buffer,
filename: outputFilename,
contentType: CONTENT_TYPES[result.format] || "image/png",
};
} finally {
if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
}
},
});
}
+9 -3
View File
@@ -1,4 +1,7 @@
import { randomUUID } from "node:crypto";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { extractText } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
@@ -10,7 +13,6 @@ import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
const settingsSchema = z.object({
@@ -79,6 +81,7 @@ export function registerOcr(app: FastifyInstance) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
let scratchDir = "";
try {
// Decode HEIC/HEIF input via system decoder
if (validation.format === "heif") {
@@ -123,7 +126,8 @@ export function registerOcr(app: FastifyInstance) {
"Starting OCR",
);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
scratchDir = join(tmpdir(), "snapotter-scratch", jobId);
await mkdir(scratchDir, { recursive: true });
const jobIdForProgress = clientJobId;
const onProgress = jobIdForProgress
@@ -152,7 +156,7 @@ export function registerOcr(app: FastifyInstance) {
try {
const result = await extractText(
fileBuffer,
workspacePath,
scratchDir,
{
quality: tier,
language: settings.language,
@@ -225,6 +229,8 @@ export function registerOcr(app: FastifyInstance) {
error: "OCR failed",
details: err instanceof Error ? err.message : "Unknown error",
});
} finally {
if (scratchDir) await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
}
});
}
+34 -28
View File
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { detectFaceLandmarks, removeBackground } from "@snapotter/ai";
import {
@@ -18,7 +19,7 @@ import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace, getWorkspacePath } from "../../lib/workspace.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { updateSingleFileProgress } from "../progress.js";
import { registerToolProcessFn } from "../tool-factory.js";
@@ -203,11 +204,11 @@ export function registerPassportPhoto(app: FastifyInstance) {
);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const scratchDir = join(tmpdir(), "snapotter-scratch", jobId);
await mkdir(scratchDir, { recursive: true });
// Save original to workspace for generate phase
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
// Save original to object storage for generate phase
await putObject(`uploads/${jobId}/${filename}`, fileBuffer);
// Progress callback
const jobIdForProgress = clientJobId;
@@ -253,16 +254,21 @@ export function registerPassportPhoto(app: FastifyInstance) {
}
: undefined;
const bgRemovedBuffer = await removeBackground(
fileBuffer,
join(workspacePath, "output"),
{ model: "birefnet-portrait" },
bgProgress,
);
let bgRemovedBuffer: Buffer;
try {
bgRemovedBuffer = await removeBackground(
fileBuffer,
scratchDir,
{ model: "birefnet-portrait" },
bgProgress,
);
} finally {
await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
}
// Save bg-removed image to workspace
// Save bg-removed image to object storage for generate phase
const bgRemovedFilename = `${filename.replace(/\.[^.]+$/, "")}_nobg.png`;
await writeFile(join(workspacePath, "output", bgRemovedFilename), bgRemovedBuffer);
await putObject(`outputs/${jobId}/${bgRemovedFilename}`, bgRemovedBuffer);
// Create a smaller preview for fast transfer (max 800px wide)
const meta = await sharp(bgRemovedBuffer).metadata();
@@ -383,10 +389,9 @@ export function registerPassportPhoto(app: FastifyInstance) {
};
try {
const workspacePath = getWorkspacePath(jobId);
const bgRemovedFilename = `${filename.replace(/\.[^.]+$/, "")}_nobg.png`;
const bgRemovedBuffer = await readFile(join(workspacePath, "output", bgRemovedFilename));
const bgRemovedBuffer = await getObjectBuffer(`outputs/${jobId}/${bgRemovedFilename}`);
// Use actual bg-removed image dimensions for crop (may differ from
// the original image dimensions reported by the analyze endpoint).
@@ -494,8 +499,7 @@ export function registerPassportPhoto(app: FastifyInstance) {
// Save output
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_passport.jpg`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, cropped);
await putObject(`outputs/${jobId}/${outputFilename}`, cropped);
const response: Record<string, unknown> = {
jobId,
@@ -526,7 +530,7 @@ export function registerPassportPhoto(app: FastifyInstance) {
if (printBuffer) {
const printFilename = `${filename.replace(/\.[^.]+$/, "")}_passport_print_${printLayout}.jpg`;
await writeFile(join(workspacePath, "output", printFilename), printBuffer);
await putObject(`outputs/${jobId}/${printFilename}`, printBuffer);
response.printDownloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(printFilename)}`;
}
}
@@ -555,7 +559,7 @@ export function registerPassportPhoto(app: FastifyInstance) {
registerToolProcessFn({
toolId: "passport-photo",
settingsSchema: pipelineSettingsSchema,
process: async (inputBuffer, settings, filename) => {
process: async (inputBuffer, settings, filename, ctx) => {
const s = settings as z.infer<typeof pipelineSettingsSchema>;
const orientedBuffer = await autoOrient(inputBuffer);
@@ -572,16 +576,18 @@ export function registerPassportPhoto(app: FastifyInstance) {
const imgH = landmarksResult.imageHeight;
// Step 2: Remove background
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID());
const needsCleanup = !ctx?.scratchDir;
if (needsCleanup) await mkdir(scratchDir, { recursive: true });
const bgRemovedBuffer = await removeBackground(
orientedBuffer,
join(workspacePath, "output"),
{
let bgRemovedBuffer: Buffer;
try {
bgRemovedBuffer = await removeBackground(orientedBuffer, scratchDir, {
model: "birefnet-portrait",
},
);
});
} finally {
if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
}
// Step 3: Look up spec and compute crop
const countrySpec = PASSPORT_SPECS.find((sp) => sp.code === s.countryCode);
+19 -23
View File
@@ -1,7 +1,4 @@
import { randomUUID } from "node:crypto";
import { createWriteStream } from "node:fs";
import { stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import * as mupdf from "mupdf";
@@ -11,7 +8,7 @@ import { env } from "../../config.js";
import { formatZodErrors } from "../../lib/errors.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { encodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
// ── Settings schema ──────────────────────────────────────────────
const settingsSchema = z.object({
@@ -329,9 +326,8 @@ export function registerPdfToImage(app: FastifyInstance) {
const ext = FORMAT_EXT[settings.format] ?? ".png";
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputDir = join(workspacePath, "output");
const pages: Array<{ page: number; downloadUrl: string; size: number }> = [];
const pageFilenames: string[] = [];
for (const pageNum of selectedPages) {
const pngBytes = renderPage(doc, pageNum - 1, settings.dpi);
@@ -342,8 +338,8 @@ export function registerPdfToImage(app: FastifyInstance) {
settings.colorMode,
);
const filename = `page-${pageNum}${ext}`;
const filePath = join(outputDir, filename);
await writeFile(filePath, imageBuffer);
await putObject(`outputs/${jobId}/${filename}`, imageBuffer);
pageFilenames.push(filename);
pages.push({
page: pageNum,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`,
@@ -354,23 +350,23 @@ export function registerPdfToImage(app: FastifyInstance) {
doc.destroy();
doc = null;
// Generate ZIP
// Build ZIP by streaming each entry from object storage (O(1-entry) peak)
const zipFilename = "pdf-pages.zip";
const zipPath = join(outputDir, zipFilename);
await new Promise<void>((resolve, reject) => {
const output = createWriteStream(zipPath);
const archive = archiver("zip", { zlib: { level: 5 } });
output.on("close", resolve);
const archive = archiver("zip", { zlib: { level: 5 } });
const zipChunks: Buffer[] = [];
archive.on("data", (chunk: Buffer) => zipChunks.push(chunk));
const zipDone = new Promise<void>((resolve, reject) => {
archive.on("end", resolve);
archive.on("error", reject);
archive.pipe(output);
for (const p of pages) {
const fname = `page-${p.page}${ext}`;
archive.file(join(outputDir, fname), { name: fname });
}
archive.finalize();
});
const zipStat = await stat(zipPath);
for (const fname of pageFilenames) {
const buf = await getObjectBuffer(`outputs/${jobId}/${fname}`);
archive.append(buf, { name: fname });
}
await archive.finalize();
await zipDone;
const zipBuffer = Buffer.concat(zipChunks);
await putObject(`outputs/${jobId}/${zipFilename}`, zipBuffer);
return reply.send({
jobId,
@@ -379,7 +375,7 @@ export function registerPdfToImage(app: FastifyInstance) {
format: settings.format,
pages,
zipUrl: `/api/v1/download/${jobId}/${encodeURIComponent(zipFilename)}`,
zipSize: zipStat.size,
zipSize: zipBuffer.length,
});
} catch (err) {
doc?.destroy();
+2 -6
View File
@@ -1,11 +1,9 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import QRCode from "qrcode";
import { z } from "zod";
import { formatZodErrors } from "../../lib/errors.js";
import { createWorkspace } from "../../lib/workspace.js";
import { putObject } from "../../lib/object-storage.js";
const settingsSchema = z.object({
text: z.string().min(1).max(2000),
@@ -57,10 +55,8 @@ export function registerQrGenerate(app: FastifyInstance) {
});
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const filename = "qrcode.png";
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, buffer);
await putObject(`outputs/${jobId}/${filename}`, buffer);
return reply.send({
jobId,
+83 -109
View File
@@ -1,19 +1,21 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { removeRedEye } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { receiveUpload } from "../../lib/upload-stream.js";
import { registerToolProcessFn } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -23,6 +25,35 @@ const settingsSchema = z.object({
quality: z.number().min(1).max(100).default(90),
});
// ── AI job handler ────────────────────────────────────────────────
registerAiJobHandler("red-eye-removal", async (input, data, ctx) => {
const settings = settingsSchema.parse(data.settings);
const result = await removeRedEye(
input,
ctx.scratchDir,
{
sensitivity: settings.sensitivity,
strength: settings.strength,
format: settings.format,
quality: settings.quality,
},
(percent, stage) => ctx.report(percent, stage),
);
const outputFilename = `${data.filename.replace(/\.[^.]+$/, "")}_redeye_fixed.png`;
return {
buffer: result.buffer,
filename: outputFilename,
contentType: "image/png",
resultPayload: {
facesDetected: result.facesDetected,
eyesCorrected: result.eyesCorrected,
},
};
});
/** Red eye detection and removal route. */
export function registerRedEyeRemoval(app: FastifyInstance) {
app.post(
@@ -40,21 +71,20 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
});
}
const jobId = randomUUID();
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let inputKey: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = sanitizeFilename(part.filename ?? "image");
const upload = await receiveUpload(part, jobId);
inputKey = upload.key;
filename = upload.filename;
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
@@ -67,14 +97,16 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
fileBuffer = await getObjectBuffer(inputKey);
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
@@ -94,112 +126,49 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const { sensitivity, strength, format: outputFormat, quality } = settings;
try {
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
}
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
if (needsCliDecode(validation.format)) {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
}
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",
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
});
}
const originalSize = fileBuffer.length;
const jobId = randomUUID();
const decodedKey = `uploads/${jobId}/${filename}`;
if (decodedKey !== inputKey) {
await putObject(decodedKey, fileBuffer);
inputKey = decodedKey;
} else {
await putObject(inputKey, fileBuffer);
}
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "red-eye-removal" }, "Workspace creation failed");
return reply.status(422).send({
error: "Red eye removal failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const log = request.log;
log.info(
{ toolId: "red-eye-removal", imageSize: originalSize, sensitivity, strength },
"Starting red eye removal",
);
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
const result = await removeRedEye(
fileBuffer,
join(workspacePath, "output"),
{
sensitivity,
strength,
format: outputFormat,
quality,
},
onProgress,
);
// Save output
const name = filename.replace(/\.[^.]+$/, "");
const outputFilename = `${name}_redeye_fixed.png`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, result.buffer);
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
originalSize,
processedSize: result.buffer.length,
facesDetected: result.facesDetected,
eyesCorrected: result.eyesCorrected,
},
});
log.info({ toolId: "red-eye-removal", jobId, downloadUrl }, "Red eye removal complete");
})().catch((err) => {
log.error({ err, toolId: "red-eye-removal" }, "Red eye removal failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Red eye removal failed",
});
await enqueueToolJob({
jobId,
toolId,
userId: null,
pool: "ai",
inputRefs: [inputKey],
filename,
settings,
clientJobId: clientJobId ?? undefined,
kind: "ai-tool",
});
return reply.status(202).send({ jobId: progressJobId, async: true });
},
);
// Register in the pipeline/batch registry so this tool can be used
// as a step in automation pipelines (without progress callbacks).
// Register in the pipeline/batch registry
registerToolProcessFn({
toolId: "red-eye-removal",
settingsSchema: z.object({
@@ -208,7 +177,7 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
format: z.string().optional(),
quality: z.number().min(1).max(100).default(90),
}),
process: async (inputBuffer, settings, filename) => {
process: async (inputBuffer, settings, filename, ctx) => {
const s = settings as {
sensitivity?: number;
strength?: number;
@@ -228,16 +197,21 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
}
}
const orientedBuffer = await autoOrient(decoded);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const result = await removeRedEye(orientedBuffer, join(workspacePath, "output"), {
sensitivity: s.sensitivity ?? 50,
strength: s.strength ?? 70,
format: s.format,
quality: s.quality ?? 90,
});
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_redeye_fixed.png`;
return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" };
const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID());
const needsCleanup = !ctx?.scratchDir;
if (needsCleanup) await mkdir(scratchDir, { recursive: true });
try {
const result = await removeRedEye(orientedBuffer, scratchDir, {
sensitivity: s.sensitivity ?? 50,
strength: s.strength ?? 70,
format: s.format,
quality: s.quality ?? 90,
});
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_redeye_fixed.png`;
return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" };
} finally {
if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
}
},
});
}
+122 -133
View File
@@ -1,24 +1,27 @@
import { randomUUID } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { removeBackground } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { autoOrient } from "../../lib/auto-orient.js";
import {
applyEffects,
BG_FORMAT_CONTENT_TYPES,
type BgOutputFormat,
} from "../../lib/bg-effects.js";
import { formatZodErrors } from "../../lib/errors.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace, getWorkspacePath } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { receiveUpload } from "../../lib/upload-stream.js";
import { registerToolProcessFn } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -37,6 +40,43 @@ const settingsSchema = z.object({
decontaminate: z.boolean().optional(),
});
// ── AI job handler (runs inside the BullMQ worker) ────────────────
registerAiJobHandler("remove-background", async (input, data, ctx) => {
const settings = settingsSchema.parse(data.settings);
// Phase 1: AI background removal -> transparent PNG
const transparentResult = await removeBackground(
input,
ctx.scratchDir,
{
model: settings.model,
edgeRefine: settings.edgeRefine,
decontaminate: settings.decontaminate,
},
(percent, stage) => ctx.report(percent, stage),
);
// The mask IS the transparent result; cache original for effects re-apply
const maskFilename = `${data.filename.replace(/\.[^.]+$/, "")}_mask.png`;
const originalFilename = `${data.filename.replace(/\.[^.]+$/, "")}_original.png`;
const maskUrl = `/api/v1/download/${data.jobId}/${encodeURIComponent(maskFilename)}`;
const originalUrl = `/api/v1/download/${data.jobId}/${encodeURIComponent(originalFilename)}`;
return {
buffer: transparentResult,
filename: maskFilename,
contentType: "image/png",
resultPayload: {
maskUrl,
originalUrl,
filename: data.filename,
model: settings.model,
},
extraOutputs: [{ name: originalFilename, buffer: input, contentType: "image/png" }],
};
});
/**
* AI background removal with two-phase flow:
*
@@ -65,19 +105,20 @@ export function registerRemoveBackground(app: FastifyInstance) {
});
}
const jobId = randomUUID();
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let inputKey: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) chunks.push(chunk);
fileBuffer = Buffer.concat(chunks);
filename = sanitizeFilename(part.filename ?? "image");
const upload = await receiveUpload(part, jobId);
inputKey = upload.key;
filename = upload.filename;
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
@@ -90,16 +131,23 @@ export function registerRemoveBackground(app: FastifyInstance) {
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
}
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
fileBuffer = await getObjectBuffer(inputKey);
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
// Orphaned uploads/<jobId>/ dir will be cleaned by T10 TTL sweeper
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
@@ -108,12 +156,14 @@ export function registerRemoveBackground(app: FastifyInstance) {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
// Orphaned uploads/<jobId>/ dir will be cleaned by T10 TTL sweeper
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
// Orphaned uploads/<jobId>/ dir will be cleaned by T10 TTL sweeper
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
@@ -138,98 +188,36 @@ export function registerRemoveBackground(app: FastifyInstance) {
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",
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
});
}
const originalSize = fileBuffer.length;
const jobId = randomUUID();
// Write decoded input for the worker
const decodedKey = `uploads/${jobId}/${filename}`;
if (decodedKey !== inputKey) {
await putObject(decodedKey, fileBuffer);
inputKey = decodedKey;
} else {
await putObject(inputKey, fileBuffer);
}
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "remove-background" }, "Workspace creation failed");
return reply.status(422).send({
error: "Background removal failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const log = request.log;
log.info(
{ toolId: "remove-background", imageSize: originalSize, model: settings.model },
"Starting background removal",
);
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent: Math.min(percent, 95),
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
// Phase 1: AI background removal -> transparent PNG
const transparentResult = await removeBackground(
fileBuffer,
join(workspacePath, "output"),
{
model: settings.model,
edgeRefine: settings.edgeRefine,
decontaminate: settings.decontaminate,
},
onProgress,
);
// Cache the mask (transparent PNG) and original for effects re-apply
const maskFilename = `${filename.replace(/\.[^.]+$/, "")}_mask.png`;
const originalFilename = `${filename.replace(/\.[^.]+$/, "")}_original.png`;
await writeFile(join(workspacePath, "output", maskFilename), transparentResult);
await writeFile(join(workspacePath, "output", originalFilename), fileBuffer);
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(maskFilename)}`;
const maskUrl = `/api/v1/download/${jobId}/${encodeURIComponent(maskFilename)}`;
const originalUrl = `/api/v1/download/${jobId}/${encodeURIComponent(originalFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
maskUrl,
originalUrl,
originalSize,
processedSize: transparentResult.length,
filename,
model: settings.model,
},
});
log.info(
{ toolId: "remove-background", jobId, downloadUrl },
"Background removal complete",
);
})().catch((err) => {
log.error({ err, toolId: "remove-background" }, "Background removal failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Background removal failed",
});
// Enqueue on the AI pool
await enqueueToolJob({
jobId,
toolId,
userId: null,
pool: "ai",
inputRefs: [inputKey],
filename,
settings,
clientJobId: clientJobId ?? undefined,
kind: "ai-tool",
});
// AI tools always return 202 (no sync window)
return reply.status(202).send({ jobId: progressJobId, async: true });
},
);
@@ -256,7 +244,7 @@ export function registerRemoveBackground(app: FastifyInstance) {
} catch (err) {
return reply.status(400).send({
error: "Failed to parse request",
details: err instanceof Error ? err.message : String(err),
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
}
@@ -297,15 +285,13 @@ export function registerRemoveBackground(app: FastifyInstance) {
const { jobId, filename } = settings;
const workspacePath = getWorkspacePath(jobId);
const baseName = filename.replace(/\.[^.]+$/, "");
const maskPath = join(workspacePath, "output", `${baseName}_mask.png`);
const originalPath = join(workspacePath, "output", `${baseName}_original.png`);
const maskKey = `outputs/${jobId}/${baseName}_mask.png`;
const originalKey = `outputs/${jobId}/${baseName}_original.png`;
const [maskBuffer, originalBuffer] = await Promise.all([
readFile(maskPath),
readFile(originalPath),
getObjectBuffer(maskKey),
getObjectBuffer(originalKey),
]);
// Decode HEIC/HEIF background image if needed
@@ -337,8 +323,7 @@ export function registerRemoveBackground(app: FastifyInstance) {
// Save the final output
const outputFilename = `${baseName}_nobg.${fmt}`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, resultBuffer);
await putObject(`outputs/${jobId}/${outputFilename}`, resultBuffer);
return reply.send({
jobId,
@@ -349,7 +334,7 @@ export function registerRemoveBackground(app: FastifyInstance) {
request.log.error({ err }, "Effects processing failed");
return reply.status(422).send({
error: "Effects processing failed",
details: err instanceof Error ? err.message : "Unknown error",
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
});
}
},
@@ -359,38 +344,42 @@ export function registerRemoveBackground(app: FastifyInstance) {
registerToolProcessFn({
toolId: "remove-background",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
process: async (inputBuffer, settings, filename, ctx) => {
const s = settings as z.infer<typeof settingsSchema>;
const orientedBuffer = await autoOrient(inputBuffer);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID());
const needsCleanup = !ctx?.scratchDir;
if (needsCleanup) await mkdir(scratchDir, { recursive: true });
try {
const transparentResult = await removeBackground(orientedBuffer, scratchDir, {
model: s.model,
edgeRefine: s.edgeRefine,
decontaminate: s.decontaminate,
});
const transparentResult = await removeBackground(
orientedBuffer,
join(workspacePath, "output"),
{ model: s.model, edgeRefine: s.edgeRefine, decontaminate: s.decontaminate },
);
const fmt = (s.outputFormat ?? "png") as BgOutputFormat;
const resultBuffer = await applyEffects(transparentResult, orientedBuffer, {
backgroundType: s.backgroundType,
backgroundColor: s.backgroundColor,
gradientColor1: s.gradientColor1,
gradientColor2: s.gradientColor2,
gradientAngle: s.gradientAngle,
blurEnabled: s.blurEnabled,
blurIntensity: s.blurIntensity,
shadowEnabled: s.shadowEnabled,
shadowOpacity: s.shadowOpacity,
outputFormat: fmt,
});
const fmt = (s.outputFormat ?? "png") as BgOutputFormat;
const resultBuffer = await applyEffects(transparentResult, orientedBuffer, {
backgroundType: s.backgroundType,
backgroundColor: s.backgroundColor,
gradientColor1: s.gradientColor1,
gradientColor2: s.gradientColor2,
gradientAngle: s.gradientAngle,
blurEnabled: s.blurEnabled,
blurIntensity: s.blurIntensity,
shadowEnabled: s.shadowEnabled,
shadowOpacity: s.shadowOpacity,
outputFormat: fmt,
});
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_nobg.${fmt}`;
return {
buffer: resultBuffer,
filename: outputFilename,
contentType: BG_FORMAT_CONTENT_TYPES[fmt],
};
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_nobg.${fmt}`;
return {
buffer: resultBuffer,
filename: outputFilename,
contentType: BG_FORMAT_CONTENT_TYPES[fmt],
};
} finally {
if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
}
},
});
}
+113 -161
View File
@@ -1,21 +1,23 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { restorePhoto } from "@snapotter/ai";
import { getBundleForTool } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeAnyFormat, decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { receiveUpload } from "../../lib/upload-stream.js";
import { registerToolProcessFn } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -28,6 +30,52 @@ const settingsSchema = z.object({
colorizeStrength: z.number().min(0).max(100).default(85),
});
// ── AI job handler ────────────────────────────────────────────────
registerAiJobHandler("restore-photo", async (input, data, ctx) => {
const settings = settingsSchema.parse(data.settings);
const result = await restorePhoto(
input,
ctx.scratchDir,
{
scratchRemoval: settings.scratchRemoval,
faceEnhancement: settings.faceEnhancement,
fidelity: settings.fidelity,
denoise: settings.denoise,
denoiseStrength: settings.denoiseStrength,
colorize: settings.colorize,
colorizeStrength: settings.colorizeStrength,
},
(percent, stage) => ctx.report(percent, stage),
);
const outputFormat = await resolveOutputFormat(input, data.filename);
let outputBuffer = result.buffer;
if (outputFormat.format !== "png") {
outputBuffer = await sharp(result.buffer)
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
}
const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format;
const outputFilename = `${data.filename.replace(/\.[^.]+$/, "")}_restored.${ext}`;
return {
buffer: outputBuffer,
filename: outputFilename,
contentType: outputFormat.contentType,
resultPayload: {
width: result.width,
height: result.height,
steps: result.steps,
scratchCoverage: result.scratchCoverage,
facesEnhanced: result.facesEnhanced,
isGrayscale: result.isGrayscale,
colorized: result.colorized,
},
};
});
/**
* AI photo restoration route.
* Multi-step pipeline: scratch repair, face enhancement, denoising,
@@ -46,21 +94,20 @@ export function registerRestorePhoto(app: FastifyInstance) {
});
}
const jobId = randomUUID();
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let inputKey: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = sanitizeFilename(part.filename ?? "image");
const upload = await receiveUpload(part, jobId);
inputKey = upload.key;
filename = upload.filename;
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
@@ -73,14 +120,16 @@ export function registerRestorePhoto(app: FastifyInstance) {
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
fileBuffer = await getObjectBuffer(inputKey);
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
@@ -101,30 +150,17 @@ export function registerRestorePhoto(app: FastifyInstance) {
}
try {
// Decode HEIC/HEIF input
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
}
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
if (needsCliDecode(validation.format)) {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
}
// Auto-orient to fix EXIF rotation
fileBuffer = await autoOrient(fileBuffer);
// AVIF can pass metadata validation but fail pixel decode when
// Sharp's bundled libheif lacks support for the bitstream version.
// Convert early (the sidecar needs PNG anyway); fall back to ImageMagick.
if (validation.format === "avif") {
try {
fileBuffer = await sharp(fileBuffer).png().toBuffer();
} catch {
request.log.warn(
{ toolId: "restore-photo" },
"Sharp AVIF decode failed, using ImageMagick",
);
fileBuffer = await decodeAnyFormat(fileBuffer, "avif");
}
}
@@ -132,122 +168,33 @@ export function registerRestorePhoto(app: FastifyInstance) {
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",
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
});
}
const originalSize = fileBuffer.length;
const jobId = randomUUID();
const decodedKey = `uploads/${jobId}/${filename}`;
if (decodedKey !== inputKey) {
await putObject(decodedKey, fileBuffer);
inputKey = decodedKey;
} else {
await putObject(inputKey, fileBuffer);
}
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "restore-photo" }, "Workspace creation failed");
return reply.status(422).send({
error: "Photo restoration failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const log = request.log;
log.info({ toolId: "restore-photo", imageSize: originalSize }, "Starting photo restoration");
// Reply immediately so the HTTP connection closes within proxy timeout limits.
// The result will be delivered via the SSE progress channel.
reply.status(202).send({ jobId: progressJobId, async: true });
const onProgress = (percent: number, stage: string) => {
updateSingleFileProgress({
jobId: progressJobId,
phase: "processing",
stage,
percent,
});
};
// Fire-and-forget: processing happens after the response is sent
(async () => {
// Process with Python sidecar
const result = await restorePhoto(
fileBuffer,
join(workspacePath, "output"),
{
scratchRemoval: settings.scratchRemoval,
faceEnhancement: settings.faceEnhancement,
fidelity: settings.fidelity,
denoise: settings.denoise,
denoiseStrength: settings.denoiseStrength,
colorize: settings.colorize,
colorizeStrength: settings.colorizeStrength,
},
onProgress,
);
// Resolve output format to match input
const outputFormat = await resolveOutputFormat(fileBuffer, filename);
let outputBuffer = result.buffer;
// Convert from PNG (Python output) to target format
if (outputFormat.format !== "png") {
outputBuffer = await sharp(result.buffer)
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
}
// Save output
const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format;
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_restored.${ext}`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, outputBuffer);
// Generate browser-compatible preview for non-previewable formats
const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif", "bmp"]);
let previewUrl: string | undefined;
if (!BROWSER_PREVIEWABLE.has(ext)) {
try {
const previewBuffer = await sharp(outputBuffer).webp({ quality: 80 }).toBuffer();
const previewPath = join(workspacePath, "output", "preview.webp");
await writeFile(previewPath, previewBuffer);
previewUrl = `/api/v1/download/${jobId}/preview.webp`;
} catch {
// Non-fatal
}
}
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
previewUrl,
originalSize,
processedSize: outputBuffer.length,
width: result.width,
height: result.height,
steps: result.steps,
scratchCoverage: result.scratchCoverage,
facesEnhanced: result.facesEnhanced,
isGrayscale: result.isGrayscale,
colorized: result.colorized,
},
});
log.info({ toolId: "restore-photo", jobId, downloadUrl }, "Photo restoration complete");
})().catch((err) => {
log.error({ err, toolId: "restore-photo" }, "Photo restoration failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Photo restoration failed",
});
await enqueueToolJob({
jobId,
toolId: "restore-photo",
userId: null,
pool: "ai",
inputRefs: [inputKey],
filename,
settings,
clientJobId: clientJobId ?? undefined,
kind: "ai-tool",
});
return reply.status(202).send({ jobId: progressJobId, async: true });
});
// Register in the pipeline/batch registry
@@ -262,34 +209,39 @@ export function registerRestorePhoto(app: FastifyInstance) {
colorize: z.boolean().default(false),
colorizeStrength: z.number().min(0).max(100).default(85),
}),
process: async (inputBuffer, settings, filename) => {
process: async (inputBuffer, settings, filename, ctx) => {
const s = settings as z.infer<typeof settingsSchema>;
const orientedBuffer = await autoOrient(inputBuffer);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const result = await restorePhoto(orientedBuffer, join(workspacePath, "output"), {
scratchRemoval: s.scratchRemoval,
faceEnhancement: s.faceEnhancement,
fidelity: s.fidelity,
denoise: s.denoise,
denoiseStrength: s.denoiseStrength,
colorize: s.colorize,
colorizeStrength: s.colorizeStrength,
});
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
let outputBuffer = result.buffer;
if (outputFormat.format !== "png") {
outputBuffer = await sharp(result.buffer)
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID());
const needsCleanup = !ctx?.scratchDir;
if (needsCleanup) await mkdir(scratchDir, { recursive: true });
try {
const result = await restorePhoto(orientedBuffer, scratchDir, {
scratchRemoval: s.scratchRemoval,
faceEnhancement: s.faceEnhancement,
fidelity: s.fidelity,
denoise: s.denoise,
denoiseStrength: s.denoiseStrength,
colorize: s.colorize,
colorizeStrength: s.colorizeStrength,
});
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
let outputBuffer = result.buffer;
if (outputFormat.format !== "png") {
outputBuffer = await sharp(result.buffer)
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
}
const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format;
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_restored.${ext}`;
return {
buffer: outputBuffer,
filename: outputFilename,
contentType: outputFormat.contentType,
};
} finally {
if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
}
const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format;
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_restored.${ext}`;
return {
buffer: outputBuffer,
filename: outputFilename,
contentType: outputFormat.contentType,
};
},
});
}
+2 -6
View File
@@ -1,6 +1,4 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
@@ -12,8 +10,8 @@ import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { putObject } from "../../lib/object-storage.js";
import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js";
import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
direction: z.enum(["horizontal", "vertical", "grid"]).default("horizontal"),
@@ -289,10 +287,8 @@ export function registerStitch(app: FastifyInstance) {
}
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const filename = `stitch.${settings.format}`;
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, result);
await putObject(`outputs/${jobId}/${filename}`, result);
return reply.send({
jobId,
+3 -8
View File
@@ -1,6 +1,4 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import PQueue from "p-queue";
@@ -13,8 +11,8 @@ import { formatZodErrors } from "../../lib/errors.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { putObject } from "../../lib/object-storage.js";
import { decompressSvgz, isSvgBuffer, sanitizeSvg } from "../../lib/svg-sanitize.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateJobProgress } from "../progress.js";
const NON_PREVIEWABLE = new Set(["tiff", "heif"]);
@@ -422,9 +420,7 @@ export function registerSvgToRaster(app: FastifyInstance) {
ext,
} = await convertSvg(fileBuffer, filename, settings);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", outFilename);
await writeFile(outputPath, buffer);
await putObject(`outputs/${jobId}/${outFilename}`, buffer);
let previewUrl: string | undefined;
if (NON_PREVIEWABLE.has(ext)) {
@@ -435,8 +431,7 @@ export function registerSvgToRaster(app: FastifyInstance) {
.resize(1200, 1200, { fit: "inside" })
.webp({ quality: 80 })
.toBuffer();
const previewPath = join(workspacePath, "output", "preview.webp");
await writeFile(previewPath, previewBuffer);
await putObject(`outputs/${jobId}/preview.webp`, previewBuffer);
previewUrl = `/api/v1/download/${jobId}/preview.webp`;
} catch {
// Non-fatal - frontend shows success card fallback
+75 -108
View File
@@ -1,20 +1,22 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { removeBackground } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { receiveUpload } from "../../lib/upload-stream.js";
import { registerToolProcessFn } from "../tool-factory.js";
const TOOL_ID = "transparency-fixer";
@@ -29,10 +31,6 @@ const settingsSchema = z.object({
/**
* Sharp-based defringe post-processing.
*
* Removes semi-transparent fringe pixels that rembg sometimes leaves around
* hair, fur, and fine edges. Works by blurring the alpha channel and zeroing
* out pixels whose alpha falls below a computed threshold.
*/
async function applyDefringe(buffer: Buffer, intensity: number): Promise<Buffer> {
if (intensity <= 0) return buffer;
@@ -44,13 +42,11 @@ async function applyDefringe(buffer: Buffer, intensity: number): Promise<Buffer>
const { data, info } = await img.raw().toBuffer({ resolveWithObject: true });
const pixelCount = info.width * info.height;
// Extract alpha channel
const alpha = Buffer.alloc(pixelCount);
for (let i = 0; i < pixelCount; i++) {
alpha[i] = data[i * 4 + 3];
}
// Blur the alpha channel
const blurRadius = Math.max(0.3, Math.round(intensity / 20));
const blurredAlphaRaw = await sharp(alpha, {
raw: { width: info.width, height: info.height, channels: 1 },
@@ -59,7 +55,6 @@ async function applyDefringe(buffer: Buffer, intensity: number): Promise<Buffer>
.raw()
.toBuffer();
// Threshold: zero out fringe pixels
const threshold = Math.round(128 + (intensity / 100) * 80);
const result = Buffer.from(data);
for (let i = 0; i < pixelCount; i++) {
@@ -129,6 +124,31 @@ async function processTransparencyFix(
return resultBuffer;
}
// ── AI job handler ────────────────────────────────────────────────
registerAiJobHandler("transparency-fixer", async (input, data, ctx) => {
const settings = settingsSchema.parse(data.settings);
const resultBuffer = await processTransparencyFix(
input,
settings,
ctx.scratchDir,
(percent, stage) => ctx.report(Math.min(percent, 95), stage),
);
const outputExt = settings.outputFormat === "webp" ? "webp" : "png";
const outputFilename = `${data.filename.replace(/\.[^.]+$/, "")}_fixed.${outputExt}`;
const contentType = outputExt === "webp" ? "image/webp" : "image/png";
return {
buffer: resultBuffer,
filename: outputFilename,
contentType,
resultPayload: {
filename: data.filename,
},
};
});
export function registerTransparencyFixer(app: FastifyInstance) {
app.post(
"/api/v1/tools/transparency-fixer",
@@ -144,19 +164,20 @@ export function registerTransparencyFixer(app: FastifyInstance) {
});
}
const jobId = randomUUID();
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let inputKey: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) chunks.push(chunk);
fileBuffer = Buffer.concat(chunks);
filename = sanitizeFilename(part.filename ?? "image");
const upload = await receiveUpload(part, jobId);
inputKey = upload.key;
filename = upload.filename;
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
@@ -169,14 +190,16 @@ export function registerTransparencyFixer(app: FastifyInstance) {
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
fileBuffer = await getObjectBuffer(inputKey);
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
@@ -197,104 +220,48 @@ export function registerTransparencyFixer(app: FastifyInstance) {
}
try {
// Decode HEIC/HEIF before processing
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
const ext = filename.match(/\.[^.]+$/)?.[0];
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
}
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
if (needsCliDecode(validation.format)) {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
const ext = filename.match(/\.[^.]+$/)?.[0];
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
}
// Auto-orient to fix EXIF rotation
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: TOOL_ID }, "Input decoding failed");
return reply.status(422).send({
error: "Transparency fix failed",
details: err instanceof Error ? err.message : "Unknown error",
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
});
}
const originalSize = fileBuffer.length;
const jobId = randomUUID();
const decodedKey = `uploads/${jobId}/${filename}`;
if (decodedKey !== inputKey) {
await putObject(decodedKey, fileBuffer);
inputKey = decodedKey;
} else {
await putObject(inputKey, fileBuffer);
}
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
} catch (err) {
request.log.error({ err, toolId: TOOL_ID }, "Workspace creation failed");
return reply.status(422).send({
error: "Transparency fix failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const log = request.log;
log.info(
{ toolId: TOOL_ID, imageSize: originalSize, model: DEFAULT_MODEL },
"Starting transparency fix",
);
// 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),
});
};
const outputExt = settings.outputFormat === "webp" ? "webp" : "png";
// Fire-and-forget: processing happens after the response is sent
(async () => {
const resultBuffer = await processTransparencyFix(
fileBuffer,
settings,
join(workspacePath, "output"),
onProgress,
);
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_fixed.${outputExt}`;
await writeFile(join(workspacePath, "output", outputFilename), resultBuffer);
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
originalSize,
processedSize: resultBuffer.length,
filename,
},
});
log.info({ toolId: TOOL_ID, jobId, downloadUrl }, "Transparency fix complete");
})().catch((err) => {
log.error({ err, toolId: TOOL_ID }, "Transparency fix failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Transparency fix failed",
});
await enqueueToolJob({
jobId,
toolId: TOOL_ID,
userId: null,
pool: "ai",
inputRefs: [inputKey],
filename,
settings,
clientJobId: clientJobId ?? undefined,
kind: "ai-tool",
});
return reply.status(202).send({ jobId: progressJobId, async: true });
},
);
@@ -302,22 +269,22 @@ export function registerTransparencyFixer(app: FastifyInstance) {
registerToolProcessFn({
toolId: TOOL_ID,
settingsSchema,
process: async (inputBuffer, settings, filename) => {
process: async (inputBuffer, settings, filename, ctx) => {
const s = settings as z.infer<typeof settingsSchema>;
const orientedBuffer = await autoOrient(inputBuffer);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID());
const needsCleanup = !ctx?.scratchDir;
if (needsCleanup) await mkdir(scratchDir, { recursive: true });
try {
const resultBuffer = await processTransparencyFix(orientedBuffer, s, scratchDir);
const resultBuffer = await processTransparencyFix(
orientedBuffer,
s,
join(workspacePath, "output"),
);
const outputExt = s.outputFormat === "webp" ? "webp" : "png";
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_fixed.${outputExt}`;
const contentType = outputExt === "webp" ? "image/webp" : "image/png";
return { buffer: resultBuffer, filename: outputFilename, contentType };
const outputExt = s.outputFormat === "webp" ? "webp" : "png";
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_fixed.${outputExt}`;
const contentType = outputExt === "webp" ? "image/webp" : "image/png";
return { buffer: resultBuffer, filename: outputFilename, contentType };
} finally {
if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
}
},
});
}
+141 -175
View File
@@ -1,22 +1,24 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { upscale } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { getObjectBuffer, putObject } from "../../lib/object-storage.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { receiveUpload } from "../../lib/upload-stream.js";
import { registerToolProcessFn } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -28,6 +30,86 @@ const settingsSchema = z.object({
quality: z.union([z.number(), z.string()]).transform(Number).default(95),
});
// ── AI job handler (runs inside the BullMQ worker) ────────────────
registerAiJobHandler("upscale", async (input, data, ctx) => {
const settings = settingsSchema.parse(data.settings);
const scale = settings.scale;
const model = settings.model;
const faceEnhance = settings.faceEnhance;
const denoise = settings.denoise;
let format = settings.format;
const outputQuality = settings.quality;
if (format === "auto") {
const detected = await resolveOutputFormat(input, data.filename);
format = detected.format === "jpeg" ? "jpg" : detected.format;
}
const needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format);
const pythonFormat = needsNodeConversion ? "png" : format;
const result = await upscale(
input,
ctx.scratchDir,
{ scale, model, faceEnhance, denoise, format: pythonFormat, quality: outputQuality },
(percent, stage) => ctx.report(percent, stage),
);
let outputBuffer = result.buffer;
let finalFormat = result.format;
if (needsNodeConversion) {
if (format === "heic" || format === "heif") {
outputBuffer = await encodeHeic(result.buffer, outputQuality);
finalFormat = format;
} else if (format === "jxl") {
outputBuffer = await encodeJxl(result.buffer, outputQuality);
finalFormat = "jxl";
} else if (format === "avif") {
outputBuffer = await sharp(result.buffer).avif({ quality: outputQuality }).toBuffer();
finalFormat = "avif";
}
}
const EXT_MAP: Record<string, string> = {
jpeg: "jpg",
jpg: "jpg",
png: "png",
webp: "webp",
tiff: "tiff",
gif: "gif",
avif: "avif",
heic: "heic",
heif: "heif",
jxl: "jxl",
};
const ext = EXT_MAP[finalFormat] || "png";
const outputFilename = `${data.filename.replace(/\.[^.]+$/, "")}_${scale}x.${ext}`;
const CONTENT_TYPES: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
webp: "image/webp",
tiff: "image/tiff",
gif: "image/gif",
avif: "image/avif",
heic: "image/heic",
heif: "image/heif",
jxl: "image/jxl",
};
return {
buffer: outputBuffer,
filename: outputFilename,
contentType: CONTENT_TYPES[finalFormat] || "image/png",
resultPayload: {
width: result.width,
height: result.height,
method: result.method,
},
};
});
/**
* AI image upscaling route.
* Uses Real-ESRGAN when available, falls back to Lanczos.
@@ -46,21 +128,20 @@ export function registerUpscale(app: FastifyInstance) {
});
}
const jobId = randomUUID();
let fileBuffer: Buffer | null = null;
let filename = "image";
let settingsRaw: string | null = null;
let clientJobId: string | null = null;
let inputKey: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
fileBuffer = Buffer.concat(chunks);
filename = sanitizeFilename(part.filename ?? "image");
const upload = await receiveUpload(part, jobId);
inputKey = upload.key;
filename = upload.filename;
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
@@ -73,16 +154,19 @@ export function registerUpscale(app: FastifyInstance) {
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
if (!inputKey) {
return reply.status(400).send({ error: "No image file provided" });
}
fileBuffer = await getObjectBuffer(inputKey);
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
// Orphaned uploads/<jobId>/ dir will be cleaned by T10 TTL sweeper
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
}
@@ -100,169 +184,46 @@ export function registerUpscale(app: FastifyInstance) {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const scale = settings.scale;
const model = settings.model;
const faceEnhance = settings.faceEnhance;
const denoise = settings.denoise;
let format = settings.format;
const outputQuality = settings.quality;
try {
if (format === "auto") {
const detected = await resolveOutputFormat(fileBuffer, filename);
format = detected.format === "jpeg" ? "jpg" : detected.format;
}
// Decode HEIC/HEIF input via system decoder
if (validation.format === "heif") {
fileBuffer = await decodeHeic(fileBuffer);
}
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
if (needsCliDecode(validation.format)) {
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
}
// Auto-orient to fix EXIF rotation before upscaling
fileBuffer = await autoOrient(fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "upscale" }, "Input decoding failed");
return reply.status(422).send({
error: "Upscaling failed",
details: err instanceof Error ? err.message : "Unknown error",
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
});
}
const originalSize = fileBuffer.length;
const jobId = randomUUID();
// Write decoded input for the worker
const decodedKey = `uploads/${jobId}/${filename}`;
if (decodedKey !== inputKey) {
await putObject(decodedKey, fileBuffer);
inputKey = decodedKey;
} else {
await putObject(inputKey, fileBuffer);
}
const progressJobId = clientJobId || jobId;
let workspacePath: string;
try {
workspacePath = await createWorkspace(jobId);
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
} catch (err) {
request.log.error({ err, toolId: "upscale" }, "Workspace creation failed");
return reply.status(422).send({
error: "Upscaling failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
const log = request.log;
log.info(
{ toolId: "upscale", imageSize: originalSize, scale, model, format },
"Starting upscale",
);
// 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 needsNodeConversion = ["heic", "heif", "avif", "jxl"].includes(format);
const pythonFormat = needsNodeConversion ? "png" : format;
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 upscale(
fileBuffer,
join(workspacePath, "output"),
{ scale, model, faceEnhance, denoise, format: pythonFormat, quality: outputQuality },
onProgress,
);
let outputBuffer = result.buffer;
let finalFormat = result.format;
if (needsNodeConversion) {
if (format === "heic" || format === "heif") {
outputBuffer = await encodeHeic(result.buffer, outputQuality);
finalFormat = format;
} else if (format === "jxl") {
outputBuffer = await encodeJxl(result.buffer, outputQuality);
finalFormat = "jxl";
} else if (format === "avif") {
outputBuffer = await sharp(result.buffer).avif({ quality: outputQuality }).toBuffer();
finalFormat = "avif";
}
}
const EXT_MAP: Record<string, string> = {
jpeg: "jpg",
jpg: "jpg",
png: "png",
webp: "webp",
tiff: "tiff",
gif: "gif",
avif: "avif",
heic: "heic",
heif: "heif",
jxl: "jxl",
};
const ext = EXT_MAP[finalFormat] || "png";
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_${scale}x.${ext}`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, outputBuffer);
const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif", "bmp"]);
let previewUrl: string | undefined;
if (!BROWSER_PREVIEWABLE.has(finalFormat)) {
try {
const previewInput =
finalFormat === "heic" || finalFormat === "heif"
? await decodeHeic(outputBuffer)
: outputBuffer;
const previewBuffer = await sharp(previewInput).webp({ quality: 80 }).toBuffer();
const previewPath = join(workspacePath, "output", "preview.webp");
await writeFile(previewPath, previewBuffer);
previewUrl = `/api/v1/download/${jobId}/preview.webp`;
} catch {
// Non-fatal
}
}
if (model !== "auto" && result.method !== model) {
log.warn(
{ toolId: "upscale", requested: model, actual: result.method },
`Upscale model mismatch: requested ${model} but used ${result.method}`,
);
}
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
updateSingleFileProgress({
jobId: progressJobId,
phase: "complete",
percent: 100,
result: {
jobId,
downloadUrl,
previewUrl,
originalSize,
processedSize: outputBuffer.length,
width: result.width,
height: result.height,
method: result.method,
},
});
log.info({ toolId: "upscale", jobId, downloadUrl }, "Upscale complete");
})().catch((err) => {
log.error({ err, toolId: "upscale" }, "Upscaling failed");
updateSingleFileProgress({
jobId: progressJobId,
phase: "failed",
percent: 0,
error: err instanceof Error ? err.message : "Upscale failed",
});
await enqueueToolJob({
jobId,
toolId,
userId: null,
pool: "ai",
inputRefs: [inputKey],
filename,
settings,
clientJobId: clientJobId ?? undefined,
kind: "ai-tool",
});
return reply.status(202).send({ jobId: progressJobId, async: true });
});
// Register in the pipeline/batch registry so this tool can be used
@@ -272,26 +233,31 @@ export function registerUpscale(app: FastifyInstance) {
settingsSchema: z.object({
scale: z.union([z.number(), z.string()]).transform(Number).default(2),
}),
process: async (inputBuffer, settings, filename) => {
process: async (inputBuffer, settings, filename, ctx) => {
const scale = Number((settings as { scale?: number }).scale) || 2;
const orientedBuffer = await autoOrient(inputBuffer);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const result = await upscale(orientedBuffer, join(workspacePath, "output"), { scale });
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
let outputBuffer = result.buffer;
if (outputFormat.format !== "png") {
outputBuffer = await sharp(result.buffer)
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
const scratchDir = ctx?.scratchDir ?? join(tmpdir(), "snapotter-scratch", randomUUID());
const needsCleanup = !ctx?.scratchDir;
if (needsCleanup) await mkdir(scratchDir, { recursive: true });
try {
const result = await upscale(orientedBuffer, scratchDir, { scale });
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
let outputBuffer = result.buffer;
if (outputFormat.format !== "png") {
outputBuffer = await sharp(result.buffer)
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
}
const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format;
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_${scale}x.${ext}`;
return {
buffer: outputBuffer,
filename: outputFilename,
contentType: outputFormat.contentType,
};
} finally {
if (needsCleanup) await rm(scratchDir, { recursive: true, force: true }).catch(() => {});
}
const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format;
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_${scale}x.${ext}`;
return {
buffer: outputBuffer,
filename: outputFilename,
contentType: outputFormat.contentType,
};
},
});
}
+2 -6
View File
@@ -1,6 +1,4 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { vectorize as vtrace } from "@neplex/vectorizer";
import type { FastifyInstance } from "fastify";
import potrace from "potrace";
@@ -12,8 +10,8 @@ import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { putObject } from "../../lib/object-storage.js";
import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js";
import { createWorkspace } from "../../lib/workspace.js";
import { registerToolProcessFn } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -189,9 +187,7 @@ export function registerVectorize(app: FastifyInstance) {
const result = await vectorizeBuffer(fileBuffer, settings, filename);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", result.filename);
await writeFile(outputPath, result.buffer);
await putObject(`outputs/${jobId}/${result.filename}`, result.buffer);
return reply.send({
jobId,
+3 -9
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
@@ -7,6 +8,7 @@ import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { putObject } from "../../lib/object-storage.js";
import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js";
const settingsSchema = z.object({
@@ -226,16 +228,8 @@ export function registerWatermarkImage(app: FastifyInstance) {
.composite([{ input: wmBuffer, top, left }])
.toBuffer();
// Use tool-factory's workspace pattern
const { randomUUID } = await import("node:crypto");
const { writeFile } = await import("node:fs/promises");
const { join } = await import("node:path");
const { createWorkspace } = await import("../../lib/workspace.js");
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, result);
await putObject(`outputs/${jobId}/${filename}`, result);
return reply.send({
jobId,