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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user