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