mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(jobs)!: SnapOtter 2.0 phase 2 job spine: async queues, worker pools, object storage, admin dashboard (#217)
This commit is contained in:
@@ -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(() => {});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user