2026-03-22 04:03:38 +08:00
|
|
|
/**
|
|
|
|
|
* Batch processing route.
|
|
|
|
|
*
|
|
|
|
|
* POST /api/v1/tools/:toolId/batch
|
|
|
|
|
*
|
|
|
|
|
* Accepts multipart with multiple files + settings JSON.
|
|
|
|
|
* Processes all files through the tool using p-queue for concurrency control.
|
|
|
|
|
* Returns a ZIP file containing all processed images.
|
|
|
|
|
*/
|
|
|
|
|
import { randomUUID } from "node:crypto";
|
2026-04-18 02:40:16 +08:00
|
|
|
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@ashim/shared";
|
2026-03-22 04:03:38 +08:00
|
|
|
import archiver from "archiver";
|
2026-03-25 09:27:12 +08:00
|
|
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
2026-03-22 04:03:38 +08:00
|
|
|
import PQueue from "p-queue";
|
2026-03-25 09:27:12 +08:00
|
|
|
import { env } from "../config.js";
|
|
|
|
|
import { autoOrient } from "../lib/auto-orient.js";
|
2026-04-17 14:15:27 +08:00
|
|
|
import { formatZodErrors } from "../lib/errors.js";
|
2026-04-18 02:40:16 +08:00
|
|
|
import { isToolInstalled } from "../lib/feature-status.js";
|
2026-03-22 04:03:38 +08:00
|
|
|
import { validateImageBuffer } from "../lib/file-validation.js";
|
2026-03-23 11:46:45 +08:00
|
|
|
import { sanitizeFilename } from "../lib/filename.js";
|
2026-04-04 21:33:48 +08:00
|
|
|
import { decodeHeic } from "../lib/heic-converter.js";
|
2026-03-25 09:27:12 +08:00
|
|
|
import { type JobProgress, updateJobProgress } from "./progress.js";
|
|
|
|
|
import { getToolConfig } from "./tool-factory.js";
|
2026-03-22 04:03:38 +08:00
|
|
|
|
|
|
|
|
interface ParsedFile {
|
|
|
|
|
buffer: Buffer;
|
|
|
|
|
filename: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 09:27:12 +08:00
|
|
|
export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
|
2026-03-22 04:03:38 +08:00
|
|
|
app.post(
|
|
|
|
|
"/api/v1/tools/:toolId/batch",
|
2026-03-25 09:27:12 +08:00
|
|
|
async (request: FastifyRequest<{ Params: { toolId: string } }>, reply: FastifyReply) => {
|
2026-03-22 04:03:38 +08:00
|
|
|
const { toolId } = request.params;
|
|
|
|
|
|
|
|
|
|
// Look up the tool config from the registry
|
|
|
|
|
const toolConfig = getToolConfig(toolId);
|
|
|
|
|
if (!toolConfig) {
|
|
|
|
|
return reply.status(404).send({ error: `Tool "${toolId}" not found` });
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 02:40:16 +08:00
|
|
|
// Guard: check if the tool's AI feature bundle is installed
|
|
|
|
|
if (!isToolInstalled(toolId)) {
|
|
|
|
|
const bundle = getBundleForTool(toolId);
|
|
|
|
|
return reply.status(501).send({
|
|
|
|
|
error: "Feature not installed",
|
|
|
|
|
code: "FEATURE_NOT_INSTALLED",
|
|
|
|
|
feature: TOOL_BUNDLE_MAP[toolId],
|
|
|
|
|
featureName: bundle?.name ?? toolId,
|
|
|
|
|
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 04:03:38 +08:00
|
|
|
// Parse multipart: collect all files and the settings field
|
|
|
|
|
const files: ParsedFile[] = [];
|
|
|
|
|
let settingsRaw: string | null = null;
|
2026-03-23 14:03:56 +08:00
|
|
|
let clientJobId: string | null = null;
|
2026-03-22 04:03:38 +08:00
|
|
|
|
|
|
|
|
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 buffer = Buffer.concat(chunks);
|
|
|
|
|
if (buffer.length > 0) {
|
|
|
|
|
files.push({
|
|
|
|
|
buffer,
|
|
|
|
|
filename: sanitizeFilename(part.filename ?? "image"),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} else if (part.fieldname === "settings") {
|
|
|
|
|
settingsRaw = part.value as string;
|
2026-03-23 14:03:56 +08:00
|
|
|
} else if (part.fieldname === "clientJobId") {
|
|
|
|
|
clientJobId = part.value as string;
|
2026-03-22 04:03:38 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
return reply.status(400).send({
|
|
|
|
|
error: "Failed to parse multipart request",
|
|
|
|
|
details: err instanceof Error ? err.message : String(err),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (files.length === 0) {
|
|
|
|
|
return reply.status(400).send({ error: "No image files provided" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Enforce batch size limit
|
|
|
|
|
if (files.length > env.MAX_BATCH_SIZE) {
|
|
|
|
|
return reply.status(400).send({
|
|
|
|
|
error: `Too many files. Maximum batch size is ${env.MAX_BATCH_SIZE}`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Parse and validate settings
|
|
|
|
|
let settings: unknown;
|
|
|
|
|
try {
|
|
|
|
|
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
|
|
|
|
const result = toolConfig.settingsSchema.safeParse(parsed);
|
|
|
|
|
if (!result.success) {
|
|
|
|
|
return reply.status(400).send({
|
|
|
|
|
error: "Invalid settings",
|
2026-04-17 14:15:27 +08:00
|
|
|
details: formatZodErrors(result.error.issues),
|
2026-03-22 04:03:38 +08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
settings = result.data;
|
|
|
|
|
} catch {
|
|
|
|
|
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create a job ID for progress tracking
|
2026-03-23 14:03:56 +08:00
|
|
|
const jobId = clientJobId || randomUUID();
|
2026-03-22 04:03:38 +08:00
|
|
|
|
|
|
|
|
const progress: JobProgress = {
|
|
|
|
|
jobId,
|
|
|
|
|
status: "processing",
|
|
|
|
|
totalFiles: files.length,
|
|
|
|
|
completedFiles: 0,
|
|
|
|
|
failedFiles: 0,
|
|
|
|
|
errors: [],
|
|
|
|
|
};
|
|
|
|
|
updateJobProgress({ ...progress });
|
|
|
|
|
|
|
|
|
|
// Use p-queue for concurrency control
|
|
|
|
|
const queue = new PQueue({ concurrency: env.CONCURRENT_JOBS });
|
|
|
|
|
|
2026-04-06 12:53:53 +08:00
|
|
|
// All processed buffers are held in memory until ZIP streaming begins.
|
|
|
|
|
// Peak memory scales with files.length * avg output size. MAX_BATCH_SIZE bounds this.
|
|
|
|
|
// Collect results in indexed array to preserve upload order
|
|
|
|
|
const results: ({ buffer: Buffer; filename: string } | null)[] = new Array(files.length).fill(
|
|
|
|
|
null,
|
|
|
|
|
);
|
2026-03-22 04:03:38 +08:00
|
|
|
|
|
|
|
|
// Process all files through the queue
|
2026-03-24 00:41:54 +08:00
|
|
|
try {
|
2026-04-06 12:53:53 +08:00
|
|
|
const tasks = files.map((file, index) =>
|
2026-03-24 00:41:54 +08:00
|
|
|
queue.add(async () => {
|
|
|
|
|
progress.currentFile = file.filename;
|
2026-03-22 04:03:38 +08:00
|
|
|
updateJobProgress({ ...progress });
|
|
|
|
|
|
2026-03-24 00:41:54 +08:00
|
|
|
// Validate the image
|
|
|
|
|
const validation = await validateImageBuffer(file.buffer);
|
|
|
|
|
if (!validation.valid) {
|
|
|
|
|
progress.failedFiles++;
|
|
|
|
|
progress.errors.push({
|
|
|
|
|
filename: file.filename,
|
|
|
|
|
error: `Invalid image: ${validation.reason}`,
|
|
|
|
|
});
|
|
|
|
|
progress.completedFiles++;
|
|
|
|
|
updateJobProgress({ ...progress });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-22 04:03:38 +08:00
|
|
|
|
2026-03-24 00:41:54 +08:00
|
|
|
try {
|
2026-04-04 21:33:48 +08:00
|
|
|
let processBuffer = file.buffer;
|
2026-04-12 17:53:16 +08:00
|
|
|
let processFilename = file.filename;
|
2026-04-12 08:50:19 +08:00
|
|
|
// Skip HEIC decode and auto-orient for edit-metadata (ExifTool handles all formats natively)
|
|
|
|
|
const skipPreprocess = toolId === "edit-metadata" || toolId === "strip-metadata";
|
|
|
|
|
if (!skipPreprocess && validation.format === "heif") {
|
2026-04-04 21:33:48 +08:00
|
|
|
processBuffer = await decodeHeic(processBuffer);
|
2026-04-12 17:53:16 +08:00
|
|
|
// Update extension to match decoded format (HEIC/HEIF → PNG)
|
|
|
|
|
const ext = processFilename.match(/\.[^.]+$/)?.[0];
|
2026-04-14 22:15:37 +08:00
|
|
|
if (ext) processFilename = `${processFilename.slice(0, -ext.length)}.png`;
|
2026-04-04 21:33:48 +08:00
|
|
|
}
|
2026-04-12 08:50:19 +08:00
|
|
|
if (!skipPreprocess) {
|
|
|
|
|
processBuffer = await autoOrient(processBuffer);
|
|
|
|
|
}
|
2026-04-12 17:53:16 +08:00
|
|
|
const result = await toolConfig.process(processBuffer, settings, processFilename);
|
2026-03-22 04:03:38 +08:00
|
|
|
|
2026-04-06 12:53:53 +08:00
|
|
|
results[index] = { buffer: result.buffer, filename: result.filename };
|
2026-03-22 04:03:38 +08:00
|
|
|
|
2026-03-24 00:41:54 +08:00
|
|
|
progress.completedFiles++;
|
|
|
|
|
updateJobProgress({ ...progress });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
progress.failedFiles++;
|
|
|
|
|
progress.errors.push({
|
|
|
|
|
filename: file.filename,
|
|
|
|
|
error: err instanceof Error ? err.message : "Processing failed",
|
|
|
|
|
});
|
|
|
|
|
progress.completedFiles++;
|
|
|
|
|
updateJobProgress({ ...progress });
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await Promise.all(tasks);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
request.log.error({ err }, "Unexpected error in batch queue");
|
|
|
|
|
}
|
2026-03-22 04:03:38 +08:00
|
|
|
|
|
|
|
|
// Finalize progress
|
2026-03-25 09:27:12 +08:00
|
|
|
progress.status = progress.failedFiles === progress.totalFiles ? "failed" : "completed";
|
2026-03-22 04:03:38 +08:00
|
|
|
progress.currentFile = undefined;
|
|
|
|
|
updateJobProgress({ ...progress });
|
|
|
|
|
|
2026-04-06 12:53:53 +08:00
|
|
|
// Deduplicate filenames in original order and build X-File-Results header
|
|
|
|
|
const usedNames = new Set<string>();
|
|
|
|
|
function getUniqueName(name: string): string {
|
|
|
|
|
if (!usedNames.has(name)) {
|
|
|
|
|
usedNames.add(name);
|
|
|
|
|
return name;
|
|
|
|
|
}
|
|
|
|
|
const dotIdx = name.lastIndexOf(".");
|
|
|
|
|
const base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
|
|
|
|
|
const ext = dotIdx > 0 ? name.slice(dotIdx) : "";
|
|
|
|
|
let counter = 1;
|
|
|
|
|
let candidate = `${base}_${counter}${ext}`;
|
|
|
|
|
while (usedNames.has(candidate)) {
|
|
|
|
|
counter++;
|
|
|
|
|
candidate = `${base}_${counter}${ext}`;
|
|
|
|
|
}
|
|
|
|
|
usedNames.add(candidate);
|
|
|
|
|
return candidate;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fileResultsMap: Record<string, string> = {};
|
|
|
|
|
for (let i = 0; i < results.length; i++) {
|
|
|
|
|
const entry = results[i];
|
|
|
|
|
if (entry) {
|
|
|
|
|
const uniqueName = getUniqueName(entry.filename);
|
|
|
|
|
entry.filename = uniqueName;
|
|
|
|
|
fileResultsMap[String(i)] = uniqueName;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If every file failed, return an error instead of an empty ZIP
|
|
|
|
|
if (progress.status === "failed") {
|
|
|
|
|
return reply.status(422).send({
|
|
|
|
|
error: "All files failed processing",
|
|
|
|
|
errors: progress.errors,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Hijack and stream the ZIP response after all processing
|
|
|
|
|
reply.hijack();
|
|
|
|
|
reply.raw.writeHead(200, {
|
|
|
|
|
"Content-Type": "application/zip",
|
|
|
|
|
"Content-Disposition": `attachment; filename="batch-${toolId}-${jobId.slice(0, 8)}.zip"`,
|
|
|
|
|
"Transfer-Encoding": "chunked",
|
|
|
|
|
"X-Job-Id": jobId,
|
|
|
|
|
"X-File-Results": JSON.stringify(fileResultsMap),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const archive = archiver("zip", { zlib: { level: 5 } });
|
|
|
|
|
|
|
|
|
|
archive.on("error", (err) => {
|
|
|
|
|
request.log.error({ err }, "Archiver error during batch processing");
|
|
|
|
|
if (!reply.raw.writableEnded) {
|
|
|
|
|
reply.raw.end();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
archive.pipe(reply.raw);
|
|
|
|
|
|
|
|
|
|
// Append results in original upload order
|
|
|
|
|
for (const result of results) {
|
|
|
|
|
if (result) {
|
|
|
|
|
archive.append(result.buffer, { name: result.filename });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 04:03:38 +08:00
|
|
|
await archive.finalize();
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|