mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: restore-photo colorize hang and AVIF decode failures
- Fix dispatcher pipe deadlock: drain stdout pipe in a background thread to prevent blocking when ONNX runtime output exceeds 64KB pipe buffer - Add 5-minute SSE stall timeout so the UI shows an error instead of hanging forever when async AI processing stalls - Guard CPU colorization: skip for images >2MP on CPU and when DDColor model is not installed, with clear user-facing messages - Add AVIF decode fallback via ImageMagick for bitstream variants that Sharp's bundled libheif cannot decode (affects all tools)
This commit is contained in:
@@ -90,6 +90,31 @@ export async function decodeToSharpCompat(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-resort decode: convert any image to PNG via ImageMagick.
|
||||
* Used when Sharp's bundled decoders fail (e.g. AVIF 2.0 bitstreams).
|
||||
*/
|
||||
export async function decodeAnyFormat(buffer: Buffer, format: string): Promise<Buffer> {
|
||||
const cmd = await findMagickCmd();
|
||||
const id = randomUUID();
|
||||
const ext = format || "img";
|
||||
const inputPath = join(tmpdir(), `any-in-${id}.${ext}`);
|
||||
const outputPath = join(tmpdir(), `any-out-${id}.png`);
|
||||
|
||||
try {
|
||||
await writeFile(inputPath, buffer);
|
||||
await execFileAsync(
|
||||
cmd,
|
||||
magickArgs(cmd, [inputPath, "-colorspace", "sRGB", `png:${outputPath}`]),
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
return await readFile(outputPath);
|
||||
} finally {
|
||||
await rm(inputPath, { force: true }).catch(() => {});
|
||||
await rm(outputPath, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ── ImageMagick helpers ────────────────────────────────────────
|
||||
|
||||
let cachedMagickCmd: string | null = null;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { formatZodErrors } from "../lib/errors.js";
|
||||
import { isToolInstalled } from "../lib/feature-status.js";
|
||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||
import { sanitizeFilename } from "../lib/filename.js";
|
||||
import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
|
||||
import { decodeAnyFormat, decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
|
||||
import { decodeHeic } from "../lib/heic-converter.js";
|
||||
import type { WorkerInput, WorkerOutput } from "../lib/image-worker.js";
|
||||
import { decompressSvgz, sanitizeSvg } from "../lib/svg-sanitize.js";
|
||||
@@ -244,6 +244,27 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
}
|
||||
}
|
||||
|
||||
// AVIF can pass metadata validation but fail pixel decode when
|
||||
// Sharp's bundled libheif lacks support for the bitstream version.
|
||||
// A 1x1 resize forces a minimal pixel decode to catch this early.
|
||||
if (validation.format === "avif") {
|
||||
try {
|
||||
await sharp(fileBuffer).resize(1).raw().toBuffer();
|
||||
} catch {
|
||||
try {
|
||||
reportProgress(10, "Decoding...");
|
||||
fileBuffer = await decodeAnyFormat(fileBuffer, "avif");
|
||||
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
||||
} catch (fallbackErr) {
|
||||
return reply.status(422).send({
|
||||
error: "Failed to decode AVIF file",
|
||||
details: fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reportProgress(15, "Preparing...");
|
||||
|
||||
// Parse and validate settings
|
||||
|
||||
@@ -11,7 +11,7 @@ import { formatZodErrors } from "../../lib/errors.js";
|
||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||
import { sanitizeFilename } from "../../lib/filename.js";
|
||||
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||
import { decodeAnyFormat, decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
@@ -113,6 +113,21 @@ export function registerRestorePhoto(app: FastifyInstance) {
|
||||
|
||||
// Auto-orient to fix EXIF rotation
|
||||
fileBuffer = await autoOrient(fileBuffer);
|
||||
|
||||
// AVIF can pass metadata validation but fail pixel decode when
|
||||
// Sharp's bundled libheif lacks support for the bitstream version.
|
||||
// Convert early (the sidecar needs PNG anyway); fall back to ImageMagick.
|
||||
if (validation.format === "avif") {
|
||||
try {
|
||||
fileBuffer = await sharp(fileBuffer).png().toBuffer();
|
||||
} catch {
|
||||
request.log.warn(
|
||||
{ toolId: "restore-photo" },
|
||||
"Sharp AVIF decode failed, using ImageMagick",
|
||||
);
|
||||
fileBuffer = await decodeAnyFormat(fileBuffer, "avif");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
request.log.error({ err, toolId: "restore-photo" }, "Input decoding failed");
|
||||
return reply.status(422).send({
|
||||
|
||||
@@ -31,9 +31,10 @@ const IDLE_PROGRESS: ToolProgress = {
|
||||
const AI_PYTHON_TOOLS = new Set<string>(PYTHON_SIDECAR_TOOLS);
|
||||
|
||||
// Tools that are not Python sidecar but still need an extended XHR timeout.
|
||||
const LONG_RUNNING_TOOLS = new Set<string>(["content-aware-resize", "content-aware-crop"]);
|
||||
const LONG_RUNNING_TOOLS = new Set<string>(["content-aware-resize", "ai-canvas-expand"]);
|
||||
|
||||
const UPLOAD_WEIGHT = 15;
|
||||
const SSE_STALL_TIMEOUT_MS = 300_000;
|
||||
|
||||
export function useToolProcessor(toolId: string) {
|
||||
const { processing, error, processedUrl, originalSize, processedSize, setProcessing, setError } =
|
||||
@@ -45,6 +46,7 @@ export function useToolProcessor(toolId: string) {
|
||||
const xhrRef = useRef<XMLHttpRequest | null>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const stallTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const isAiTool = AI_PYTHON_TOOLS.has(toolId);
|
||||
const toolName = TOOLS.find((t) => t.id === toolId)?.name ?? toolId;
|
||||
@@ -56,6 +58,7 @@ export function useToolProcessor(toolId: string) {
|
||||
if (eventSourceRef.current) eventSourceRef.current.close();
|
||||
if (xhrRef.current) xhrRef.current.abort();
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
if (stallTimerRef.current) clearTimeout(stallTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -91,6 +94,33 @@ export function useToolProcessor(toolId: string) {
|
||||
const clientJobId = generateId();
|
||||
let asyncMode = false;
|
||||
|
||||
const clearStallTimer = () => {
|
||||
if (stallTimerRef.current) {
|
||||
clearTimeout(stallTimerRef.current);
|
||||
stallTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const resetStallTimer = () => {
|
||||
clearStallTimer();
|
||||
stallTimerRef.current = setTimeout(() => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
useFileStore.getState().updateEntry(capturedIndex, {
|
||||
status: "failed",
|
||||
error: "Processing timed out",
|
||||
});
|
||||
setError(
|
||||
"Processing timed out with no progress for 5 minutes. Try again or use a smaller image.",
|
||||
);
|
||||
setProcessing(false);
|
||||
setProgress(IDLE_PROGRESS);
|
||||
}, SSE_STALL_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
// Open SSE for real-time progress from the server (all tools)
|
||||
try {
|
||||
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
|
||||
@@ -101,8 +131,11 @@ export function useToolProcessor(toolId: string) {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type !== "single") return;
|
||||
|
||||
if (asyncMode) resetStallTimer();
|
||||
|
||||
// AI tools deliver results via SSE (they return 202 from the XHR)
|
||||
if (data.phase === "complete" && data.result) {
|
||||
clearStallTimer();
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
@@ -124,6 +157,7 @@ export function useToolProcessor(toolId: string) {
|
||||
}
|
||||
|
||||
if (data.phase === "failed" && asyncMode) {
|
||||
clearStallTimer();
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
@@ -202,6 +236,7 @@ export function useToolProcessor(toolId: string) {
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 202) {
|
||||
asyncMode = true;
|
||||
resetStallTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -248,6 +283,7 @@ export function useToolProcessor(toolId: string) {
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
clearStallTimer();
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
@@ -259,6 +295,7 @@ export function useToolProcessor(toolId: string) {
|
||||
};
|
||||
|
||||
xhr.ontimeout = () => {
|
||||
clearStallTimer();
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
|
||||
Reference in New Issue
Block a user