import fs from "node:fs/promises"; import path from "node:path"; import { pngBufferToBitmap } from "./pngBitmap.js"; import { createNativeScannerResultWorkflowService } from "./nativeScannerResultWorkflowService.js"; import { parseArtifactCandidate } from "../../src/lib/artifactOcrParser.js"; import { toStoredArtifact } from "../../src/lib/artifactStore.js"; import { matchParsedArtifactToIk, type IkArtifactCatalog } from "../../src/lib/ikArtifactMatcher.js"; import { createStoredScanResultEntry } from "../../src/lib/scanResultEntry.js"; import type { ArtifactStoreLoadResult, ArtifactStoreSaveResult, CaptureResult, NativeScannerImageLoadStatus, NativeScannerProcessStatus, NativeScannerPromotionStatus, NativeScannerReviewArtifactInput, NativeScannerReviewStatus, NativeScannerResultsLoadStatus, ReviewSamplePayload, } from "../../src/types/global.js"; import type { ScanResultCategory, StoredArtifactRecord, StoredScanResultEntry } from "../../src/types/storage.js"; export type NativeCaptureJobPayload = { sequence: number; category?: string; page?: number; row?: number; col?: number; capturedAt?: string; relativePath?: string; absolutePath?: string; }; export interface NativeScannerProcessingService { processRun(options?: { runDir?: string; persist?: boolean; limit?: number }): Promise; loadResults(options?: { runDir?: string; limit?: number }): Promise; promoteResults(options: { runDir?: string; resultIds: string[] }): Promise; reviewResult(options: { runDir?: string; resultId: string; action: "approve" | "reject"; artifact?: NativeScannerReviewArtifactInput; note?: string; }): Promise; loadImage(options: { runDir?: string; imagePath: string }): Promise; } interface NativeScannerProcessingServiceDependencies { resolveRunDir(runDir?: string): string; buildCaptureResult(imagePath: string, job: NativeCaptureJobPayload): Promise; loadArtifacts?: () => Promise; saveArtifacts(records: StoredArtifactRecord[]): Promise & Partial>; saveReviewSample?: (sample: ReviewSamplePayload) => Promise<{ ok: boolean }>; loadIkArtifactCatalog?: () => Promise; } type ProcessedNativeJob = { result: NativeScannerProcessStatus["results"][number]; scanResult: StoredScanResultEntry; storedRecord: StoredArtifactRecord | null; }; const POST_CAPTURE_QUEUE_CONCURRENCY = 4; export function createNativeScannerProcessingService( deps: NativeScannerProcessingServiceDependencies, ): NativeScannerProcessingService { const resultWorkflows = createNativeScannerResultWorkflowService(deps); return { async processRun(options = {}) { const started = Date.now(); const runDir = deps.resolveRunDir(options.runDir); if (!runDir) { return emptyProcessStatus("No native scanner runDir available."); } const { jobsPath, jobs } = await readNativeCaptureJobs(runDir); const limit = Math.max(1, Math.min(jobs.length, Math.round(options.limit ?? jobs.length))); const selectedJobs = jobs.slice(0, limit); const runId = path.basename(runDir); const ikCatalog = deps.loadIkArtifactCatalog ? await deps.loadIkArtifactCatalog().catch(() => null) : null; const processedJobs = await mapWithConcurrency( selectedJobs, POST_CAPTURE_QUEUE_CONCURRENCY, (job) => processNativeCaptureJob({ deps, ikCatalog, job, persist: Boolean(options.persist), runDir, runId, }), ); const results = processedJobs.map((entry) => entry.result); const scanResults = processedJobs.map((entry) => entry.scanResult); const recordsToPersist = processedJobs .map((entry) => entry.storedRecord) .filter((record): record is StoredArtifactRecord => Boolean(record)); let stored = 0; if (options.persist && recordsToPersist.length > 0) { const saved = await deps.saveArtifacts(recordsToPersist); stored = saved.added + saved.updated; } const status: NativeScannerProcessStatus = { ok: true, runDir, jobsPath, reportPath: path.join(runDir, "processing-report.json"), scanResultsPath: path.join(runDir, "scan-results.json"), processed: results.length, parsed: results.filter((result) => result.parsed).length, review: results.filter((result) => result.needsReview).length, stored, errors: results.filter((result) => result.error).length, elapsedMs: Date.now() - started, queueConcurrency: Math.min(POST_CAPTURE_QUEUE_CONCURRENCY, selectedJobs.length), persisted: Boolean(options.persist), results, }; await fs.writeFile(status.scanResultsPath, JSON.stringify(scanResults, null, 2), "utf8"); await fs.writeFile(status.reportPath, JSON.stringify(status, null, 2), "utf8"); return status; }, async loadResults(options = {}) { const runDir = deps.resolveRunDir(options.runDir); if (!runDir) { return emptyResultsStatus("No native scanner runDir available."); } const resultsPath = path.join(runDir, "scan-results.json"); try { const raw = await fs.readFile(resultsPath, "utf8"); const parsed = JSON.parse(raw); const results = Array.isArray(parsed) ? parsed.filter(isStoredScanResultEntry) : []; const limit = Number.isFinite(options.limit) ? Math.max(1, Math.min(results.length, Math.round(options.limit ?? results.length))) : results.length; return { ok: true, runDir, path: resultsPath, total: results.length, results: results.slice(-limit), }; } catch (error) { return { ...emptyResultsStatus(error instanceof Error ? error.message : String(error)), runDir, path: resultsPath, }; } }, promoteResults: resultWorkflows.promoteResults, reviewResult: resultWorkflows.reviewResult, async loadImage(options) { const runDir = deps.resolveRunDir(options.runDir); if (!runDir) { return emptyImageStatus("No native scanner runDir available."); } const resolvedRunDir = path.resolve(runDir); const imagePath = path.isAbsolute(options.imagePath) ? path.resolve(options.imagePath) : path.resolve(resolvedRunDir, options.imagePath); if (!isPathInside(resolvedRunDir, imagePath)) { return { ...emptyImageStatus("Image path is outside the native scanner run directory."), runDir: resolvedRunDir, path: imagePath }; } if (!/\.png$/i.test(imagePath)) { return { ...emptyImageStatus("Native scanner previews must be PNG files."), runDir: resolvedRunDir, path: imagePath }; } try { const stat = await fs.stat(imagePath); if (stat.size > 20 * 1024 * 1024) { return { ...emptyImageStatus("Native scanner preview is too large."), runDir: resolvedRunDir, path: imagePath }; } const buffer = await fs.readFile(imagePath); const bitmap = pngBufferToBitmap(buffer); return { ok: true, runDir: resolvedRunDir, path: imagePath, dataUrl: `data:image/png;base64,${buffer.toString("base64")}`, width: bitmap.width, height: bitmap.height, }; } catch (error) { return { ...emptyImageStatus(error instanceof Error ? error.message : String(error)), runDir: resolvedRunDir, path: imagePath, }; } }, }; } export function nativeScannerProcessStats(status: NativeScannerProcessStatus): Record { return { processed: status.processed, parsed: status.parsed, review: status.review, stored: status.stored, errors: status.errors, elapsedMs: status.elapsedMs, queueConcurrency: status.queueConcurrency, }; } async function processNativeCaptureJob({ deps, ikCatalog, job, persist, runDir, runId, }: { deps: NativeScannerProcessingServiceDependencies; ikCatalog: IkArtifactCatalog | null; job: NativeCaptureJobPayload; persist: boolean; runDir: string; runId: string; }): Promise { const category = nativeJobCategory(job.category); const imagePath = job.absolutePath || (job.relativePath ? path.join(runDir, job.relativePath) : ""); if (!category.artifactProcessingSupported) { return reviewJobResult({ category: category.scanResultCategory, error: `Native post-capture processing for category '${category.nativeCategory}' is not implemented yet; IK catalog is available only.`, imagePath, job, runId, }); } if (!imagePath || !(await fileExists(imagePath))) { const error = "Card crop image missing."; return reviewJobResult({ category: category.scanResultCategory, error, imagePath, job, runId }); } try { const capture = await deps.buildCaptureResult(imagePath, job); const parsed = parseArtifactCandidate(capture); const ikMatch = parsed && ikCatalog ? matchParsedArtifactToIk(parsed, ikCatalog) : null; const notes = [...new Set([...(parsed?.notes ?? []), ...(ikMatch?.notes ?? [])])]; const needsReview = parsedArtifactNeedsReview(parsed) || Boolean(ikMatch && !ikMatch.matched); const canPersist = parsedArtifactCanPersist(parsed, needsReview); const shouldPersist = Boolean(persist && parsed && canPersist && !needsReview); const storedRecord = parsed && shouldPersist ? toStoredArtifact(parsed, "native-ik-scan", needsReview, capture.locked) : null; return { result: { sequence: job.sequence, category: category.scanResultCategory, page: job.page, row: job.row, col: job.col, imagePath, parsed: Boolean(parsed), artifactName: parsed?.name, setName: parsed?.setName, slot: parsed?.slot, confidence: parsed?.confidence ?? 0, needsReview, persisted: shouldPersist, ikMatch: ikMatch ?? undefined, notes, ocr: capture.ocr ?? [], }, scanResult: createStoredScanResultEntry({ runId, sequence: job.sequence, page: job.page, row: job.row, col: job.col, category: category.scanResultCategory, source: "native-ik-scan", imagePath, parsed, needsReview, confidence: parsed?.confidence ?? 0, capturedAt: job.capturedAt, artifactRecordId: storedRecord?.id, persistedArtifact: shouldPersist, notes, locked: capture.locked, ikMatch: ikMatch ?? undefined, }), storedRecord, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return reviewJobResult({ category: category.scanResultCategory, error: message, imagePath, job, runId }); } } function reviewJobResult({ category, error, imagePath, job, runId, }: { category?: ScanResultCategory; error: string; imagePath: string; job: NativeCaptureJobPayload; runId: string; }): ProcessedNativeJob { const scanResultCategory = category ?? nativeJobCategory(job.category).scanResultCategory; return { result: { sequence: job.sequence, category: scanResultCategory, page: job.page, row: job.row, col: job.col, imagePath, parsed: false, needsReview: true, confidence: 0, ocr: [], error, }, scanResult: createStoredScanResultEntry({ runId, sequence: job.sequence, page: job.page, row: job.row, col: job.col, category: scanResultCategory, source: "native-ik-scan", imagePath, parsed: null, needsReview: true, confidence: 0, capturedAt: job.capturedAt, error, notes: [error], }), storedRecord: null, }; } function nativeJobCategory(category: string | undefined): { nativeCategory: string; scanResultCategory: ScanResultCategory; artifactProcessingSupported: boolean; } { const nativeCategory = (category ?? "artifacts").trim().toLowerCase() || "artifacts"; if (nativeCategory === "artifact" || nativeCategory === "artifacts") { return { nativeCategory, scanResultCategory: "artifact", artifactProcessingSupported: true }; } if (nativeCategory === "weapon" || nativeCategory === "weapons") { return { nativeCategory, scanResultCategory: "weapon", artifactProcessingSupported: false }; } if (nativeCategory === "character" || nativeCategory === "characters") { return { nativeCategory, scanResultCategory: "character", artifactProcessingSupported: false }; } if (nativeCategory === "material" || nativeCategory === "materials") { return { nativeCategory, scanResultCategory: "material", artifactProcessingSupported: false }; } return { nativeCategory, scanResultCategory: "unknown", artifactProcessingSupported: false }; } async function mapWithConcurrency( items: readonly TInput[], concurrency: number, worker: (item: TInput, index: number) => Promise, ) { const output = Array(items.length); let nextIndex = 0; const workerCount = Math.max(1, Math.min(items.length, Math.floor(concurrency))); await Promise.all(Array.from({ length: workerCount }, async () => { while (nextIndex < items.length) { const index = nextIndex++; output[index] = await worker(items[index], index); } })); return output; } async function readNativeCaptureJobs(runDir: string): Promise<{ jobsPath: string; jobs: NativeCaptureJobPayload[] }> { const jobsPath = path.join(runDir, "capture-jobs.jsonl"); const raw = await fs.readFile(jobsPath, "utf8"); const jobs = raw .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .map((line) => JSON.parse(line) as NativeCaptureJobPayload) .filter((job) => Number.isFinite(job.sequence)); return { jobsPath, jobs }; } async function fileExists(filePath: string) { try { await fs.access(filePath); return true; } catch { return false; } } function emptyProcessStatus(error: string): NativeScannerProcessStatus { return { ok: false, runDir: "", jobsPath: "", reportPath: "", scanResultsPath: "", processed: 0, parsed: 0, review: 0, stored: 0, errors: 1, elapsedMs: 0, queueConcurrency: 0, persisted: false, results: [], error, }; } function emptyResultsStatus(error: string): NativeScannerResultsLoadStatus { return { ok: false, runDir: "", path: "", total: 0, results: [], error, }; } function emptyImageStatus(error: string): NativeScannerImageLoadStatus { return { ok: false, runDir: "", path: "", dataUrl: "", width: 0, height: 0, error, }; } function isPathInside(root: string, candidate: string) { const relative = path.relative(root, candidate); return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } function isStoredScanResultEntry(value: unknown): value is StoredScanResultEntry { if (!value || typeof value !== "object") return false; const entry = value as Partial; return typeof entry.id === "string" && typeof entry.runId === "string" && Number.isFinite(entry.sequence) && typeof entry.source === "string" && typeof entry.imagePath === "string" && typeof entry.extractionStatus === "string" && typeof entry.valueStatus === "string" && Array.isArray(entry.notes); } function parsedArtifactNeedsReview(parsed: ReturnType) { if (!parsed) return true; if (parsed.confidence < 78) return true; const criticalFields: Array<"name" | "slot" | "mainStat" | "mainValue" | "setName"> = ["name", "slot", "mainStat", "mainValue", "setName"]; if (criticalFields.some((field) => (parsed.fields[field]?.confidence ?? 0) < 70)) return true; if (parsed.substats.length === 0) return true; return parsed.notes.some((note) => /not confidently parsed|substats look incomplete|likely OCR misread/i.test(note)); } function parsedArtifactCanPersist(parsed: ReturnType, needsReview: boolean) { if (!parsed) return false; if (parsed.name === "Unknown artifact") return false; if (parsed.slot === "Unknown slot") return false; if (parsed.setName === "Unknown set") return false; if (parsed.mainStat === "Unknown main stat") return false; if (parsed.mainValue === "?") return false; if (parsed.substats.length === 0) return false; if (!needsReview && parsed.confidence < 68) return false; if (needsReview && parsed.confidence < 60) return false; return true; }