feat(scanner): complete localized artifact quality checkpoint

This commit is contained in:
AzuTear
2026-07-11 15:59:19 +02:00
parent 639b0b7f59
commit 8b9f948c6b
215 changed files with 35440 additions and 7273 deletions
+186 -85
View File
@@ -17,6 +17,10 @@ import { registerIpcHandlers } from "./bootstrap/ipcBootstrap.js";
import { createDevControlServer } from "./devControlServer.js";
import { createAppWindowManager, type AppWindowManager } from "./appWindowManager.js";
import { createGoodFileService, type GoodFileService } from "./services/goodFileService.js";
import { resolveNativeScannerRunDirectory } from "./services/nativeScannerRunDirectory.js";
import { readNativeScannerRunTiming, recordNativeScannerRunTiming } from "./services/nativeScannerRunTiming.js";
import { isLikelyGenshinSourceName } from "./services/captureSourceClassifier.js";
import { ikInventoryListsPathCandidates, inputHelperPathCandidates } from "./runtimePaths.js";
import type { IkArtifactCatalog } from "../src/lib/ikArtifactMatcher.js";
import type { AppSnapshot } from "../src/types/domain.js";
import type {
@@ -25,12 +29,16 @@ import type {
GoodDatabase,
NativeScannerCatalogStatus,
NativeScannerDataStatus,
NativeScannerDeleteResultStatus,
NativeScannerImageLoadStatus,
NativeScannerPreflightStatus,
NativeScannerProcessOptions,
NativeScannerProcessStatus,
NativeScannerProcessingProgressStatus,
NativeScannerPromotionStatus,
NativeScannerReviewArtifactInput,
NativeScannerReviewStatus,
NativeScannerResultsLoadOptions,
NativeScannerResultsLoadStatus,
NativeScannerRunStatus,
OcrResult,
@@ -70,7 +78,7 @@ app.commandLine.appendSwitch("disable-gpu-sandbox");
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const isDev = Boolean(process.env.VITE_DEV_SERVER_URL);
const APP_RUNTIME_STARTED_AT = new Date().toISOString();
const APP_RUNTIME_SIGNATURE = "2026-07-09-native-artifact-pipeline";
const APP_RUNTIME_SIGNATURE = "2026-07-10-packaged-runtime-acceptance";
let registeredHotkeys: Record<string, boolean> = {};
let devControlServer: Server | null = null;
@@ -93,13 +101,26 @@ let scannerDevStatus: ScannerStatusPayload = {
learningRuleCount: 0,
updatedAt: null,
};
let nativeProcessingProgress: NativeScannerProcessingProgressStatus = {
running: false,
runDir: "",
total: 0,
processed: 0,
parsed: 0,
review: 0,
stored: 0,
errors: 0,
elapsedMs: 0,
};
let repositoryContext: RepositoryContext | null = null;
let artifactStoreRepository: ArtifactStoreRepositoryPort | null = null;
let reviewSamplesRepository: ReviewSamplesRepositoryPort | null = null;
let scannerLearningRepository: ScannerLearningRepositoryPort | null = null;
let nativeScannerProcessingService: ReturnType<typeof createNativeScannerProcessingService> | null = null;
let inputHelperService: InputHelperService | null = null;
let appWindowManager: AppWindowManager | null = null;
let goodFileService: GoodFileService | null = null;
const nativeScannerRequestStartedAtByRunId = new Map<string, string>();
function getInputHelperService() {
if (!inputHelperService) {
@@ -111,12 +132,13 @@ function getInputHelperService() {
// Locate the compiled C# input/capture sidecar (ADR-008). Falls back to null so
// the service uses the embedded PowerShell helper when the exe was never built.
function resolveInputHelperExePath(): string | null {
const candidates = [
process.env.INPUT_HELPER_EXE,
path.join(process.resourcesPath, "input-helper", "InputHelper.exe"),
path.join(process.cwd(), "native", "input-helper", "bin", "publish", "InputHelper.exe"),
path.join(app.getAppPath(), "native", "input-helper", "bin", "publish", "InputHelper.exe"),
].filter((candidate): candidate is string => Boolean(candidate));
const candidates = inputHelperPathCandidates({
isPackaged: app.isPackaged,
resourcesPath: process.resourcesPath,
currentWorkingDirectory: process.cwd(),
appPath: app.getAppPath(),
overridePath: process.env.INPUT_HELPER_EXE,
});
for (const candidate of candidates) {
try {
@@ -231,12 +253,13 @@ async function publishScannerStatus(status: ScannerStatusPayload) {
}
function nativeScannerDataDir() {
const candidates = [
process.env.IK_INVENTORYLISTS_DIR,
path.join(process.cwd(), "data", "ik-inventorylists"),
path.join(app.getAppPath(), "data", "ik-inventorylists"),
path.join(process.resourcesPath, "ik-inventorylists"),
].filter((candidate): candidate is string => Boolean(candidate));
const candidates = ikInventoryListsPathCandidates({
isPackaged: app.isPackaged,
resourcesPath: process.resourcesPath,
currentWorkingDirectory: process.cwd(),
appPath: app.getAppPath(),
overridePath: process.env.IK_INVENTORYLISTS_DIR,
});
return candidates.find((candidate) => existsSync(path.join(candidate, "version.txt"))) ?? candidates[0];
}
@@ -319,37 +342,64 @@ async function nativeScannerPreflight(options: { category?: string } = {}): Prom
}
async function nativeScannerStart(options: { limit?: number; category?: string } = {}): Promise<NativeScannerRunStatus> {
const status = await getInputHelperService().nativeScannerStart({
const requestStartedAt = new Date().toISOString();
const rawStatus = await getInputHelperService().nativeScannerStart({
dataDir: nativeScannerDataDir(),
outputRoot: nativeScannerOutputRoot(),
limit: options.limit ?? 100,
category: options.category ?? "artifacts",
});
const runId = rawStatus.runId?.trim();
if (runId && !nativeScannerRequestStartedAtByRunId.has(runId)) {
nativeScannerRequestStartedAtByRunId.set(runId, requestStartedAt);
}
const status = await attachNativeScannerTiming(rawStatus);
publishNativeScannerStatus(status, { lookupStatus: await nativeScannerDataStatus().catch(() => undefined) });
return status;
}
async function nativeScannerStop(): Promise<NativeScannerRunStatus> {
const status = await getInputHelperService().nativeScannerStop();
const status = await attachNativeScannerTiming(await getInputHelperService().nativeScannerStop());
publishNativeScannerStatus(status);
return status;
}
async function nativeScannerStatus(): Promise<NativeScannerRunStatus> {
const status = await getInputHelperService().nativeScannerStatus();
const status = await attachNativeScannerTiming(await getInputHelperService().nativeScannerStatus());
publishNativeScannerStatus(status);
return status;
}
async function attachNativeScannerTiming(status: NativeScannerRunStatus): Promise<NativeScannerRunStatus> {
const runDir = status.runDir?.trim();
const runId = status.runId?.trim();
if (!runDir) return status;
const timing = await recordNativeScannerRunTiming(runDir, {
...(runId && nativeScannerRequestStartedAtByRunId.has(runId)
? { requestStartedAt: nativeScannerRequestStartedAtByRunId.get(runId) }
: {}),
...(usableRunTimingTimestamp(status.startedAt) ? { scannerStartedAt: status.startedAt } : {}),
...(usableRunTimingTimestamp(status.captureStartedAt) ? { captureStartedAt: status.captureStartedAt } : {}),
...(usableRunTimingTimestamp(status.captureCompletedAt) ? { captureCompletedAt: status.captureCompletedAt } : {}),
}).catch(() => null);
if (!status.running && runId) nativeScannerRequestStartedAtByRunId.delete(runId);
return timing ? { ...status, timing } : status;
}
function usableRunTimingTimestamp(value: unknown): value is string {
return typeof value === "string"
&& value.length > 0
&& !value.startsWith("0001-")
&& Number.isFinite(Date.parse(value));
}
function resolveNativeProcessRunDir(runDir?: string) {
const native = scannerDevStatus.nativeScanner as NativeScannerRunStatus | undefined;
const candidate = runDir?.trim() || native?.runDir || "";
if (!candidate) return "";
const root = path.resolve(nativeScannerOutputRoot());
const resolved = path.resolve(candidate);
const relative = path.relative(root, resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) return "";
return resolved;
return resolveNativeScannerRunDirectory({
outputRoot: nativeScannerOutputRoot(),
requestedRunDir: runDir,
activeRunDir: native?.runDir,
});
}
async function buildNativeCardCaptureResult(
@@ -436,39 +486,71 @@ async function buildNativeCardCaptureResult(
};
}
function createNativeScannerService() {
return createNativeScannerProcessingService({
function getNativeScannerService() {
if (nativeScannerProcessingService) return nativeScannerProcessingService;
nativeScannerProcessingService = createNativeScannerProcessingService({
resolveRunDir: resolveNativeProcessRunDir,
buildCaptureResult: buildNativeCardCaptureResult,
loadArtifacts: () => getArtifactStoreRepository().loadAll(),
saveArtifacts: (records) => getArtifactStoreRepository().saveMany(records),
removeArtifacts: (ids) => getArtifactStoreRepository().removeByIds(ids),
loadIkArtifactCatalog: loadNativeIkArtifactCatalog,
saveReviewSample: (sample) => getReviewSamplesRepository().append(sample),
});
}
async function nativeScannerProcessRun(options: { runDir?: string; persist?: boolean; limit?: number } = {}): Promise<NativeScannerProcessStatus> {
const service = createNativeScannerService();
const status = await service.processRun(options);
scannerDevStatus = {
...scannerDevStatus,
reviewStatus: `Native post-processing: ${status.parsed}/${status.processed} parsed, ${status.review} review, ${status.stored} stored.`,
stats: {
...(scannerDevStatus.stats ?? {}),
...nativeScannerProcessStats(status),
recordRunTiming: recordNativeScannerRunTiming,
loadRunTiming: readNativeScannerRunTiming,
onProgress: (progress) => {
nativeProcessingProgress = { ...progress };
},
nativeScannerProcessing: status,
automationLog: [
...(scannerDevStatus.automationLog ?? []).slice(-10),
`native process: ${status.parsed}/${status.processed} parsed, report ${status.reportPath}`,
],
updatedAt: new Date().toISOString(),
};
return status;
});
return nativeScannerProcessingService;
}
async function nativeScannerLoadResults(options: { runDir?: string; limit?: number } = {}): Promise<NativeScannerResultsLoadStatus> {
const service = createNativeScannerService();
async function nativeScannerProcessingStatus() {
return { ...nativeProcessingProgress };
}
async function nativeScannerProcessRun(options: NativeScannerProcessOptions = {}): Promise<NativeScannerProcessStatus> {
const service = getNativeScannerService();
nativeProcessingProgress = {
running: true,
runDir: options.runDir ?? "",
total: Math.max(0, Math.round(options.expectedTotal ?? 0)),
processed: 0,
parsed: 0,
review: 0,
stored: 0,
errors: 0,
elapsedMs: 0,
};
try {
const status = await service.processRun(options);
scannerDevStatus = {
...scannerDevStatus,
reviewStatus: `Native post-processing: ${status.parsed}/${status.processed} parsed, ${status.review} review, ${status.stored} stored.`,
stats: {
...(scannerDevStatus.stats ?? {}),
...nativeScannerProcessStats(status),
},
nativeScannerProcessing: status,
automationLog: [
...(scannerDevStatus.automationLog ?? []).slice(-10),
`native process: ${status.parsed}/${status.processed} parsed, report ${status.reportPath}`,
],
updatedAt: new Date().toISOString(),
};
return status;
} catch (error) {
nativeProcessingProgress = {
...nativeProcessingProgress,
running: false,
error: error instanceof Error ? error.message : String(error),
};
throw error;
}
}
async function nativeScannerLoadResults(options: NativeScannerResultsLoadOptions = {}): Promise<NativeScannerResultsLoadStatus> {
const service = getNativeScannerService();
const loaded = await service.loadResults(options);
scannerDevStatus = {
...scannerDevStatus,
@@ -483,7 +565,7 @@ async function nativeScannerLoadResults(options: { runDir?: string; limit?: numb
}
async function nativeScannerPromoteResults(options: { runDir?: string; resultIds: string[] }): Promise<NativeScannerPromotionStatus> {
const service = createNativeScannerService();
const service = getNativeScannerService();
const status = await service.promoteResults(options);
scannerDevStatus = {
...scannerDevStatus,
@@ -507,7 +589,7 @@ async function nativeScannerReviewResult(options: {
artifact?: NativeScannerReviewArtifactInput;
note?: string;
}): Promise<NativeScannerReviewStatus> {
const service = createNativeScannerService();
const service = getNativeScannerService();
const status = await service.reviewResult(options);
scannerDevStatus = {
...scannerDevStatus,
@@ -524,8 +606,30 @@ async function nativeScannerReviewResult(options: {
return status;
}
async function nativeScannerDeleteResult(options: {
runDir?: string;
resultId: string;
removeLinkedStoreRecord?: boolean;
}): Promise<NativeScannerDeleteResultStatus> {
const service = getNativeScannerService();
const status = await service.deleteResult(options);
scannerDevStatus = {
...scannerDevStatus,
reviewStatus: status.ok
? `Local native result removed: ${status.resultId}${status.deletedStoreRecord ? " and linked store record" : ""}.`
: `Local native result removal blocked: ${status.error ?? "unknown error"}`,
nativeScannerDeletion: status,
automationLog: [
...(scannerDevStatus.automationLog ?? []).slice(-10),
`native local deletion: ${status.resultId || "unknown"} ${status.ok ? "ok" : status.error ?? "blocked"}`,
],
updatedAt: new Date().toISOString(),
};
return status;
}
async function nativeScannerLoadImage(options: { runDir?: string; imagePath: string }): Promise<NativeScannerImageLoadStatus> {
const service = createNativeScannerService();
const service = getNativeScannerService();
return service.loadImage(options);
}
@@ -701,16 +805,6 @@ async function getAutomationGuardCommand() {
};
}
function isLikelyGenshinSourceName(sourceName: string) {
const lowered = sourceName.toLowerCase();
return (
lowered.includes("genshin")
|| lowered.includes("genshinimpact")
|| lowered.includes("yuanshen")
|| sourceName.includes("\u539f\u795e")
);
}
async function capturePrimaryScreenViaGdi() {
return getInputHelperService().capturePrimaryScreenViaGdi();
}
@@ -734,6 +828,7 @@ function nativeImageFromGdiCapture(gdi: Awaited<ReturnType<InputHelperService["c
function shouldUseDirectGdiHotPath(options: CaptureOptions = {}) {
return Boolean(
options.ocrMode === "artifact" ||
options.ocrMode === "inventory-count" ||
options.skipOcrUnlessArtifactDetail ||
options.skipOcr ||
options.omitCrops,
@@ -1398,7 +1493,7 @@ function imageCropDataUrl(sourceImage: NativeImage, rect: Electron.Rectangle, im
function imageCropFingerprint(sourceImage: NativeImage, rect: Electron.Rectangle, imageSize: { width: number; height: number }) {
const safeRect = clampCaptureRect(rect, imageSize);
const bitmap = sourceImage.crop(safeRect).getBitmap();
const bitmap = sourceImage.crop(safeRect).toBitmap();
let hash = 2166136261;
const stride = Math.max(4, Math.floor(bitmap.length / 4096) * 4);
for (let index = 0; index < bitmap.length; index += stride) {
@@ -1424,7 +1519,7 @@ function preprocessedCropPngBuffer(sourceImage: NativeImage, rect: Electron.Rect
const size = upscaled.getSize();
if (!size.width || !size.height) return upscaled.toPNG();
const binarized = binarizeForOcr(
{ data: upscaled.getBitmap(), width: size.width, height: size.height },
{ data: upscaled.toBitmap(), width: size.width, height: size.height },
cropId === "artifact-level" ? { contrast: 80 } : {},
);
return nativeImage
@@ -1442,19 +1537,22 @@ function createCrops(
cropOptions: { sanctified?: boolean; skipOcr?: boolean } = {},
) {
const isArtifactScanMode = options.ocrMode === "artifact";
const isInventoryCountMode = options.ocrMode === "inventory-count";
const fastArtifactProfile = isArtifactScanMode && options.ocrProfile === "fast";
const omitCropImages = Boolean(options.omitCropImages);
const skipCropOcr = Boolean(cropOptions.skipOcr);
const templates: CropTemplate[] = detailCropRects(detailRect, imageSize, { ...cropOptions, fastProfile: fastArtifactProfile })
.filter((template) => {
if (fastArtifactProfile && (
template.id === "artifact-set-effects" ||
template.id === "artifact-main-stat-value"
)) return false;
if (template.id === "artifact-footer" && options.omitEquippedOcr) return false;
if (!isArtifactScanMode || template.id !== "artifact-footer" || !bitmap) return true;
return hasEquippedFooterMarker(bitmap, imageSize, template.rect);
});
const templates: CropTemplate[] = isInventoryCountMode
? []
: detailCropRects(detailRect, imageSize, { ...cropOptions, fastProfile: fastArtifactProfile })
.filter((template) => {
if (fastArtifactProfile && (
template.id === "artifact-set-effects" ||
template.id === "artifact-main-stat-value"
)) return false;
if (template.id === "artifact-footer" && options.omitEquippedOcr) return false;
if (!isArtifactScanMode || template.id !== "artifact-footer" || !bitmap) return true;
return hasEquippedFooterMarker(bitmap, imageSize, template.rect);
});
if (!isArtifactScanMode && inventoryRect.width > 120 && inventoryRect.height > 80) {
templates.push({
@@ -1544,24 +1642,25 @@ async function buildCaptureResult(
options: CaptureOptions = {},
) {
const buildStartedAt = Date.now();
const isInventoryCountMode = options.ocrMode === "inventory-count";
const size = sourceImage.getSize();
if (!size.width || !size.height) {
throw new Error("Capture produced an empty image.");
}
const bitmap = sourceImage.getBitmap();
const bitmap = sourceImage.toBitmap();
const detailRect = inferDetailRect(bitmap, size);
const artifactDetail = analyzeArtifactDetailPanel(bitmap, size, detailRect);
const paimonMenu = analyzePaimonMenu(bitmap, size);
const sanctified = detectSanctifiedArtifactDetail(bitmap, size, detailRect);
const skipOcrForMissingDetail = Boolean(options.skipOcrUnlessArtifactDetail && !artifactDetail.present);
const paimonMenu = isInventoryCountMode ? undefined : analyzePaimonMenu(bitmap, size);
const sanctified = isInventoryCountMode ? undefined : detectSanctifiedArtifactDetail(bitmap, size, detailRect);
const skipOcrForMissingDetail = Boolean(options.skipOcrUnlessArtifactDetail && !artifactDetail?.present);
const shouldSkipOcr = Boolean(options.skipOcr || skipOcrForMissingDetail);
const inventoryRect = inferInventoryRect(size, detailRect);
const omitCrops = Boolean(options.omitCrops);
const crops = omitCrops
? []
: createCrops(sourceImage, size, detailRect, inventoryRect, options, bitmap, { sanctified, skipOcr: shouldSkipOcr });
const lockSignal = options.omitLockState
const lockSignal = options.omitLockState || isInventoryCountMode
? undefined
: (() => {
const lockRect = clampCaptureRect(lockIconCropRect(detailRect, size), size);
@@ -1572,7 +1671,7 @@ async function buildCaptureResult(
try {
return lockSignalRatio(pngBufferToBitmap(lockImage.toPNG()));
} catch {
return lockSignalRatio({ data: lockImage.getBitmap(), width: lockSize.width, height: lockSize.height });
return lockSignalRatio({ data: lockImage.toBitmap(), width: lockSize.width, height: lockSize.height });
}
})()
: undefined;
@@ -1590,11 +1689,11 @@ async function buildCaptureResult(
: undefined;
})();
const locked = lockSignal ? isLocked(lockSignal.ratio, lockSignal.threshold) : undefined;
const omitFullFrame = Boolean(options.omitFullFrame || options.ocrMode === "artifact");
const omitDetailPreview = Boolean(options.omitDetailPreview);
const omitInventoryPreview = Boolean(options.omitInventoryPreview || options.ocrMode === "artifact");
const detailFingerprint = imageCropFingerprint(sourceImage, detailRect, size);
const inventoryFingerprint = imageCropFingerprint(sourceImage, inventoryRect, size);
const omitFullFrame = Boolean(options.omitFullFrame || options.ocrMode === "artifact" || isInventoryCountMode);
const omitDetailPreview = Boolean(options.omitDetailPreview || isInventoryCountMode);
const omitInventoryPreview = Boolean(options.omitInventoryPreview || options.ocrMode === "artifact" || isInventoryCountMode);
const detailFingerprint = isInventoryCountMode ? undefined : imageCropFingerprint(sourceImage, detailRect, size);
const inventoryFingerprint = isInventoryCountMode ? undefined : imageCropFingerprint(sourceImage, inventoryRect, size);
const fastOcrPriority: Record<string, number> = {
"artifact-substats": 0,
"artifact-name": 1,
@@ -1757,8 +1856,9 @@ function initializeAppLifecycle() {
nativeScannerStart: (options?: { limit?: number; category?: string }) => nativeScannerStart(options),
nativeScannerStop: () => nativeScannerStop(),
nativeScannerStatus: () => nativeScannerStatus(),
nativeScannerProcessRun: (options?: { runDir?: string; persist?: boolean; limit?: number }) => nativeScannerProcessRun(options),
nativeScannerLoadResults: (options?: { runDir?: string; limit?: number }) => nativeScannerLoadResults(options),
nativeScannerProcessRun: (options?: NativeScannerProcessOptions) => nativeScannerProcessRun(options),
nativeScannerProcessingStatus: () => nativeScannerProcessingStatus(),
nativeScannerLoadResults: (options?: NativeScannerResultsLoadOptions) => nativeScannerLoadResults(options),
nativeScannerPromoteResults: (options: { runDir?: string; resultIds: string[] }) => nativeScannerPromoteResults(options),
nativeScannerReviewResult: (options: {
runDir?: string;
@@ -1767,6 +1867,7 @@ function initializeAppLifecycle() {
artifact?: NativeScannerReviewArtifactInput;
note?: string;
}) => nativeScannerReviewResult(options),
nativeScannerDeleteResult: (options: { runDir?: string; resultId: string; removeLinkedStoreRecord?: boolean }) => nativeScannerDeleteResult(options),
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => nativeScannerLoadImage(options),
readRuntimeInfo: () => readRuntimeInfo(),
loadSnapshotFromDisk: () => loadSnapshotFromDisk(),