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:
SnapOtter
2026-05-13 15:42:24 +08:00
parent 917c1ff773
commit 3760885342
6 changed files with 143 additions and 18 deletions
+25
View File
@@ -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;
+22 -1
View File
@@ -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
+16 -1
View File
@@ -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({
+38 -1
View File
@@ -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();
+22 -5
View File
@@ -118,7 +118,12 @@ def _run_script_main(script_name, args):
Since some scripts (like remove_bg.py) manipulate file descriptors directly
(os.dup2), we use a pipe at the fd level rather than StringIO.
A drain thread reads the pipe concurrently to prevent deadlock when
scripts produce more than 64 KB of stdout (e.g. ONNX runtime logging).
"""
import threading
script_dir = os.path.dirname(os.path.abspath(__file__))
# ── Feature gate: reject scripts whose bundle is not installed ──
@@ -150,6 +155,20 @@ def _run_script_main(script_name, args):
old_sys_stdout = sys.stdout
sys.stdout = os.fdopen(1, "w", closefd=False)
# Drain the pipe in a background thread so the pipe buffer never fills.
captured_chunks = []
def _drain():
with os.fdopen(read_fd, "r") as f:
while True:
chunk = f.read(8192)
if not chunk:
break
captured_chunks.append(chunk)
drain_thread = threading.Thread(target=_drain, daemon=True)
drain_thread.start()
exit_code = 0
try:
sys.argv = ["script.py"] + args
@@ -178,7 +197,7 @@ def _run_script_main(script_name, args):
# Flush before restoring
sys.stdout.flush()
# Restore stdout fd
# Restore stdout fd (closes the pipe write end, unblocking the drain thread)
os.dup2(real_stdout_fd, 1)
os.close(real_stdout_fd)
@@ -188,10 +207,8 @@ def _run_script_main(script_name, args):
# Restore sys.argv
sys.argv = old_argv
# Read captured output from the pipe
read_file = os.fdopen(read_fd, "r")
captured = read_file.read()
read_file.close()
drain_thread.join(timeout=10)
captured = "".join(captured_chunks)
return captured.strip(), exit_code
+20 -10
View File
@@ -625,16 +625,26 @@ def main():
# ── Step 5: Colorization ─────────────────────────────────
colorized = False
if do_colorize and bw_detected:
emit_progress(82, "Colorizing B&W photo")
try:
result, colorized = colorize_bw(result, intensity=0.85)
if colorized:
steps_applied.append("colorize")
emit_progress(92, "Colorization complete")
else:
emit_progress(92, "Colorization model not available")
except Exception as e:
emit_progress(92, f"Colorization skipped: {str(e)[:40]}")
total_pixels = orig_h * orig_w
has_gpu = device == "cuda"
max_pixels = 8_000_000 if has_gpu else 2_000_000
if total_pixels > max_pixels and not has_gpu:
mp = total_pixels / 1_000_000
emit_progress(92, f"Colorization skipped: image too large for CPU ({mp:.1f}MP, max 2MP)")
elif not os.path.exists(DDCOLOR_MODEL_PATH):
emit_progress(92, "Colorization skipped: DDColor model not installed")
else:
emit_progress(82, "Colorizing B&W photo")
try:
result, colorized = colorize_bw(result, intensity=0.85)
if colorized:
steps_applied.append("colorize")
emit_progress(92, "Colorization complete")
else:
emit_progress(92, "Colorization model not available")
except Exception as e:
emit_progress(92, f"Colorization skipped: {str(e)[:40]}")
else:
emit_progress(92, "Colorization skipped")