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
+126 -36
View File
@@ -8,9 +8,10 @@ import { apiToolPath } from "@snapotter/shared";
// enhance-faces, colorize, noise-removal, red-eye-removal, restore-photo,
// passport-photo, erase-object
//
// These tools require the AI sidecar (Python bridge). Each test detects
// Most of these tools require the AI sidecar (Python bridge). OCR Fast is
// built in; only its signed Balanced/Best runtime is optional. Each test detects
// whether the required feature bundle is installed:
// - 200 = tool works, verify output
// - 200 or terminal 202 = tool works, verify output
// - 501 FEATURE_NOT_INSTALLED = skip gracefully (expected when bundle missing)
// - Other errors = genuine failures
@@ -71,7 +72,6 @@ async function isFeatureInstalled(
"enhance-faces": "upscale-enhance",
"noise-removal": "upscale-enhance",
"restore-photo": "photo-restoration",
ocr: "ocr",
};
const bundleId = toolBundleMap[toolId];
@@ -81,6 +81,25 @@ async function isFeatureInstalled(
return bundle?.status === "installed";
}
interface OcrFeatureState {
status: string;
compatibility?: "compatible" | "incompatible" | "invalid";
availableQualities?: Array<"fast" | "balanced" | "best">;
}
async function getOcrFeatureState(
request: import("@playwright/test").APIRequestContext,
): Promise<OcrFeatureState> {
const res = await request.get("/api/v1/features", {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.ok()).toBe(true);
const data = await res.json();
const state = data.bundles?.find((bundle: { id: string }) => bundle.id === "ocr");
expect(state).toBeDefined();
return state as OcrFeatureState;
}
/**
* Post a file to an AI tool and return the response.
* Handles the 501 FEATURE_NOT_INSTALLED case gracefully.
@@ -108,6 +127,44 @@ async function callAiTool(
return { installed: false, ok: false, status, body };
}
// Image OCR is a long-running API contract and always returns 202 once
// validation/enqueueing succeeds. Follow its SSE stream so black-box Docker
// assertions still inspect the authoritative terminal metadata.
if (toolId === "ocr" && status === 202 && typeof body.jobId === "string") {
const progress = await request.get(
`/api/v1/jobs/${encodeURIComponent(body.jobId as string)}/progress`,
{
headers: { Authorization: `Bearer ${token}` },
timeout: 120_000,
},
);
expect(progress.ok()).toBe(true);
const frames = (await progress.text())
.split(/\n\n/u)
.map((frame) => frame.match(/^data:\s*(.+)$/mu)?.[1])
.filter((data): data is string => data !== undefined)
.map((data) => JSON.parse(data) as Record<string, unknown>);
const terminal = [...frames]
.reverse()
.find((frame) => frame.phase === "complete" || frame.phase === "failed");
expect(terminal).toBeDefined();
if (terminal?.phase === "failed") {
return {
installed: true,
ok: false,
status: 422,
body: { error: terminal.error ?? "OCR failed" },
};
}
expect(terminal?.result).toBeDefined();
return {
installed: true,
ok: true,
status: 200,
body: terminal?.result as Record<string, unknown>,
};
}
return { installed: true, ok: res.ok(), status, body };
}
@@ -226,35 +283,68 @@ test.describe("Upscale", () => {
// ─── OCR ────────────────────────────────────────────────────────────
test.describe("OCR", () => {
test("OCR processes image or returns 501", async ({ request }) => {
test("default OCR works with or without the accurate pack", async ({ request }) => {
const state = await getOcrFeatureState(request);
const result = await callAiTool(request, "ocr", JPG_100x100, {});
if (!result.installed) {
expect(result.body.feature).toBe("ocr");
test.skip();
return;
}
expect(result.ok).toBe(true);
// OCR response may have text, blocks, or engine field
expect(result.body.text !== undefined || result.body.blocks !== undefined).toBe(true);
expect(typeof result.body.text).toBe("string");
if (state.availableQualities?.includes("best")) {
expect(result.body).toMatchObject({
engine: "rapidocr-onnx",
provider: "CPUExecutionProvider",
device: "cpu",
requestedQuality: "best",
actualQuality: "best",
degraded: false,
});
} else {
expect(result.body).toMatchObject({
engine: "tesseract",
provider: "native",
device: "cpu",
requestedQuality: "fast",
actualQuality: "fast",
degraded: false,
});
}
});
test("OCR with tesseract engine", async ({ request }) => {
const result = await callAiTool(request, "ocr", JPG_100x100, { engine: "tesseract" });
if (!result.installed) {
test.skip();
return;
}
test("Fast OCR is always the built-in native CPU engine", async ({ request }) => {
const result = await callAiTool(request, "ocr", JPG_100x100, { quality: "fast" });
expect(result.ok).toBe(true);
expect(result.body).toMatchObject({
engine: "tesseract",
provider: "native",
device: "cpu",
requestedQuality: "fast",
actualQuality: "fast",
degraded: false,
});
});
test("OCR with paddleocr engine", async ({ request }) => {
const result = await callAiTool(request, "ocr", JPG_100x100, { engine: "paddleocr" });
if (!result.installed) {
test.skip();
return;
}
expect(result.ok).toBe(true);
});
for (const quality of ["balanced", "best"] as const) {
test(`${quality} OCR requires the signed accurate runtime`, async ({ request }) => {
const state = await getOcrFeatureState(request);
const result = await callAiTool(request, "ocr", JPG_100x100, { quality });
if (state.availableQualities?.includes(quality)) {
expect(result.ok).toBe(true);
expect(result.body).toMatchObject({
engine: "rapidocr-onnx",
provider: "CPUExecutionProvider",
device: "cpu",
requestedQuality: quality,
actualQuality: quality,
degraded: false,
});
} else {
expect(result.status).toBe(501);
expect(["FEATURE_NOT_INSTALLED", "FEATURE_INCOMPATIBLE"]).toContain(result.body.code);
expect(result.body).toMatchObject({ feature: "ocr", requestedQuality: quality });
}
});
}
test("OCR on Japanese text image", async ({ request }) => {
const ocrJapanese = contentFixture("ocr-japanese.png");
@@ -262,25 +352,26 @@ test.describe("OCR", () => {
request,
"ocr",
ocrJapanese,
{},
{ quality: "fast" },
"ocr-japanese.png",
"image/png",
);
if (!result.installed) {
test.skip();
return;
}
expect(result.ok).toBe(true);
expect(result.body).toMatchObject({ engine: "tesseract", actualQuality: "fast" });
});
test("OCR on chat screenshot", async ({ request }) => {
const ocrChat = contentFixture("ocr-chat.jpeg");
const result = await callAiTool(request, "ocr", ocrChat, {}, "ocr-chat.jpeg", "image/jpeg");
if (!result.installed) {
test.skip();
return;
}
const result = await callAiTool(
request,
"ocr",
ocrChat,
{ quality: "fast" },
"ocr-chat.jpeg",
"image/jpeg",
);
expect(result.ok).toBe(true);
expect(result.body).toMatchObject({ engine: "tesseract", actualQuality: "fast" });
// Chat screenshot should contain some readable text
if (result.body.text) {
expect((result.body.text as string).length).toBeGreaterThan(0);
@@ -788,7 +879,6 @@ test.describe("AI Feature Bundle Status", () => {
{ tool: "upscale", featureKey: "upscale", bundle: "upscale-enhance" },
{ tool: "blur-faces", featureKey: "blur-faces", bundle: "face-detection" },
{ tool: "erase-object", featureKey: "erase-object", bundle: "object-eraser-colorize" },
{ tool: "ocr", featureKey: "ocr", bundle: "ocr" },
{ tool: "colorize", featureKey: "colorize", bundle: "object-eraser-colorize" },
{
tool: "ai-canvas-expand",