fix: verbose error handling, batch processing, and multi-file support

- Replace [object Object] errors with readable messages across all 20+ API
  routes by normalizing Zod validation errors to strings (formatZodErrors)
- Add parseApiError() on frontend to defensively handle any details type
- Add global Fastify error handler with full stack traces in logs
- Fix image-to-pdf auth: Object.entries(headers) → headers.forEach()
- Fix passport-photo: safeParse + formatZodErrors, safe error extraction
- Fix OCR silent fallbacks: log exception type/message when falling back,
  include actual engine used in API response and Docker logs
- Fix split tool: process all uploaded images, combine into ZIP with
  subfolders per image
- Fix batch support for blur-faces, strip-metadata, edit-metadata,
  vectorize: add processAllFiles branch for multi-file uploads
- Docker: LOG_LEVEL=debug, PYTHONWARNINGS=default for visibility
- Add Playwright e2e tests verifying all fixes against Docker container
This commit is contained in:
ashim-hq
2026-04-17 14:15:27 +08:00
parent 2e2dbbb8e0
commit 32239600ae
39 changed files with 936 additions and 163 deletions
+4 -1
View File
@@ -3,6 +3,7 @@ import { basename, extname } from "node:path";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { formatZodErrors } from "../../lib/errors.js";
const settingsSchema = z.object({
pattern: z.string().min(1).max(200).default("image-{{index}}"),
@@ -53,7 +54,9 @@ export function registerBulkRename(app: FastifyInstance) {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
+4 -1
View File
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -467,7 +468,9 @@ export function registerCollage(app: FastifyInstance) {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
+4 -1
View File
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -74,7 +75,9 @@ export function registerCompose(app: FastifyInstance) {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
@@ -5,6 +5,7 @@ import { seamCarve } from "@ashim/ai";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -82,10 +83,7 @@ export function registerContentAwareResize(app: FastifyInstance) {
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
details: formatZodErrors(result.error.issues),
});
}
settings = result.data;
+2 -4
View File
@@ -4,6 +4,7 @@ import { basename, join } from "node:path";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { formatZodErrors } from "../../lib/errors.js";
import {
buildTagArgs,
type EditMetadataSettings,
@@ -150,10 +151,7 @@ export function registerEditMetadata(app: FastifyInstance) {
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
details: formatZodErrors(result.error.issues),
});
}
settings = result.data;
+4 -1
View File
@@ -6,6 +6,7 @@ import PDFDocument from "pdfkit";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -62,7 +63,9 @@ export function registerImageToPdf(app: FastifyInstance) {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
+10 -1
View File
@@ -3,6 +3,7 @@ import { basename } from "node:path";
import { extractText } from "@ashim/ai";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
@@ -66,7 +67,7 @@ export function registerOcr(app: FastifyInstance) {
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: result.error.issues });
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
@@ -135,10 +136,18 @@ export function registerOcr(app: FastifyInstance) {
});
}
if (result.engine && result.engine !== tier) {
request.log.warn(
{ toolId: "ocr", requested: tier, actual: result.engine },
`OCR engine fallback: requested ${tier} but used ${result.engine}`,
);
}
return reply.send({
jobId,
filename,
text: result.text,
engine: result.engine,
});
} catch (err) {
lastError = err;
@@ -4,6 +4,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeHeic } from "../../lib/heic-converter.js";
@@ -117,10 +118,7 @@ export function registerOptimizeForWeb(app: FastifyInstance) {
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
details: formatZodErrors(result.error.issues),
});
}
settings = result.data;
+5 -5
View File
@@ -7,6 +7,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace, getWorkspacePath } from "../../lib/workspace.js";
@@ -275,15 +276,14 @@ export function registerPassportPhoto(app: FastifyInstance) {
app.post(
"/api/v1/tools/passport-photo/generate",
async (request: FastifyRequest, reply: FastifyReply) => {
let parsed: z.infer<typeof generateSettingsSchema>;
try {
parsed = generateSettingsSchema.parse(request.body);
} catch (err) {
const parseResult = generateSettingsSchema.safeParse(request.body);
if (!parseResult.success) {
return reply.status(400).send({
error: "Invalid settings",
details: err instanceof Error ? err.message : String(err),
details: formatZodErrors(parseResult.error.issues),
});
}
const parsed = parseResult.data;
const {
jobId,
+4 -1
View File
@@ -7,6 +7,7 @@ import type { FastifyInstance } from "fastify";
import * as mupdf from "mupdf";
import sharp from "sharp";
import { z } from "zod";
import { formatZodErrors } from "../../lib/errors.js";
import { encodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -290,7 +291,9 @@ export function registerPdfToImage(app: FastifyInstance) {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
+2 -4
View File
@@ -4,6 +4,7 @@ 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";
const settingsSchema = z.object({
@@ -37,10 +38,7 @@ export function registerQrGenerate(app: FastifyInstance) {
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
details: formatZodErrors(result.error.issues),
});
}
+4 -1
View File
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
const settingsSchema = z.object({
@@ -67,7 +68,9 @@ export function registerSplit(app: FastifyInstance) {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
+4 -1
View File
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -91,7 +92,9 @@ export function registerStitch(app: FastifyInstance) {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
+5 -2
View File
@@ -7,6 +7,7 @@ import PQueue from "p-queue";
import sharp from "sharp";
import { z } from "zod";
import { env } from "../../config.js";
import { formatZodErrors } from "../../lib/errors.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { isSvgBuffer, sanitizeSvg } from "../../lib/svg-sanitize.js";
@@ -147,7 +148,7 @@ export function registerSvgToRaster(app: FastifyInstance) {
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: result.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })),
details: formatZodErrors(result.error.issues),
});
}
settings = result.data;
@@ -365,7 +366,9 @@ export function registerSvgToRaster(app: FastifyInstance) {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
+4 -1
View File
@@ -7,6 +7,7 @@ import potrace from "potrace";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -82,7 +83,9 @@ export function registerVectorize(app: FastifyInstance) {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
+4 -1
View File
@@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
const settingsSchema = z.object({
@@ -56,7 +57,9 @@ export function registerWatermarkImage(app: FastifyInstance) {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {