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
@@ -84,12 +84,24 @@ export function BlurFacesControls({ settings: initialSettings, onChange }: BlurF
export function BlurFacesSettings() {
const { files } = useFileStore();
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor("blur-faces");
const {
processFiles,
processAllFiles,
processing,
error,
downloadUrl,
originalSize,
processedSize,
progress,
} = useToolProcessor("blur-faces");
const [settings, setSettings] = useState<Record<string, unknown>>({});
const handleProcess = () => {
processFiles(files, settings);
if (files.length > 1) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
const hasFile = files.length > 0;
@@ -133,8 +133,16 @@ function LabeledInput({
export function EditMetadataSettings() {
const { entries, selectedIndex, files } = useFileStore();
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor("edit-metadata");
const {
processFiles,
processAllFiles,
processing,
error,
downloadUrl,
originalSize,
processedSize,
progress,
} = useToolProcessor("edit-metadata");
const [form, setForm] = useState<FormFields>(EMPTY_FORM);
const [initialForm, setInitialForm] = useState<FormFields>(EMPTY_FORM);
@@ -391,7 +399,11 @@ export function EditMetadataSettings() {
settings.fieldsToRemove = Array.from(fieldsToRemove);
}
processFiles(files, settings);
if (files.length > 1) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
return (
@@ -111,7 +111,13 @@ export function EraseObjectSettings({
} else {
try {
const body = JSON.parse(xhr.responseText);
setError(body.error || body.details || `Failed: ${xhr.status}`);
setError(
typeof body.error === "string"
? body.error
: typeof body.details === "string"
? body.details
: `Failed: ${xhr.status}`,
);
} catch {
setError(`Processing failed: ${xhr.status}`);
}
@@ -220,10 +220,9 @@ export function ImageToPdfSettings() {
};
xhr.open("POST", "/api/v1/tools/image-to-pdf");
const headers = formatHeaders();
for (const [key, value] of Object.entries(headers)) {
xhr.setRequestHeader(key, value as string);
}
formatHeaders().forEach((value, key) => {
xhr.setRequestHeader(key, value);
});
xhr.send(formData);
}, [files, pageSize, orientation, margin, setProcessing, setError]);
@@ -442,7 +442,14 @@ export function PassportPhotoSettings() {
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(body?.details || body?.error || `Analysis failed: ${response.status}`);
const msg = body
? typeof body.details === "string"
? body.details
: typeof body.error === "string"
? body.error
: `Analysis failed: ${response.status}`
: `Analysis failed: ${response.status}`;
throw new Error(msg);
}
const result = await response.json();
@@ -504,9 +511,14 @@ export function PassportPhotoSettings() {
if (!response.ok) {
const errBody = await response.json().catch(() => null);
throw new Error(
errBody?.details || errBody?.error || `Generation failed: ${response.status}`,
);
const msg = errBody
? typeof errBody.details === "string"
? errBody.details
: typeof errBody.error === "string"
? errBody.error
: `Generation failed: ${response.status}`
: `Generation failed: ${response.status}`;
throw new Error(msg);
}
const result: GenerateResult = await response.json();
@@ -92,8 +92,6 @@ export function SplitSettings() {
setZipBlobUrl(null);
try {
const formData = new FormData();
formData.append("file", files[0]);
const effectiveGrid = getEffectiveGrid();
const settings: Record<string, unknown> = {
columns: effectiveGrid.columns,
@@ -107,41 +105,60 @@ export function SplitSettings() {
if (LOSSY_FORMATS.has(outputFormat)) {
settings.quality = quality;
}
formData.append("settings", JSON.stringify(settings));
const res = await fetch("/api/v1/tools/split", {
method: "POST",
headers: formatHeaders(),
body: formData,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Failed: ${res.status}`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setZipBlobUrl(url);
const settingsJson = JSON.stringify(settings);
const JSZip = (await import("jszip")).default;
const zip = await JSZip.loadAsync(blob);
const tileEntries: Array<{ row: number; col: number; blobUrl: string | null }> = [];
const combinedZip = new JSZip();
const previewTiles: Array<{ row: number; col: number; blobUrl: string | null }> = [];
const multiFile = files.length > 1;
const fileNames = Object.keys(zip.files).filter((n) => !zip.files[n].dir);
fileNames.sort();
for (let fi = 0; fi < files.length; fi++) {
const file = files[fi];
const formData = new FormData();
formData.append("file", file);
formData.append("settings", settingsJson);
for (const name of fileNames) {
const fileData = await zip.files[name].async("blob");
const tileBlobUrl = URL.createObjectURL(fileData);
const match = name.match(/_r(\d+)_c(\d+)/);
const row = match ? Number.parseInt(match[1], 10) : 0;
const col = match ? Number.parseInt(match[2], 10) : 0;
tileEntries.push({ row, col, blobUrl: tileBlobUrl });
const res = await fetch("/api/v1/tools/split", {
method: "POST",
headers: formatHeaders(),
body: formData,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to split ${file.name}: ${text || res.status}`);
}
const blob = await res.blob();
const fileZip = await JSZip.loadAsync(blob);
const baseName = file.name.replace(/\.[^.]+$/, "");
const prefix = multiFile ? `${baseName}/` : "";
const fileNames = Object.keys(fileZip.files).filter((n) => !fileZip.files[n].dir);
fileNames.sort();
for (const name of fileNames) {
const data = await fileZip.files[name].async("uint8array");
combinedZip.file(`${prefix}${name}`, data);
if (fi === 0) {
const tileBlob = new Blob([data as BlobPart]);
const tileBlobUrl = URL.createObjectURL(tileBlob);
const match = name.match(/_r(\d+)_c(\d+)/);
previewTiles.push({
row: match ? Number.parseInt(match[1], 10) : 0,
col: match ? Number.parseInt(match[2], 10) : 0,
blobUrl: tileBlobUrl,
});
}
}
}
tileEntries.sort((a, b) => a.row - b.row || a.col - b.col);
const combinedBlob = await combinedZip.generateAsync({ type: "blob" });
setZipBlobUrl(URL.createObjectURL(combinedBlob));
previewTiles.sort((a, b) => a.row - b.row || a.col - b.col);
setTiles(
tileEntries.map((t, i) => ({
previewTiles.map((t, i) => ({
row: t.row,
col: t.col,
label: `${i + 1}`,
@@ -173,7 +190,8 @@ export function SplitSettings() {
if (!zipBlobUrl) return;
const a = document.createElement("a");
a.href = zipBlobUrl;
const baseName = files[0]?.name?.replace(/\.[^.]+$/, "") ?? "split";
const baseName =
files.length > 1 ? "split-batch" : (files[0]?.name?.replace(/\.[^.]+$/, "") ?? "split");
a.download = `${baseName}-${grid.columns}x${grid.rows}.zip`;
a.click();
}, [zipBlobUrl, files, grid]);
@@ -381,12 +399,20 @@ export function SplitSettings() {
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Splitting..." : `Split into ${tileCount} Tiles`}
{processing
? "Splitting..."
: files.length > 1
? `Split ${files.length} Images (${tileCount} tiles each)`
: `Split into ${tileCount} Tiles`}
</button>
{hasTiles && (
<div className="space-y-3 border-t border-border pt-3">
<p className="text-xs font-medium text-foreground">{tiles.length} Tiles Generated</p>
<p className="text-xs font-medium text-foreground">
{files.length > 1
? `${files.length} images split (${tiles.length} tiles each)`
: `${tiles.length} Tiles Generated`}
</p>
<div
className="grid gap-1"
style={{ gridTemplateColumns: `repeat(${grid.columns}, 1fr)` }}
@@ -197,8 +197,16 @@ export function StripMetadataControls({
export function StripMetadataSettings() {
const { entries, selectedIndex, files } = useFileStore();
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
useToolProcessor("strip-metadata");
const {
processFiles,
processAllFiles,
processing,
error,
downloadUrl,
originalSize,
processedSize,
progress,
} = useToolProcessor("strip-metadata");
const [stripSettings, setStripSettings] = useState<Record<string, unknown>>({
stripAll: true,
@@ -267,7 +275,11 @@ export function StripMetadataSettings() {
}, [currentFile, fileKey, metadataCache]);
const handleProcess = () => {
processFiles(files, stripSettings);
if (files.length > 1) {
processAllFiles(files, stripSettings);
} else {
processFiles(files, stripSettings);
}
};
const hasFile = files.length > 0;
@@ -115,41 +115,92 @@ export function VectorizeSettings() {
setError(null);
setDownloadUrl(null);
const settingsJson = JSON.stringify({
colorMode,
threshold,
colorPrecision,
layerDifference,
filterSpeckle,
pathMode,
cornerThreshold,
invert,
});
try {
const formData = new FormData();
formData.append("file", files[0]);
formData.append(
"settings",
JSON.stringify({
colorMode,
threshold,
colorPrecision,
layerDifference,
filterSpeckle,
pathMode,
cornerThreshold,
invert,
}),
);
if (files.length === 1) {
const formData = new FormData();
formData.append("file", files[0]);
formData.append("settings", settingsJson);
const res = await fetch("/api/v1/tools/vectorize", {
method: "POST",
headers: formatHeaders(),
body: formData,
});
const res = await fetch("/api/v1/tools/vectorize", {
method: "POST",
headers: formatHeaders(),
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Failed: ${res.status}`);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Failed: ${res.status}`);
}
const result = await res.json();
setJobId(result.jobId);
setProcessedUrl(result.downloadUrl);
setDownloadUrl(result.downloadUrl);
setOriginalSize(result.originalSize);
setProcessedSize(result.processedSize);
setSizes(result.originalSize, result.processedSize);
} else {
const { updateEntry, setBatchZip } = useFileStore.getState();
const JSZip = (await import("jszip")).default;
const zip = new JSZip();
let totalOriginal = 0;
let totalProcessed = 0;
for (let i = 0; i < files.length; i++) {
const file = files[i];
const formData = new FormData();
formData.append("file", file);
formData.append("settings", settingsJson);
const res = await fetch("/api/v1/tools/vectorize", {
method: "POST",
headers: formatHeaders(),
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
updateEntry(i, {
status: "failed",
error: body.error || `Failed: ${res.status}`,
});
continue;
}
const result = await res.json();
totalOriginal += result.originalSize;
totalProcessed += result.processedSize;
const svgRes = await fetch(result.downloadUrl, { headers: formatHeaders() });
const svgBlob = await svgRes.blob();
const svgName = file.name.replace(/\.[^.]+$/, ".svg");
zip.file(svgName, svgBlob);
updateEntry(i, {
processedUrl: result.downloadUrl,
processedSize: result.processedSize,
status: "completed",
error: null,
});
}
const zipBlob = await zip.generateAsync({ type: "blob" });
setBatchZip(zipBlob, "vectorize-batch.zip");
setOriginalSize(totalOriginal);
setProcessedSize(totalProcessed);
setSizes(totalOriginal, totalProcessed);
}
const result = await res.json();
setJobId(result.jobId);
setProcessedUrl(result.downloadUrl);
setDownloadUrl(result.downloadUrl);
setOriginalSize(result.originalSize);
setProcessedSize(result.processedSize);
setSizes(result.originalSize, result.processedSize);
} catch (err) {
setError(err instanceof Error ? err.message : "Vectorization failed");
} finally {