fix: make OCR portable and reliable across AMD64 and ARM64 (#519)

* fix: make OCR portable and reliable

* fix: harden OCR installation portability

* fix: pin OCR partials across downloads

* fix: make OCR execution reliably asynchronous

* fix: harden OCR portability and docs routes

* fix: preserve decoder and docs safeguards
This commit is contained in:
SnapOtter
2026-07-15 03:34:24 +08:00
committed by GitHub
parent 58121f205f
commit 991c981529
409 changed files with 67151 additions and 8076 deletions
@@ -37,13 +37,16 @@ interface ProgressHandlers {
* flaky network, proxy buffering) the UI hung forever at the last percent
* (~25%) even though the backend job had finished and saved its result.
*
* This reconnects on tab refocus -- the progress endpoint replays the
* terminal frame from its 10-minute Redis cache, so a job that completed
* while SSE was dead still resolves -- and arms a stall timeout that fails
* gracefully instead of hanging. Returns a cleanup the caller must invoke on
* sync completion, error, or unmount.
* This reconnects on tab refocus -- the progress endpoint replays the terminal
* frame from Redis and, after that cache expires, from the durable job record,
* so a job that completed while SSE was dead still resolves -- and arms a stall
* timeout that fails gracefully instead of hanging. Returns a cleanup the
* caller must invoke on sync completion, error, or unmount.
*/
function subscribeJobProgress(clientJobId: string, handlers: ProgressHandlers): () => void {
export function subscribeEraseObjectJobProgress(
clientJobId: string,
handlers: ProgressHandlers,
): () => void {
let es: EventSource | null = null;
let stall: ReturnType<typeof setTimeout> | null = null;
let done = false;
@@ -84,6 +87,10 @@ function subscribeJobProgress(clientJobId: string, handlers: ProgressHandlers):
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "heartbeat") {
resetStall();
return;
}
if (data.type !== "single") return;
resetStall();
if (data.phase === "complete" && data.result) {
@@ -174,7 +181,7 @@ export function EraseObjectSettings({
});
};
const stopProgress = subscribeJobProgress(clientJobId, {
const stopProgress = subscribeEraseObjectJobProgress(clientJobId, {
onProgress,
onComplete: (r) => {
applyResult(r);
@@ -283,7 +290,7 @@ export function EraseObjectSettings({
setProgressStage(null);
};
const stopProgress = subscribeJobProgress(clientJobId, {
const stopProgress = subscribeEraseObjectJobProgress(clientJobId, {
onProgress: (percent) => {
setProgressPhase("processing");
setProgressPercent(15 + (percent / 100) * 85);
@@ -329,7 +336,7 @@ export function EraseObjectSettings({
setProgressPercent(15);
};
xhr.onload = () => {
// 202 = async: subscribeJobProgress drives completion via SSE.
// 202 = async: the progress subscription drives completion via SSE.
if (xhr.status === 202) return;
stopProgress();
@@ -1,16 +1,11 @@
import { Download } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { format } from "@/lib/format";
import { useFileStore } from "@/stores/file-store";
const QUALITY_OPTIONS = [
{ value: "fast", label: "Fast" },
{ value: "balanced", label: "Balanced" },
{ value: "best", label: "Best" },
] as const;
import { OcrQualityControl, useOcrQuality } from "./ocr-quality-control";
const LANGUAGE_OPTIONS = [
{ value: "auto", labelKey: "autoDetect" },
@@ -29,16 +24,22 @@ export function OcrPdfSettings() {
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("ocr-pdf");
const [quality, setQuality] = useState("balanced");
const [language, setLanguage] = useState("auto");
const { quality, setQuality, canRun } = useOcrQuality(language);
const [pages, setPages] = useState("all");
const [enhance, setEnhance] = useState(quality === "best");
const [enhanceManuallySet, setEnhanceManuallySet] = useState(false);
useEffect(() => {
if (!enhanceManuallySet) setEnhance(quality === "best");
}, [enhanceManuallySet, quality]);
const ts = t.toolSettings["ocr-pdf"];
const hasFile = files.length > 0;
const hasMultiple = files.length > 1;
const handleProcess = () => {
const settings = { quality, language, pages };
const settings = { quality, language, pages, enhance };
if (hasMultiple) {
processAllFiles(files, settings);
} else {
@@ -58,24 +59,25 @@ export function OcrPdfSettings() {
<label htmlFor="ocrpdf-quality" className="mb-1.5 block text-sm font-medium">
{ts.quality}
</label>
<div className="grid grid-cols-3 gap-1.5">
{QUALITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setQuality(opt.value)}
className={`rounded-lg border px-2 py-2 text-xs font-medium transition-colors ${
quality === opt.value
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:border-primary/50"
}`}
>
{opt.label}
</button>
))}
</div>
<OcrQualityControl quality={quality} language={language} onChange={setQuality} />
</div>
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={enhance}
onChange={(event) => {
setEnhance(event.target.checked);
setEnhanceManuallySet(true);
}}
className="rounded border-border accent-primary"
/>
<span className="text-sm text-muted-foreground">
{t.toolSettings.ocr.enhanceBeforeScanning}
</span>
<span className="sr-only">{t.toolSettings.ocr.enhanceHint}</span>
</label>
{/* Language */}
<div>
<label htmlFor="ocrpdf-language" className="mb-1.5 block text-sm font-medium">
@@ -124,7 +126,7 @@ export function OcrPdfSettings() {
) : (
<button
type="submit"
disabled={!hasFile || processing}
disabled={!hasFile || processing || !canRun}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:bg-muted w-full rounded-md px-4 py-2 text-sm font-medium disabled:cursor-not-allowed"
>
{hasMultiple ? format(ts.submitBatch, { count: files.length }) : ts.submit}
@@ -0,0 +1,149 @@
import { Download, Loader2 } from "lucide-react";
import { useCallback, useId, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { useAuth } from "@/hooks/use-auth";
import { format, formatFileSize } from "@/lib/format";
import { useFeaturesStore } from "@/stores/features-store";
export type OcrQuality = "fast" | "balanced" | "best";
export function useOcrQuality(language = "auto"): {
quality: OcrQuality;
setQuality: (quality: OcrQuality) => void;
canRun: boolean;
} {
const [selection, setSelection] = useState<{
language: string;
quality: OcrQuality;
} | null>(null);
const bundle = useFeaturesStore((state) => state.bundles.find((item) => item.id === "ocr"));
const bestAvailable = bundle?.availableQualities?.includes("best") ?? false;
const balancedAvailable = bundle?.availableQualities?.includes("balanced") ?? false;
const korean = language === "ko";
const selected = selection?.language === language ? selection.quality : null;
const accurateDefault: OcrQuality = bestAvailable
? "best"
: balancedAvailable
? "balanced"
: "best";
const ordinaryDefault: OcrQuality = bestAvailable
? "best"
: balancedAvailable
? "balanced"
: "fast";
const quality = korean
? selected && selected !== "fast"
? selected
: accurateDefault
: (selected ?? ordinaryDefault);
const canRun =
(!korean || quality !== "fast") &&
(quality === "fast" || (bundle?.availableQualities?.includes(quality) ?? false));
const setQuality = useCallback(
(nextQuality: OcrQuality) => {
if (korean && nextQuality === "fast") return;
setSelection({ language, quality: nextQuality });
},
[korean, language],
);
return { quality, setQuality, canRun };
}
export function OcrQualityControl({
quality,
language = "auto",
onChange,
}: {
quality: OcrQuality;
language?: string;
onChange: (quality: OcrQuality) => void;
}) {
const { t } = useTranslation();
const { hasPermission } = useAuth();
const bundle = useFeaturesStore((state) => state.bundles.find((item) => item.id === "ocr"));
const installBundle = useFeaturesStore((state) => state.installBundle);
const installing = useFeaturesStore((state) => state.installing.ocr);
const queued = useFeaturesStore((state) => state.queued.includes("ocr"));
const installError = useFeaturesStore((state) => state.errors.ocr);
const helpId = useId();
const korean = language === "ko";
const accurateSelected = quality !== "fast";
const accurateAvailable = bundle?.availableQualities?.includes(quality) ?? false;
const needsPack = accurateSelected && !accurateAvailable;
const isAdmin = hasPermission("features:manage");
const incompatible = bundle?.compatibility === "incompatible";
const bytes = bundle?.missingDownloadBytes ?? bundle?.downloadBytes;
const size = bytes
? formatFileSize(bytes)
: (bundle?.estimatedSize ?? "~208-234 MiB download / ~409-488 MiB installed");
const qualityTranslations = t.toolSettings["remove-gif-background"];
const qualityOptions: { value: OcrQuality; label: string }[] = [
{ value: "fast", label: qualityTranslations.qualityFast },
{ value: "balanced", label: qualityTranslations.qualityBalanced },
{ value: "best", label: qualityTranslations.qualityBest },
];
return (
<div className="space-y-2">
<div className="grid grid-cols-3 gap-1.5">
{qualityOptions.map((option) => {
const fastUnsupported = korean && option.value === "fast";
return (
<button
key={option.value}
type="button"
aria-pressed={quality === option.value}
aria-describedby={fastUnsupported ? helpId : undefined}
disabled={fastUnsupported}
onClick={() => onChange(option.value)}
className={`rounded-lg border px-2 py-2 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
quality === option.value
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:border-primary/50"
}`}
>
{option.label}
</button>
);
})}
</div>
{korean && (
<p id={helpId} className="text-xs text-muted-foreground">
{t.toolSettings.ocr.fastKoreanUnsupported}
</p>
)}
{needsPack && (
<div className="rounded-lg border border-border bg-muted/40 p-3 text-start">
<p className="text-xs text-muted-foreground">
{incompatible
? (bundle?.error ?? bundle?.compatibilityReason ?? t.features.notEnabledDescription)
: format(t.features.requiresDownload, { size })}
</p>
{!incompatible && isAdmin && (
<button
type="button"
onClick={() => installBundle("ocr")}
disabled={!!installing || queued}
className="mt-2 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground disabled:opacity-50"
>
{installing || queued ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{installing || queued
? t.settings.aiFeatures.installing
: format(t.features.enableButton, { name: bundle?.name ?? t.tools.ocr.name })}
</button>
)}
{!incompatible && !isAdmin && (
<p className="mt-1 text-xs text-muted-foreground">{t.features.notEnabledDescription}</p>
)}
{installError && <p className="mt-1 text-xs text-destructive">{installError}</p>}
</div>
)}
</div>
);
}
+117 -52
View File
@@ -1,19 +1,12 @@
import { Check, ChevronDown, ChevronRight, Copy, Download, Info } from "lucide-react";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { format } from "@/lib/format";
import { copyToClipboard, generateId } from "@/lib/utils";
import { useFileStore } from "@/stores/file-store";
type OcrQuality = "fast" | "balanced" | "best";
const QUALITY_OPTIONS: { value: OcrQuality; label: string }[] = [
{ value: "fast", label: "Fast" },
{ value: "balanced", label: "Balanced" },
{ value: "best", label: "Best" },
];
import { type OcrQuality, OcrQualityControl, useOcrQuality } from "./ocr-quality-control";
const LANGUAGES = [
{ code: "auto", label: "Auto-detect" },
@@ -29,7 +22,9 @@ const LANGUAGES = [
const ENHANCE_DEFAULTS: Record<OcrQuality, boolean> = {
fast: false,
balanced: false,
best: false,
// Best evaluates the conservative contrast variant and keeps it only when
// the calibrated selector scores it above the original.
best: true,
};
function SectionLabel({ children }: { children: React.ReactNode }) {
@@ -40,28 +35,93 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
);
}
const OCR_ASYNC_STALL_TIMEOUT_MS = 5 * 60_000;
/** Send one file to the OCR API and return the extracted text. */
function ocrOneFile(
export function ocrOneFile(
file: File,
settings: { quality: string; language: string; enhance: boolean },
callbacks: {
onUploadProgress: (pct: number) => void;
onProcessingProgress: (pct: number, stage: string) => void;
},
messages: {
timeout?: string;
networkError?: string;
processingFailed?: string;
} = {},
): Promise<string> {
return new Promise((resolve, reject) => {
const clientJobId = generateId();
let settled = false;
let asyncMode = false;
let stallTimer: ReturnType<typeof setTimeout> | null = null;
let es: EventSource | null = null;
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
const cleanup = () => {
if (stallTimer) clearTimeout(stallTimer);
stallTimer = null;
es?.close();
es = null;
};
const resolveOnce = (text: string) => {
if (settled) return;
settled = true;
cleanup();
resolve(text);
};
const rejectOnce = (error: Error) => {
if (settled) return;
settled = true;
cleanup();
reject(error);
};
const armStallTimer = () => {
if (settled) return;
if (stallTimer) clearTimeout(stallTimer);
stallTimer = setTimeout(() => {
rejectOnce(
new Error(
messages.timeout ?? "OCR timed out with no progress. Try again or use a smaller image.",
),
);
}, OCR_ASYNC_STALL_TIMEOUT_MS);
};
try {
es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
} catch {
rejectOnce(new Error(messages.networkError ?? "Unable to subscribe to OCR progress"));
return;
}
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "single" && typeof data.percent === "number") {
if (data.type === "heartbeat") {
if (asyncMode) armStallTimer();
return;
}
if (data.type !== "single") return;
armStallTimer();
if (data.phase === "complete" && data.result) {
resolveOnce(typeof data.result.text === "string" ? data.result.text : "");
return;
}
if (data.phase === "failed") {
rejectOnce(new Error(typeof data.error === "string" ? data.error : "OCR failed"));
return;
}
if (typeof data.percent === "number") {
callbacks.onProcessingProgress(data.percent, data.stage);
}
} catch {}
};
es.onerror = () => es.close();
// EventSource reconnects automatically. The progress endpoint replays the
// terminal frame, so transient network loss must not discard a queued OCR.
es.onerror = () => {};
const formData = new FormData();
formData.append("file", file);
@@ -69,30 +129,37 @@ function ocrOneFile(
formData.append("clientJobId", clientJobId);
const xhr = new XMLHttpRequest();
xhr.timeout = 600_000;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) callbacks.onUploadProgress((e.loaded / e.total) * 100);
};
xhr.onload = () => {
es.close();
// The BullMQ worker owns long OCR jobs. Keep the progress subscription
// alive and resolve from buildLegacyResultPayload(resultPayload).text.
if (xhr.status === 202) {
asyncMode = true;
armStallTimer();
return;
}
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText).text ?? "");
const body = JSON.parse(xhr.responseText);
resolveOnce(typeof body.text === "string" ? body.text : "");
} catch {
reject(new Error("Invalid response"));
rejectOnce(new Error(messages.processingFailed ?? "Invalid response"));
}
} else {
try {
const body = JSON.parse(xhr.responseText);
reject(new Error(body.error || body.details || `Failed: ${xhr.status}`));
rejectOnce(new Error(body.error || body.details || `Failed: ${xhr.status}`));
} catch {
reject(new Error(`Processing failed: ${xhr.status}`));
rejectOnce(new Error(messages.processingFailed ?? `Processing failed: ${xhr.status}`));
}
}
};
xhr.onerror = () => {
es.close();
reject(new Error("Network error"));
};
xhr.onerror = () => rejectOnce(new Error(messages.networkError ?? "Network error"));
xhr.ontimeout = () => rejectOnce(new Error(messages.timeout ?? "OCR request timed out"));
xhr.onabort = () => rejectOnce(new Error(messages.processingFailed ?? "OCR request canceled"));
xhr.open("POST", "/api/v1/tools/image/ocr");
for (const [key, value] of formatHeaders()) {
xhr.setRequestHeader(key, value);
@@ -105,8 +172,8 @@ export function OcrSettings() {
const { t } = useTranslation();
const { files, processing, error, setProcessing, setError } = useFileStore();
const [quality, setQuality] = useState<OcrQuality>("balanced");
const [language, setLanguage] = useState("auto");
const { quality, setQuality, canRun } = useOcrQuality(language);
const [enhance, setEnhance] = useState(false);
const [enhanceManuallySet, setEnhanceManuallySet] = useState(false);
const [langOpen, setLangOpen] = useState(false);
@@ -119,6 +186,10 @@ export function OcrSettings() {
const [elapsed, setElapsed] = useState(0);
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!enhanceManuallySet) setEnhance(ENHANCE_DEFAULTS[quality]);
}, [enhanceManuallySet, quality]);
const handleQualityChange = (q: OcrQuality) => {
setQuality(q);
if (!enhanceManuallySet) setEnhance(ENHANCE_DEFAULTS[q]);
@@ -158,18 +229,27 @@ export function OcrSettings() {
const fileShare = 100 / total;
try {
const text = await ocrOneFile(file, settings, {
onUploadProgress: (pct) => {
setProgressPhase("uploading");
setProgressPercent(fileBase + (pct / 100) * fileShare * 0.15);
setProgressStage(`${prefix}Uploading...`);
const text = await ocrOneFile(
file,
settings,
{
onUploadProgress: (pct) => {
setProgressPhase("uploading");
setProgressPercent(fileBase + (pct / 100) * fileShare * 0.15);
setProgressStage(`${prefix}Uploading...`);
},
onProcessingProgress: (pct, stage) => {
setProgressPhase("processing");
setProgressPercent(fileBase + fileShare * 0.15 + (pct / 100) * fileShare * 0.85);
setProgressStage(`${prefix}${stage}`);
},
},
onProcessingProgress: (pct, stage) => {
setProgressPhase("processing");
setProgressPercent(fileBase + fileShare * 0.15 + (pct / 100) * fileShare * 0.85);
setProgressStage(`${prefix}${stage}`);
{
timeout: t.errors.timeout,
networkError: t.errors.networkError,
processingFailed: t.errors.processingFailed,
},
});
);
results.push(total > 1 ? `--- ${file.name} ---\n${text || "(no text detected)"}` : text);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
@@ -223,22 +303,7 @@ export function OcrSettings() {
<div className="space-y-3">
{/* Quality selector */}
<SectionLabel>{t.toolSettings.ocr.quality}</SectionLabel>
<div className="grid grid-cols-3 gap-1.5">
{QUALITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => handleQualityChange(opt.value)}
className={`py-2 px-2 rounded-lg border text-xs font-medium transition-colors ${
quality === opt.value
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:border-primary/50"
}`}
>
{opt.label}
</button>
))}
</div>
<OcrQualityControl quality={quality} language={language} onChange={handleQualityChange} />
{/* Enhance toggle */}
<label className="flex items-center gap-2 cursor-pointer">
@@ -252,7 +317,7 @@ export function OcrSettings() {
{t.toolSettings.ocr.enhanceBeforeScanning}
</span>
<span
title="Automatically deskews, enhances contrast, removes noise, and upscales the image before scanning for better accuracy."
title={t.toolSettings.ocr.enhanceHint}
className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-muted-foreground/40 text-muted-foreground text-[10px] cursor-help"
>
<Info className="h-2.5 w-2.5" />
@@ -305,7 +370,7 @@ export function OcrSettings() {
type="button"
data-testid="ocr-submit"
onClick={handleProcess}
disabled={!hasFile || processing}
disabled={!hasFile || processing || !canRun}
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"
>
{files.length > 1
@@ -27,12 +27,16 @@ interface ProgressHandlers {
/**
* Subscribe to async (202) job progress with the same mobile-resilient recovery
* as the standard tool processor (PRs #203/#204). Reconnects on tab refocus (the
* progress endpoint replays the terminal frame from its Redis cache, so a job
* that finished while SSE was dead still resolves) and arms a stall timeout that
* fails gracefully instead of hanging at the last percent. Returns a cleanup the
* caller must invoke on sync completion, error, or unmount.
* progress endpoint replays the terminal frame from Redis and, after that cache
* expires, from the durable job record, so a job that finished while SSE was dead
* still resolves) and arms a stall timeout that fails gracefully instead of
* hanging at the last percent. Returns a cleanup the caller must invoke on sync
* completion, error, or unmount.
*/
function subscribeJobProgress(clientJobId: string, handlers: ProgressHandlers): () => void {
export function subscribeSignPdfJobProgress(
clientJobId: string,
handlers: ProgressHandlers,
): () => void {
let es: EventSource | null = null;
let stall: ReturnType<typeof setTimeout> | null = null;
let done = false;
@@ -73,6 +77,10 @@ function subscribeJobProgress(clientJobId: string, handlers: ProgressHandlers):
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "heartbeat") {
resetStall();
return;
}
if (data.type !== "single") return;
resetStall();
if (data.phase === "complete" && data.result) {
@@ -154,7 +162,7 @@ export function SignPdfSettings({ signProps }: { signProps?: SignProps }) {
setProcessing(false);
};
const stopProgress = subscribeJobProgress(clientJobId, {
const stopProgress = subscribeSignPdfJobProgress(clientJobId, {
onProgress: (percent) => setProgress(percent),
onComplete: (r) => {
setDownloadUrl(r.downloadUrl as string);
@@ -187,7 +195,7 @@ export function SignPdfSettings({ signProps }: { signProps?: SignProps }) {
const xhr = new XMLHttpRequest();
xhr.timeout = 600_000;
xhr.onload = () => {
// 202 = async: subscribeJobProgress drives completion via SSE.
// 202 = async: the progress subscription drives completion via SSE.
if (xhr.status === 202) return;
stopProgress();
progressCleanupRef.current = null;