mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: add exotic format decoding and upload progress to stitch tool
The stitch route only decoded HEIC via ensureSharpCompat, so exotic formats (DNG, PSD, TGA, BMP, JXL, HDR, QOI, DDS, ICO, JP2, DPX, etc.) crashed Sharp at metadata read time, causing "Stitch creation failed". Add the full CLI decode pipeline (decodeToSharpCompat) matching the tool-factory pattern, plus SVG sanitization with proper error handling for each format category. Replace fetch() with XMLHttpRequest in the frontend to surface upload progress via onprogress events, and add a progress bar that shows upload percentage then pulses during server-side stitching.
This commit is contained in:
@@ -9,8 +9,10 @@ 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 { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||
import { encodeJxl } from "../../lib/format-encoders.js";
|
||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||
import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
@@ -85,7 +87,46 @@ export function registerStitch(app: FastifyInstance) {
|
||||
.status(400)
|
||||
.send({ error: `Invalid file "${file.filename}": ${validation.reason}` });
|
||||
}
|
||||
file.buffer = await autoOrient(await ensureSharpCompat(file.buffer));
|
||||
|
||||
if (validation.format === "heif") {
|
||||
try {
|
||||
file.buffer = await decodeHeic(file.buffer);
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: `Failed to decode "${file.filename}" (HEIC). Ensure libheif-examples is installed.`,
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (needsCliDecode(validation.format)) {
|
||||
const fileExt = file.filename.split(".").pop()?.toLowerCase();
|
||||
try {
|
||||
file.buffer = await decodeToSharpCompat(file.buffer, validation.format, fileExt);
|
||||
} catch {
|
||||
try {
|
||||
await sharp(file.buffer).metadata();
|
||||
} catch (err) {
|
||||
return reply.status(422).send({
|
||||
error: `Failed to decode "${file.filename}" (${validation.format.toUpperCase()})`,
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (validation.format === "svg") {
|
||||
try {
|
||||
file.buffer = decompressSvgz(file.buffer);
|
||||
file.buffer = sanitizeSvg(file.buffer);
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: `Invalid SVG "${file.filename}": ${err instanceof Error ? err.message : "Unknown error"}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
file.buffer = await autoOrient(file.buffer);
|
||||
}
|
||||
|
||||
let settings: z.infer<typeof settingsSchema>;
|
||||
|
||||
@@ -23,6 +23,7 @@ export function StitchSettings() {
|
||||
const [format, setFormat] = useState<OutputFormat>("png");
|
||||
const [quality, setQuality] = useState(90);
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
const handleProcess = async () => {
|
||||
if (files.length < 2) return;
|
||||
@@ -30,6 +31,7 @@ export function StitchSettings() {
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
setDownloadUrl(null);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
@@ -52,18 +54,47 @@ export function StitchSettings() {
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await fetch("/api/v1/tools/stitch", {
|
||||
method: "POST",
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
const result = await new Promise<{
|
||||
jobId: string;
|
||||
downloadUrl: string;
|
||||
originalSize: number;
|
||||
processedSize: number;
|
||||
}>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/v1/tools/stitch");
|
||||
|
||||
const headers = formatHeaders();
|
||||
headers.forEach((value, key) => {
|
||||
xhr.setRequestHeader(key, value);
|
||||
});
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
setUploadProgress(Math.round((e.loaded / e.total) * 100));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
resolve(JSON.parse(xhr.responseText));
|
||||
} catch {
|
||||
reject(new Error("Invalid response"));
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
reject(new Error(body.error || `Failed: ${xhr.status}`));
|
||||
} catch {
|
||||
reject(new Error(`Failed: ${xhr.status}`));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new Error("Network error"));
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -262,6 +293,25 @@ export function StitchSettings() {
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{uploadProgress < 100 ? "Uploading..." : "Stitching..."}</span>
|
||||
{uploadProgress < 100 && <span>{uploadProgress}%</span>}
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
{uploadProgress < 100 ? (
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-[width] duration-200"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full bg-primary rounded-full animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="stitch-submit"
|
||||
@@ -270,7 +320,11 @@ export function StitchSettings() {
|
||||
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 ? "Stitching..." : `Stitch ${files.length} images`}
|
||||
{processing
|
||||
? uploadProgress < 100
|
||||
? `Uploading... ${uploadProgress}%`
|
||||
: "Stitching..."
|
||||
: `Stitch ${files.length} images`}
|
||||
</button>
|
||||
|
||||
{downloadUrl && (
|
||||
|
||||
Reference in New Issue
Block a user