mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user