Compare commits
3 Commits
b7dbc618b3
...
2c6c1a8b31
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c6c1a8b31 | |||
| c7138b541d | |||
| 92345ef51a |
@@ -34,6 +34,20 @@ npm run dev
|
|||||||
|
|
||||||
Use the Electron app window for scanner work. The browser preview does not expose the local capture bridge.
|
Use the Electron app window for scanner work. The browser preview does not expose the local capture bridge.
|
||||||
|
|
||||||
|
### Input/Capture helper (C# sidecar)
|
||||||
|
|
||||||
|
Input automation and screen capture run through a compiled C# sidecar
|
||||||
|
(`native/input-helper`, see ADR-008). Build it once:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run helper:build # requires the .NET SDK; produces a self-contained exe
|
||||||
|
```
|
||||||
|
|
||||||
|
The app auto-detects the exe (`INPUT_HELPER_EXE` env override → packaged
|
||||||
|
`resources/input-helper` → `native/input-helper/bin/publish`). If the exe is not
|
||||||
|
present it falls back to the embedded PowerShell helper, so the app still runs
|
||||||
|
without the .NET build - just slower and with the old per-frame temp-file capture.
|
||||||
|
|
||||||
### Automatischer Scan: als Administrator starten
|
### Automatischer Scan: als Administrator starten
|
||||||
|
|
||||||
Genshin läuft erhöht (Administrator). Windows (UIPI) verwirft dann alle simulierten Maus-Eingaben aus einer nicht-erhöhten App - SendInput meldet dabei trotzdem Erfolg. Für den automatischen Scan muss die App deshalb ebenfalls erhöht laufen:
|
Genshin läuft erhöht (Administrator). Windows (UIPI) verwirft dann alle simulierten Maus-Eingaben aus einer nicht-erhöhten App - SendInput meldet dabei trotzdem Erfolg. Für den automatischen Scan muss die App deshalb ebenfalls erhöht laufen:
|
||||||
|
|||||||
+72
-132
@@ -1,5 +1,6 @@
|
|||||||
import { app, BrowserWindow, Menu, desktopCapturer, globalShortcut, nativeImage, screen, type NativeImage } from "electron";
|
import { app, BrowserWindow, Menu, desktopCapturer, globalShortcut, nativeImage, screen, type NativeImage } from "electron";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
import http, { type Server } from "node:http";
|
import http, { type Server } from "node:http";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
@@ -21,6 +22,17 @@ import type {
|
|||||||
ReviewSamplesRepositoryPort,
|
ReviewSamplesRepositoryPort,
|
||||||
ScannerLearningRepositoryPort,
|
ScannerLearningRepositoryPort,
|
||||||
} from "./repositories/index.js";
|
} from "./repositories/index.js";
|
||||||
|
import {
|
||||||
|
aspectRatioLabel,
|
||||||
|
detailCropRects,
|
||||||
|
inventoryCountCropRect,
|
||||||
|
inventoryGrid as layoutInventoryGrid,
|
||||||
|
inventoryRect as layoutInventoryRect,
|
||||||
|
isSixteenNine,
|
||||||
|
layoutSupportWarning,
|
||||||
|
profileDetailRect,
|
||||||
|
} from "../src/lib/layoutProfile.js";
|
||||||
|
import { binarizeForOcr } from "../src/lib/ocrPreprocess.js";
|
||||||
|
|
||||||
// Chromium's renderer sandbox can refuse to fully initialize (or silently
|
// Chromium's renderer sandbox can refuse to fully initialize (or silently
|
||||||
// crash the GPU/renderer process) when the hosting process runs with a full
|
// crash the GPU/renderer process) when the hosting process runs with a full
|
||||||
@@ -70,6 +82,25 @@ function getInputHelperService() {
|
|||||||
return inputHelperService;
|
return inputHelperService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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(app.getAppPath(), "native", "input-helper", "bin", "publish", "InputHelper.exe"),
|
||||||
|
].filter((candidate): candidate is string => Boolean(candidate));
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
if (existsSync(candidate)) return candidate;
|
||||||
|
} catch {
|
||||||
|
// Unreadable path; try the next candidate.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function getRepositoryContext() {
|
function getRepositoryContext() {
|
||||||
if (!repositoryContext) {
|
if (!repositoryContext) {
|
||||||
throw new Error("Repository context has not been initialized.");
|
throw new Error("Repository context has not been initialized.");
|
||||||
@@ -726,84 +757,48 @@ function imageCropDataUrl(sourceImage: NativeImage, rect: Electron.Rectangle, im
|
|||||||
return sourceImage.crop(safeRect).toDataURL();
|
return sourceImage.crop(safeRect).toDataURL();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preprocessed copy of a crop for OCR (ADR-009): upscale for more pixels, then
|
||||||
|
// grayscale + Otsu-binarize with inversion (artifact text is the bright
|
||||||
|
// foreground). The original crop is kept separately for the diagnostics UI.
|
||||||
|
function preprocessedCropDataUrl(sourceImage: NativeImage, rect: Electron.Rectangle, imageSize: { width: number; height: number }) {
|
||||||
|
const safeRect = clampCaptureRect(rect, imageSize);
|
||||||
|
const upscaled = sourceImage.crop(safeRect).resize({ width: Math.max(1, safeRect.width * 2), quality: "best" });
|
||||||
|
const size = upscaled.getSize();
|
||||||
|
if (!size.width || !size.height) return upscaled.toDataURL();
|
||||||
|
const binarized = binarizeForOcr({ data: upscaled.getBitmap(), width: size.width, height: size.height });
|
||||||
|
return nativeImage
|
||||||
|
.createFromBitmap(Buffer.from(binarized.data), { width: binarized.width, height: binarized.height })
|
||||||
|
.toDataURL();
|
||||||
|
}
|
||||||
|
|
||||||
function createCrops(
|
function createCrops(
|
||||||
sourceImage: NativeImage,
|
sourceImage: NativeImage,
|
||||||
imageSize: { width: number; height: number },
|
imageSize: { width: number; height: number },
|
||||||
detailRect: Electron.Rectangle,
|
detailRect: Electron.Rectangle,
|
||||||
inventoryRect: Electron.Rectangle,
|
inventoryRect: Electron.Rectangle,
|
||||||
) {
|
) {
|
||||||
const templates: CropTemplate[] = [
|
const templates: CropTemplate[] = detailCropRects(detailRect, imageSize);
|
||||||
{
|
|
||||||
id: "artifact-title",
|
|
||||||
label: "Artifact title",
|
|
||||||
rect: {
|
|
||||||
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
|
||||||
y: Math.round(detailRect.y + detailRect.height * 0.05),
|
|
||||||
width: Math.round(detailRect.width * 0.82),
|
|
||||||
height: Math.round(detailRect.height * 0.16),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "artifact-main-stat",
|
|
||||||
label: "Main stat",
|
|
||||||
rect: {
|
|
||||||
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
|
||||||
y: Math.round(detailRect.y + detailRect.height * 0.20),
|
|
||||||
width: Math.round(detailRect.width * 0.82),
|
|
||||||
height: Math.round(detailRect.height * 0.18),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "artifact-substats",
|
|
||||||
label: "Substats",
|
|
||||||
rect: {
|
|
||||||
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
|
||||||
y: Math.round(detailRect.y + detailRect.height * 0.41),
|
|
||||||
width: Math.round(detailRect.width * 0.82),
|
|
||||||
height: Math.round(detailRect.height * 0.25),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "artifact-footer",
|
|
||||||
label: "Footer",
|
|
||||||
rect: {
|
|
||||||
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
|
||||||
y: Math.round(detailRect.y + detailRect.height * 0.78),
|
|
||||||
width: Math.round(detailRect.width * 0.82),
|
|
||||||
height: Math.round(detailRect.height * 0.16),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (inventoryRect.width > 120 && inventoryRect.height > 80) {
|
if (inventoryRect.width > 120 && inventoryRect.height > 80) {
|
||||||
templates.push({
|
templates.push({
|
||||||
id: "inventory-count",
|
id: "inventory-count",
|
||||||
label: "Inventory count",
|
label: "Inventory count",
|
||||||
rect: {
|
rect: inventoryCountCropRect(inventoryRect, imageSize),
|
||||||
x: Math.round(inventoryRect.x + inventoryRect.width * 0.62),
|
|
||||||
y: Math.round(inventoryRect.y + inventoryRect.height * 0.02),
|
|
||||||
width: Math.round(inventoryRect.width * 0.34),
|
|
||||||
height: Math.round(inventoryRect.height * 0.09),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return templates
|
return templates
|
||||||
.map((template) => ({
|
.map((template) => {
|
||||||
...template,
|
const rect = clampCaptureRect(template.rect, imageSize);
|
||||||
rect: clampCaptureRect(template.rect, imageSize),
|
return {
|
||||||
dataUrl: imageCropDataUrl(sourceImage, template.rect, imageSize),
|
id: template.id,
|
||||||
}))
|
label: template.label,
|
||||||
.filter((crop) => crop.rect.width > 0 && crop.rect.height > 0)
|
rect,
|
||||||
.map((crop) => ({
|
dataUrl: imageCropDataUrl(sourceImage, rect, imageSize),
|
||||||
...crop,
|
ocrDataUrl: preprocessedCropDataUrl(sourceImage, rect, imageSize),
|
||||||
rect: {
|
};
|
||||||
x: crop.rect.x,
|
})
|
||||||
y: crop.rect.y,
|
.filter((crop) => crop.rect.width > 0 && crop.rect.height > 0);
|
||||||
width: crop.rect.width,
|
|
||||||
height: crop.rect.height,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferDetailRect(bitmap: Buffer, imageSize: { width: number; height: number }) {
|
function inferDetailRect(bitmap: Buffer, imageSize: { width: number; height: number }) {
|
||||||
@@ -845,79 +840,17 @@ function inferDetailRect(bitmap: Buffer, imageSize: { width: number; height: num
|
|||||||
return clampCaptureRect({ x: left, y: top, width: widthGuess, height: heightGuess }, imageSize);
|
return clampCaptureRect({ x: left, y: top, width: widthGuess, height: heightGuess }, imageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (width > 0 && height > 0) {
|
// Colour detection found nothing usable; fall back to the resolution-anchored
|
||||||
return clampCaptureRect(
|
// profile rect (single source of truth in layoutProfile).
|
||||||
{
|
return profileDetailRect(imageSize);
|
||||||
x: Math.round(width * 0.50),
|
|
||||||
y: Math.round(height * 0.08),
|
|
||||||
width: Math.round(width * 0.46),
|
|
||||||
height: Math.round(height * 0.74),
|
|
||||||
},
|
|
||||||
imageSize,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { x: 0, y: 0, width, height };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferInventoryRect(imageSize: { width: number; height: number }, detailRect: Electron.Rectangle) {
|
function inferInventoryRect(imageSize: { width: number; height: number }, detailRect: Electron.Rectangle) {
|
||||||
const { width, height } = imageSize;
|
return layoutInventoryRect(imageSize, detailRect);
|
||||||
const preferredWidth = Math.max(140, Math.round(width * 0.48));
|
|
||||||
const x = Math.round(width * 0.03);
|
|
||||||
const y = Math.round(detailRect.y + detailRect.height * 0.09);
|
|
||||||
const availableWidth = Math.max(100, detailRect.x - Math.round(width * 0.04));
|
|
||||||
const panelWidth = Math.max(100, Math.min(preferredWidth, availableWidth));
|
|
||||||
const safeWidth = panelWidth > width * 0.85 ? Math.round(width * 0.55) : panelWidth;
|
|
||||||
return clampCaptureRect(
|
|
||||||
{
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width: Math.min(safeWidth, Math.max(width - x - Math.round(width * 0.02), 100)),
|
|
||||||
height: Math.max(140, Math.round(height * 0.70)),
|
|
||||||
},
|
|
||||||
imageSize,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferInventoryGrid(imageSize: { width: number; height: number }, detailRect: Electron.Rectangle) {
|
function inferInventoryGrid(imageSize: { width: number; height: number }, detailRect: Electron.Rectangle) {
|
||||||
const inventoryRect = inferInventoryRect(imageSize, detailRect);
|
return layoutInventoryGrid(imageSize, detailRect);
|
||||||
const cols = 5;
|
|
||||||
if (inventoryRect.width < 160 || inventoryRect.height < 140) {
|
|
||||||
return {
|
|
||||||
centers: [],
|
|
||||||
rows: 0,
|
|
||||||
cols: 0,
|
|
||||||
confidence: 0,
|
|
||||||
source: "missing" as const,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const cellWidth = Math.max(56, Math.round(inventoryRect.width / cols));
|
|
||||||
const stepX = Math.round(cellWidth * 0.96);
|
|
||||||
const stepY = Math.round(cellWidth * 1.03);
|
|
||||||
const visibleRows = Math.max(2, Math.min(6, Math.round(inventoryRect.height / Math.max(stepY, 1))));
|
|
||||||
|
|
||||||
const startX = inventoryRect.x + Math.max(6, Math.round(stepX * 0.45));
|
|
||||||
const startY = inventoryRect.y + Math.max(6, Math.round(stepY * 0.45));
|
|
||||||
const centers = [];
|
|
||||||
for (let row = 0; row < visibleRows; row++) {
|
|
||||||
for (let col = 0; col < cols; col++) {
|
|
||||||
const x = startX + col * stepX;
|
|
||||||
const y = startY + row * stepY;
|
|
||||||
if (x < imageSize.width && y < imageSize.height) {
|
|
||||||
centers.push({ x, y, row, col });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmed = centers.filter((center) => center.x > 0 && center.y > 0);
|
|
||||||
return {
|
|
||||||
centers: trimmed,
|
|
||||||
rows: visibleRows,
|
|
||||||
cols,
|
|
||||||
confidence: trimmed.length >= cols * 2 ? 76 : trimmed.length >= cols ? 58 : 36,
|
|
||||||
source: "detected" as const,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildCaptureResult(
|
async function buildCaptureResult(
|
||||||
@@ -939,7 +872,9 @@ async function buildCaptureResult(
|
|||||||
const croppedPayload = crops.map((crop) => ({
|
const croppedPayload = crops.map((crop) => ({
|
||||||
id: crop.id,
|
id: crop.id,
|
||||||
label: crop.label,
|
label: crop.label,
|
||||||
dataUrl: crop.dataUrl,
|
// OCR reads the preprocessed (upscaled + binarized) crop; the original is
|
||||||
|
// kept below for the diagnostics UI.
|
||||||
|
dataUrl: crop.ocrDataUrl ?? crop.dataUrl,
|
||||||
}));
|
}));
|
||||||
const recognized = options.skipOcr ? { ocr: [], timedOut: false } : await runOcrOnCropsWithTimeout(croppedPayload);
|
const recognized = options.skipOcr ? { ocr: [], timedOut: false } : await runOcrOnCropsWithTimeout(croppedPayload);
|
||||||
|
|
||||||
@@ -970,6 +905,11 @@ async function buildCaptureResult(
|
|||||||
})),
|
})),
|
||||||
inventoryGrid: inferInventoryGrid(size, detailRect),
|
inventoryGrid: inferInventoryGrid(size, detailRect),
|
||||||
inventoryCount: count,
|
inventoryCount: count,
|
||||||
|
layout: {
|
||||||
|
aspect: aspectRatioLabel(size),
|
||||||
|
isSixteenNine: isSixteenNine(size),
|
||||||
|
warning: layoutSupportWarning(size),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1032,7 +972,7 @@ function initializeAppLifecycle() {
|
|||||||
artifactStoreRepository = repositoryContext.artifactStoreRepository;
|
artifactStoreRepository = repositoryContext.artifactStoreRepository;
|
||||||
reviewSamplesRepository = repositoryContext.reviewSamplesRepository;
|
reviewSamplesRepository = repositoryContext.reviewSamplesRepository;
|
||||||
scannerLearningRepository = repositoryContext.scannerLearningRepository;
|
scannerLearningRepository = repositoryContext.scannerLearningRepository;
|
||||||
inputHelperService = createInputHelperService({ userDataPath });
|
inputHelperService = createInputHelperService({ userDataPath, exePath: resolveInputHelperExePath() });
|
||||||
|
|
||||||
registerIpcHandlers({
|
registerIpcHandlers({
|
||||||
focusMainWindow: () => focusMainWindow(),
|
focusMainWindow: () => focusMainWindow(),
|
||||||
|
|||||||
@@ -382,7 +382,7 @@ class InputHelperClient {
|
|||||||
private starting: Promise<void> | null = null;
|
private starting: Promise<void> | null = null;
|
||||||
private disposed = false;
|
private disposed = false;
|
||||||
|
|
||||||
constructor(private readonly scriptUserDataPath: string) {}
|
constructor(private readonly options: { scriptUserDataPath: string; exePath?: string | null }) {}
|
||||||
|
|
||||||
private async ensureStarted() {
|
private async ensureStarted() {
|
||||||
if (this.child) return;
|
if (this.child) return;
|
||||||
@@ -396,14 +396,31 @@ class InputHelperClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async start() {
|
private async start() {
|
||||||
const scriptPath = path.join(this.scriptUserDataPath, "input-helper.ps1");
|
// Prefer the compiled C# sidecar (ADR-008). If it is missing or fails to
|
||||||
|
// start, fall back to the embedded PowerShell helper so the app keeps working
|
||||||
|
// on machines where the native exe was never built.
|
||||||
|
if (this.options.exePath) {
|
||||||
|
try {
|
||||||
|
await this.startWith(spawn(this.options.exePath, [], { windowsHide: true, stdio: ["pipe", "pipe", "pipe"] }));
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
this.teardownChild();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.startWith(await this.spawnPowershell());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async spawnPowershell() {
|
||||||
|
const scriptPath = path.join(this.options.scriptUserDataPath, "input-helper.ps1");
|
||||||
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||||
await fs.writeFile(scriptPath, INPUT_HELPER_SCRIPT, "utf8");
|
await fs.writeFile(scriptPath, INPUT_HELPER_SCRIPT, "utf8");
|
||||||
|
return spawn("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], {
|
||||||
const child = spawn("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], {
|
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startWith(child: ChildProcessWithoutNullStreams) {
|
||||||
child.stdout.setEncoding("utf8");
|
child.stdout.setEncoding("utf8");
|
||||||
child.stdout.on("data", (chunk: string) => this.handleStdout(chunk));
|
child.stdout.on("data", (chunk: string) => this.handleStdout(chunk));
|
||||||
child.stderr.setEncoding("utf8");
|
child.stderr.setEncoding("utf8");
|
||||||
@@ -419,10 +436,22 @@ class InputHelperClient {
|
|||||||
});
|
});
|
||||||
this.child = child;
|
this.child = child;
|
||||||
|
|
||||||
// First request compiles the Win32 interop; give it extra time.
|
// The C# sidecar answers ping immediately; the PowerShell fallback compiles
|
||||||
|
// Win32 interop on the first request, so give it extra time.
|
||||||
await this.send("ping", {}, 20000);
|
await this.send("ping", {}, 20000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private teardownChild() {
|
||||||
|
const child = this.child;
|
||||||
|
this.child = null;
|
||||||
|
this.buffer = "";
|
||||||
|
try {
|
||||||
|
child?.kill();
|
||||||
|
} catch {
|
||||||
|
// Child was never spawned or already gone.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private handleStdout(chunk: string) {
|
private handleStdout(chunk: string) {
|
||||||
this.buffer += chunk;
|
this.buffer += chunk;
|
||||||
let newlineIndex = this.buffer.indexOf("\n");
|
let newlineIndex = this.buffer.indexOf("\n");
|
||||||
@@ -485,8 +514,8 @@ export interface InputHelperService {
|
|||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createInputHelperService(options: { userDataPath: string }): InputHelperService {
|
export function createInputHelperService(options: { userDataPath: string; exePath?: string | null }): InputHelperService {
|
||||||
const inputHelper = new InputHelperClient(options.userDataPath);
|
const inputHelper = new InputHelperClient({ scriptUserDataPath: options.userDataPath, exePath: options.exePath ?? null });
|
||||||
|
|
||||||
async function request(op: string, params: Record<string, unknown> = {}, timeoutMs = 8000) {
|
async function request(op: string, params: Record<string, unknown> = {}, timeoutMs = 8000) {
|
||||||
return inputHelper.request(op, params, timeoutMs);
|
return inputHelper.request(op, params, timeoutMs);
|
||||||
@@ -590,16 +619,24 @@ export function createInputHelperService(options: { userDataPath: string }): Inp
|
|||||||
|
|
||||||
async function capturePrimaryScreenViaGdi() {
|
async function capturePrimaryScreenViaGdi() {
|
||||||
const result = await request("capture", {}, 15000);
|
const result = await request("capture", {}, 15000);
|
||||||
const capturePath = String(result.path);
|
// The C# sidecar returns PNG bytes inline (no temp file). The PowerShell
|
||||||
const buffer = await fs.readFile(capturePath);
|
// fallback writes a temp PNG and returns its path.
|
||||||
await fs.unlink(capturePath).catch(() => undefined);
|
let base64: string;
|
||||||
|
if (typeof result.imageBase64 === "string" && result.imageBase64) {
|
||||||
|
base64 = result.imageBase64;
|
||||||
|
} else {
|
||||||
|
const capturePath = String(result.path);
|
||||||
|
const buffer = await fs.readFile(capturePath);
|
||||||
|
await fs.unlink(capturePath).catch(() => undefined);
|
||||||
|
base64 = buffer.toString("base64");
|
||||||
|
}
|
||||||
const captureTargetRaw = typeof result.captureTarget === "string" ? result.captureTarget : "";
|
const captureTargetRaw = typeof result.captureTarget === "string" ? result.captureTarget : "";
|
||||||
const captureTarget: "primary-screen" | "genshin-client" = captureTargetRaw === "primary-screen" || captureTargetRaw === "genshin-client"
|
const captureTarget: "primary-screen" | "genshin-client" = captureTargetRaw === "primary-screen" || captureTargetRaw === "genshin-client"
|
||||||
? captureTargetRaw
|
? captureTargetRaw
|
||||||
: "primary-screen";
|
: "primary-screen";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dataUrl: `data:image/png;base64,${buffer.toString("base64")}`,
|
dataUrl: `data:image/png;base64,${base64}`,
|
||||||
width: Number(result.width),
|
width: Number(result.width),
|
||||||
height: Number(result.height),
|
height: Number(result.height),
|
||||||
originX: Number(result.originX),
|
originX: Number(result.originX),
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# .NET build outputs — the self-contained exe is built via `npm run helper:build`,
|
||||||
|
# not committed (it is ~100 MB).
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net9.0-windows</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<AssemblyName>InputHelper</AssemblyName>
|
||||||
|
<RootNamespace>GenshinAssistant.InputHelper</RootNamespace>
|
||||||
|
<!-- Screen.PrimaryScreen (Forms) + Bitmap/Graphics.CopyFromScreen (Drawing). -->
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
|
<!-- Single self-contained exe: no .NET install needed on the user's machine. -->
|
||||||
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
|
<SelfContained>true</SelfContained>
|
||||||
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||||
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
|
<!-- Per-monitor DPI v2 so SendInput/capture coordinates match a mixed-DPI
|
||||||
|
multi-monitor setup (same reason as the old PowerShell helper). -->
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Imaging;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Security.Principal;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
// Long-lived input/capture sidecar for the Genshin Artifact Assistant.
|
||||||
|
// Drop-in replacement for the old PowerShell helper (see ADR-008): identical
|
||||||
|
// JSON-over-stdin/stdout protocol - one JSON request per line, one JSON response
|
||||||
|
// per line - so the Electron-side InputHelperService is unchanged. Win32 interop
|
||||||
|
// is compiled once (this is a native exe), and capture returns base64 PNG bytes
|
||||||
|
// directly instead of writing a temp file per frame.
|
||||||
|
|
||||||
|
namespace GenshinAssistant.InputHelper;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
private static IntPtr _genshinHwnd = IntPtr.Zero;
|
||||||
|
|
||||||
|
private static int Main()
|
||||||
|
{
|
||||||
|
// Manifest already declares PerMonitorV2; this is a belt-and-suspenders
|
||||||
|
// call for hosts that ignore the manifest.
|
||||||
|
try { Native.SetProcessDpiAwarenessContext(Native.DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); }
|
||||||
|
catch { try { Native.SetProcessDpiAwareness(2); } catch { /* oldest fallback */ Native.SetProcessDPIAware(); } }
|
||||||
|
|
||||||
|
Console.OutputEncoding = Encoding.UTF8;
|
||||||
|
var stdout = Console.Out;
|
||||||
|
|
||||||
|
string? line;
|
||||||
|
while ((line = Console.In.ReadLine()) != null)
|
||||||
|
{
|
||||||
|
if (line.Trim().Length == 0) continue;
|
||||||
|
|
||||||
|
var response = new Dictionary<string, object?> { ["id"] = "", ["ok"] = true };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(line);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
response["id"] = GetString(root, "id");
|
||||||
|
var op = GetString(root, "op");
|
||||||
|
Handle(op, root, response);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
response["ok"] = false;
|
||||||
|
response["error"] = ex.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout.WriteLine(JsonSerializer.Serialize(response));
|
||||||
|
stdout.Flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Handle(string op, JsonElement root, Dictionary<string, object?> response)
|
||||||
|
{
|
||||||
|
switch (op)
|
||||||
|
{
|
||||||
|
case "ping":
|
||||||
|
response["pong"] = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "cursor":
|
||||||
|
{
|
||||||
|
var state = GetCursorState();
|
||||||
|
response["cursorX"] = state.X;
|
||||||
|
response["cursorY"] = state.Y;
|
||||||
|
response["escapePressed"] = state.Escape;
|
||||||
|
response["enterPressed"] = state.Enter;
|
||||||
|
response["f9Pressed"] = state.F9;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "runtime":
|
||||||
|
{
|
||||||
|
var hwnd = FindGenshinWindow();
|
||||||
|
var fgHwnd = Native.GetForegroundWindow();
|
||||||
|
response["isElevated"] = IsElevated();
|
||||||
|
response["genshinFound"] = hwnd != IntPtr.Zero;
|
||||||
|
response["genshinHwnd"] = hwnd.ToInt64();
|
||||||
|
response["targetProcess"] = ProcessNameFromHwnd(hwnd);
|
||||||
|
response["foregroundProcess"] = ProcessNameFromHwnd(fgHwnd);
|
||||||
|
response["foregroundHwnd"] = fgHwnd.ToInt64();
|
||||||
|
response["helperPid"] = Environment.ProcessId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "focus":
|
||||||
|
{
|
||||||
|
var info = FocusGenshinWindow();
|
||||||
|
response["focused"] = info.Focused;
|
||||||
|
response["alreadyForeground"] = info.AlreadyForeground;
|
||||||
|
response["foregroundProcess"] = info.ForegroundProcess;
|
||||||
|
response["targetProcess"] = info.TargetProcess;
|
||||||
|
response["genshinFound"] = info.Hwnd != IntPtr.Zero;
|
||||||
|
response["setForegroundResult"] = info.SetForegroundResult;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "click":
|
||||||
|
{
|
||||||
|
var info = FocusGenshinWindow();
|
||||||
|
if (info.Focused && !info.AlreadyForeground) Thread.Sleep(120);
|
||||||
|
|
||||||
|
var targetX = GetInt(root, "x");
|
||||||
|
var targetY = GetInt(root, "y");
|
||||||
|
// Bare SetCursorPos then a batched down+up click, matching the
|
||||||
|
// verified Inventory Kamera sequence: no extra move event, no
|
||||||
|
// gap between move and click.
|
||||||
|
Native.SetCursorPos(targetX, targetY);
|
||||||
|
Native.GetCursorPos(out var pt);
|
||||||
|
var onTarget = Math.Abs(targetX - pt.X) <= 2 && Math.Abs(targetY - pt.Y) <= 2;
|
||||||
|
var clickEventsSent = onTarget ? SendMouseClickBatch() : 0u;
|
||||||
|
|
||||||
|
var state = GetCursorState();
|
||||||
|
response["cursorX"] = state.X;
|
||||||
|
response["cursorY"] = state.Y;
|
||||||
|
response["escapePressed"] = state.Escape;
|
||||||
|
response["enterPressed"] = state.Enter;
|
||||||
|
response["f9Pressed"] = state.F9;
|
||||||
|
response["moved"] = onTarget;
|
||||||
|
response["focused"] = info.Focused;
|
||||||
|
response["alreadyForeground"] = info.AlreadyForeground;
|
||||||
|
response["foregroundProcess"] = info.ForegroundProcess;
|
||||||
|
response["targetProcess"] = info.TargetProcess;
|
||||||
|
response["isElevated"] = IsElevated();
|
||||||
|
// Only report a click when the cursor is verifiably on target and
|
||||||
|
// SendInput injected both events; real acceptance is proven later
|
||||||
|
// by the detail-panel fingerprint.
|
||||||
|
response["clicked"] = onTarget && clickEventsSent >= 2;
|
||||||
|
response["inputBlocked"] = onTarget && clickEventsSent < 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "scroll":
|
||||||
|
{
|
||||||
|
var info = FocusGenshinWindow();
|
||||||
|
if (info.Focused && !info.AlreadyForeground) Thread.Sleep(120);
|
||||||
|
|
||||||
|
if (TryGetInt(root, "x", out var ax) && TryGetInt(root, "y", out var ay))
|
||||||
|
{
|
||||||
|
Native.SetCursorPos(ax, ay);
|
||||||
|
Thread.Sleep(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
Native.GetCursorPos(out var pt);
|
||||||
|
response["cursorX"] = pt.X;
|
||||||
|
response["cursorY"] = pt.Y;
|
||||||
|
response["focused"] = info.Focused;
|
||||||
|
response["foregroundProcess"] = info.ForegroundProcess;
|
||||||
|
response["isElevated"] = IsElevated();
|
||||||
|
|
||||||
|
var notches = GetInt(root, "notches");
|
||||||
|
var stepDelta = notches < 0 ? -120 : 120;
|
||||||
|
var count = Math.Min(60, Math.Abs(notches));
|
||||||
|
uint sentTotal = 0;
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
sentTotal += SendMouseWheel(stepDelta);
|
||||||
|
Thread.Sleep(45);
|
||||||
|
}
|
||||||
|
response["notchesSent"] = sentTotal;
|
||||||
|
response["inputBlocked"] = count > 0 && sentTotal == 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "bounds":
|
||||||
|
{
|
||||||
|
var bounds = GetGenshinClientBounds();
|
||||||
|
if (bounds == null)
|
||||||
|
{
|
||||||
|
response["found"] = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
response["found"] = true;
|
||||||
|
response["left"] = bounds.Value.Left;
|
||||||
|
response["top"] = bounds.Value.Top;
|
||||||
|
response["width"] = bounds.Value.Width;
|
||||||
|
response["height"] = bounds.Value.Height;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "capture":
|
||||||
|
{
|
||||||
|
var bounds = GetGenshinClientBounds();
|
||||||
|
string captureTarget;
|
||||||
|
Rect area;
|
||||||
|
if (bounds == null)
|
||||||
|
{
|
||||||
|
var screen = Screen.PrimaryScreen!.Bounds;
|
||||||
|
area = new Rect { Left = screen.Left, Top = screen.Top, Width = screen.Width, Height = screen.Height };
|
||||||
|
captureTarget = "primary-screen";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
area = bounds.Value;
|
||||||
|
captureTarget = "genshin-client";
|
||||||
|
}
|
||||||
|
|
||||||
|
using var bitmap = new Bitmap(area.Width, area.Height, PixelFormat.Format32bppArgb);
|
||||||
|
using (var graphics = Graphics.FromImage(bitmap))
|
||||||
|
{
|
||||||
|
graphics.CopyFromScreen(area.Left, area.Top, 0, 0, bitmap.Size, CopyPixelOperation.SourceCopy);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
bitmap.Save(stream, ImageFormat.Png);
|
||||||
|
response["imageBase64"] = Convert.ToBase64String(stream.ToArray());
|
||||||
|
response["width"] = area.Width;
|
||||||
|
response["height"] = area.Height;
|
||||||
|
response["originX"] = area.Left;
|
||||||
|
response["originY"] = area.Top;
|
||||||
|
response["captureTarget"] = captureTarget;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
response["ok"] = false;
|
||||||
|
response["error"] = "unknown op";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly struct Rect
|
||||||
|
{
|
||||||
|
public int Left { get; init; }
|
||||||
|
public int Top { get; init; }
|
||||||
|
public int Width { get; init; }
|
||||||
|
public int Height { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CursorState
|
||||||
|
{
|
||||||
|
public int X;
|
||||||
|
public int Y;
|
||||||
|
public bool Escape;
|
||||||
|
public bool Enter;
|
||||||
|
public bool F9;
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct FocusInfo
|
||||||
|
{
|
||||||
|
public IntPtr Hwnd;
|
||||||
|
public bool Focused;
|
||||||
|
public bool AlreadyForeground;
|
||||||
|
public string ForegroundProcess;
|
||||||
|
public string TargetProcess;
|
||||||
|
public bool SetForegroundResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CursorState GetCursorState()
|
||||||
|
{
|
||||||
|
Native.GetCursorPos(out var pt);
|
||||||
|
// Only 0x8000 (held right now). The 0x0001 "pressed since last call" bit
|
||||||
|
// is unreliable and fires for ESC presses used to navigate Genshin menus.
|
||||||
|
var esc = (Native.GetAsyncKeyState(0x1B) & 0x8000) != 0;
|
||||||
|
var enter = (Native.GetAsyncKeyState(0x0D) & 0x8000) != 0;
|
||||||
|
var f9 = (Native.GetAsyncKeyState(0x78) & 0x8000) != 0;
|
||||||
|
return new CursorState { X = pt.X, Y = pt.Y, Escape = esc, Enter = enter, F9 = f9 };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FocusInfo FocusGenshinWindow()
|
||||||
|
{
|
||||||
|
var hwnd = FindGenshinWindow();
|
||||||
|
var info = new FocusInfo
|
||||||
|
{
|
||||||
|
Hwnd = hwnd,
|
||||||
|
ForegroundProcess = "",
|
||||||
|
TargetProcess = ProcessNameFromHwnd(hwnd),
|
||||||
|
};
|
||||||
|
if (hwnd == IntPtr.Zero) return info;
|
||||||
|
|
||||||
|
info.AlreadyForeground = Native.GetForegroundWindow() == hwnd;
|
||||||
|
if (!info.AlreadyForeground)
|
||||||
|
{
|
||||||
|
Native.ShowWindowAsync(hwnd, 9); // SW_RESTORE
|
||||||
|
// No ALT tap: this app and Genshin run at the same (elevated)
|
||||||
|
// integrity level, so SetForegroundWindow succeeds on its own. An ALT
|
||||||
|
// tap would toggle menu-mnemonic mode and swallow the next inputs.
|
||||||
|
info.SetForegroundResult = Native.SetForegroundWindow(hwnd);
|
||||||
|
Thread.Sleep(140);
|
||||||
|
}
|
||||||
|
|
||||||
|
var foreground = Native.GetForegroundWindow();
|
||||||
|
info.Focused = foreground == hwnd;
|
||||||
|
info.ForegroundProcess = ProcessNameFromHwnd(foreground);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IntPtr FindGenshinWindow()
|
||||||
|
{
|
||||||
|
if (_genshinHwnd != IntPtr.Zero && Native.IsWindow(_genshinHwnd)) return _genshinHwnd;
|
||||||
|
|
||||||
|
foreach (var proc in Process.GetProcesses())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var name = proc.ProcessName;
|
||||||
|
if ((name.Contains("GenshinImpact", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| name.Contains("YuanShen", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| name.Contains("Genshin", StringComparison.OrdinalIgnoreCase))
|
||||||
|
&& proc.MainWindowHandle != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
_genshinHwnd = proc.MainWindowHandle;
|
||||||
|
return _genshinHwnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Process exited between enumeration and inspection; ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_genshinHwnd = IntPtr.Zero;
|
||||||
|
return _genshinHwnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Rect? GetGenshinClientBounds()
|
||||||
|
{
|
||||||
|
var hwnd = FindGenshinWindow();
|
||||||
|
if (hwnd == IntPtr.Zero) return null;
|
||||||
|
if (!Native.GetClientRect(hwnd, out var rect)) return null;
|
||||||
|
|
||||||
|
var topLeft = new Native.POINT { X = 0, Y = 0 };
|
||||||
|
if (!Native.ClientToScreen(hwnd, ref topLeft)) return null;
|
||||||
|
|
||||||
|
var width = rect.Right - rect.Left;
|
||||||
|
var height = rect.Bottom - rect.Top;
|
||||||
|
if (width <= 0 || height <= 0) return null;
|
||||||
|
|
||||||
|
return new Rect { Left = topLeft.X, Top = topLeft.Y, Width = width, Height = height };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ProcessNameFromHwnd(IntPtr hwnd)
|
||||||
|
{
|
||||||
|
if (hwnd == IntPtr.Zero) return "";
|
||||||
|
Native.GetWindowThreadProcessId(hwnd, out var pid);
|
||||||
|
if (pid == 0) return "";
|
||||||
|
try { return Process.GetProcessById((int)pid).ProcessName; }
|
||||||
|
catch { return ""; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsElevated()
|
||||||
|
{
|
||||||
|
using var identity = WindowsIdentity.GetCurrent();
|
||||||
|
var principal = new WindowsPrincipal(identity);
|
||||||
|
return principal.IsInRole(WindowsBuiltInRole.Administrator);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint SendMouseClickBatch()
|
||||||
|
{
|
||||||
|
var inputs = new Native.INPUT[2];
|
||||||
|
inputs[0].type = 0; // INPUT_MOUSE
|
||||||
|
inputs[0].mi.dwFlags = Native.MOUSEEVENTF_LEFTDOWN;
|
||||||
|
inputs[1].type = 0;
|
||||||
|
inputs[1].mi.dwFlags = Native.MOUSEEVENTF_LEFTUP;
|
||||||
|
return Native.SendInput(2, inputs, Marshal.SizeOf<Native.INPUT>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint SendMouseWheel(int wheelData)
|
||||||
|
{
|
||||||
|
var inputs = new Native.INPUT[1];
|
||||||
|
inputs[0].type = 0;
|
||||||
|
inputs[0].mi.mouseData = unchecked((uint)wheelData);
|
||||||
|
inputs[0].mi.dwFlags = Native.MOUSEEVENTF_WHEEL;
|
||||||
|
return Native.SendInput(1, inputs, Marshal.SizeOf<Native.INPUT>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetString(JsonElement root, string name)
|
||||||
|
=> root.TryGetProperty(name, out var value) ? value.ToString() : "";
|
||||||
|
|
||||||
|
private static int GetInt(JsonElement root, string name)
|
||||||
|
=> TryGetInt(root, name, out var value) ? value : 0;
|
||||||
|
|
||||||
|
private static bool TryGetInt(JsonElement root, string name, out int value)
|
||||||
|
{
|
||||||
|
value = 0;
|
||||||
|
if (!root.TryGetProperty(name, out var element)) return false;
|
||||||
|
if (element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out value)) return true;
|
||||||
|
if (element.ValueKind == JsonValueKind.String && int.TryParse(element.GetString(), out value)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class Native
|
||||||
|
{
|
||||||
|
public const uint MOUSEEVENTF_LEFTDOWN = 0x0002;
|
||||||
|
public const uint MOUSEEVENTF_LEFTUP = 0x0004;
|
||||||
|
public const uint MOUSEEVENTF_WHEEL = 0x0800;
|
||||||
|
public static readonly IntPtr DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = new(-4);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
public struct POINT { public int X; public int Y; }
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
|
||||||
|
|
||||||
|
// Binary-compatible with the Win32 INPUT for mouse-only use on x64:
|
||||||
|
// type(4) + 4 pad + MOUSEINPUT(32) = 40 bytes = sizeof(INPUT).
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
public struct MOUSEINPUT
|
||||||
|
{
|
||||||
|
public int dx;
|
||||||
|
public int dy;
|
||||||
|
public uint mouseData;
|
||||||
|
public uint dwFlags;
|
||||||
|
public uint time;
|
||||||
|
public UIntPtr dwExtraInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
public struct INPUT
|
||||||
|
{
|
||||||
|
public int type;
|
||||||
|
public MOUSEINPUT mi;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern bool SetProcessDPIAware();
|
||||||
|
|
||||||
|
[DllImport("shcore.dll")]
|
||||||
|
public static extern int SetProcessDpiAwareness(int value);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern bool SetProcessDpiAwarenessContext(IntPtr value);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern bool SetCursorPos(int x, int y);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern bool GetCursorPos(out POINT lpPoint);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
public static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
public static extern bool ClientToScreen(IntPtr hWnd, ref POINT lpPoint);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern short GetAsyncKeyState(int vKey);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
public static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern IntPtr GetForegroundWindow();
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern bool IsWindow(IntPtr hWnd);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="GenshinAssistant.InputHelper" type="win32" />
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<!-- PerMonitorV2: coordinates stay correct across mixed-DPI monitors. -->
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
</assembly>
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"eval": "vitest run src/eval/ocrEval.test.ts",
|
"eval": "vitest run src/eval/ocrEval.test.ts",
|
||||||
|
"helper:build": "dotnet publish native/input-helper/InputHelper.csproj -c Release -o native/input-helper/bin/publish",
|
||||||
"data:genshin": "node scripts/generate-genshin-data.cjs"
|
"data:genshin": "node scripts/generate-genshin-data.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -48,6 +49,15 @@
|
|||||||
"dist-electron/**/*",
|
"dist-electron/**/*",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "native/input-helper/bin/publish",
|
||||||
|
"to": "input-helper",
|
||||||
|
"filter": [
|
||||||
|
"**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"win": {
|
"win": {
|
||||||
"target": "nsis",
|
"target": "nsis",
|
||||||
"requestedExecutionLevel": "requireAdministrator"
|
"requestedExecutionLevel": "requireAdministrator"
|
||||||
|
|||||||
@@ -60,19 +60,20 @@ export function useScanResultCardModel({
|
|||||||
parsed,
|
parsed,
|
||||||
}: Pick<ArtifactResultCardProps, "parsed">): ScanResultCardModel {
|
}: Pick<ArtifactResultCardProps, "parsed">): ScanResultCardModel {
|
||||||
const substats = parsed.substats;
|
const substats = parsed.substats;
|
||||||
|
const rows: Array<[string, ParsedField]> = [
|
||||||
|
["Name", parsed.fields.name],
|
||||||
|
["Slot", parsed.fields.slot],
|
||||||
|
["Level", getLevelField(parsed)],
|
||||||
|
["Main", mergeField(parsed.fields.mainStat, parsed.fields.mainValue)],
|
||||||
|
["Set", parsed.fields.setName],
|
||||||
|
["Equipped", parsed.fields.equipped],
|
||||||
|
["Substats", parsed.fields.substats],
|
||||||
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
levelField: getLevelField(parsed),
|
levelField: getLevelField(parsed),
|
||||||
quality: resolveQuality(parsed.confidence),
|
quality: resolveQuality(parsed.confidence),
|
||||||
fieldRows: [
|
fieldRows: rows.map(([label, field]) => ({
|
||||||
["Name", parsed.fields.name],
|
|
||||||
["Slot", parsed.fields.slot],
|
|
||||||
["Level", getLevelField(parsed)],
|
|
||||||
["Main", mergeField(parsed.fields.mainStat, parsed.fields.mainValue)],
|
|
||||||
["Set", parsed.fields.setName],
|
|
||||||
["Equipped", parsed.fields.equipped],
|
|
||||||
["Substats", parsed.fields.substats],
|
|
||||||
].map(([label, field]) => ({
|
|
||||||
label,
|
label,
|
||||||
field,
|
field,
|
||||||
confidenceClassName: resolveFieldConfidenceClass(field),
|
confidenceClassName: resolveFieldConfidenceClass(field),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface ScanTopControlsModel {
|
|||||||
canStartAutoScan: boolean;
|
canStartAutoScan: boolean;
|
||||||
canStartManualScan: boolean;
|
canStartManualScan: boolean;
|
||||||
canCaptureSingle: boolean;
|
canCaptureSingle: boolean;
|
||||||
|
autoScanRunning: boolean;
|
||||||
handleSourceChange: (event: ChangeEvent<HTMLSelectElement>) => void;
|
handleSourceChange: (event: ChangeEvent<HTMLSelectElement>) => void;
|
||||||
selectGenshinSource: () => void;
|
selectGenshinSource: () => void;
|
||||||
openSettings: () => void;
|
openSettings: () => void;
|
||||||
@@ -41,7 +42,7 @@ export function useScanTopControlsModel({
|
|||||||
bridgeReady,
|
bridgeReady,
|
||||||
isScanning,
|
isScanning,
|
||||||
controller,
|
controller,
|
||||||
}: ScanTopControlsSectionProps): ScanTopControlsModel {
|
}: Omit<ScanTopControlsSectionProps, "refreshCaptureSources">): ScanTopControlsModel {
|
||||||
const {
|
const {
|
||||||
setSettingsOpen,
|
setSettingsOpen,
|
||||||
setDiagnosticsOpen,
|
setDiagnosticsOpen,
|
||||||
@@ -116,6 +117,7 @@ export function useScanTopControlsModel({
|
|||||||
canStartAutoScan: hasSourceSelected && !isScanning && !autoScanRunning && canAutoScan && !requiresAdminForAutoScan,
|
canStartAutoScan: hasSourceSelected && !isScanning && !autoScanRunning && canAutoScan && !requiresAdminForAutoScan,
|
||||||
canStartManualScan: hasSourceSelected && !isScanning && !autoScanRunning && canCaptureSource,
|
canStartManualScan: hasSourceSelected && !isScanning && !autoScanRunning && canCaptureSource,
|
||||||
canCaptureSingle: hasSourceSelected && !isScanning && !autoScanRunning && canCaptureSource,
|
canCaptureSingle: hasSourceSelected && !isScanning && !autoScanRunning && canCaptureSource,
|
||||||
|
autoScanRunning,
|
||||||
handleSourceChange,
|
handleSourceChange,
|
||||||
selectGenshinSource,
|
selectGenshinSource,
|
||||||
openSettings,
|
openSettings,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, type MouseEvent } from "react";
|
import { useCallback, type MouseEvent } from "react";
|
||||||
import type { ScanDetailsModalProps } from "../types";
|
import type { ScanDetailsModalProps } from "../types";
|
||||||
import type { ParsedArtifactCandidate } from "../../../../lib/artifactOcrParser";
|
import type { ParsedArtifactCandidate } from "../../../../../lib/artifactOcrParser";
|
||||||
|
|
||||||
export interface ScanDetailsModalModel {
|
export interface ScanDetailsModalModel {
|
||||||
closeDetails: () => void;
|
closeDetails: () => void;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { detailFingerprint } from "../../../../lib/autoScanLoop";
|
import { detailFingerprint } from "../../../../../lib/autoScanLoop";
|
||||||
import { sourceVersion } from "../../../../lib/genshinData";
|
import { sourceVersion } from "../../../../../lib/genshinData";
|
||||||
import { useCallback, useMemo, type MouseEvent } from "react";
|
import { useCallback, useMemo, type MouseEvent } from "react";
|
||||||
import type { ScanDiagnosticsModalProps } from "../types";
|
import type { ScanDiagnosticsModalProps } from "../types";
|
||||||
|
|
||||||
@@ -39,16 +39,20 @@ export interface ScanDiagnosticsModalModel {
|
|||||||
canSaveReviewSample: boolean;
|
canSaveReviewSample: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ScanDiagnosticsController = ScanDiagnosticsModalProps["controller"];
|
||||||
|
|
||||||
interface UseScanDiagnosticsModalModelInput extends Pick<
|
interface UseScanDiagnosticsModalModelInput extends Pick<
|
||||||
ScanDiagnosticsModalProps,
|
ScanDiagnosticsModalProps,
|
||||||
| "setDetailsOpen"
|
| "setDetailsOpen"
|
||||||
| "setDiagnosticsOpen"
|
| "setDiagnosticsOpen"
|
||||||
| "saveReviewSample"
|
|
||||||
| "canSaveReviewSample"
|
|
||||||
| "latestCapture"
|
| "latestCapture"
|
||||||
| "controller"
|
| "controller"
|
||||||
> {
|
> {
|
||||||
captureStatus: string;
|
captureStatus: string;
|
||||||
|
// saveReviewSample / canSaveReviewSample live on the controller, not the modal
|
||||||
|
// props; the component wires them through from controller.* .
|
||||||
|
saveReviewSample: ScanDiagnosticsController["saveReviewSample"];
|
||||||
|
canSaveReviewSample: ScanDiagnosticsController["canSaveReviewSample"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useScanDiagnosticsModalModel({
|
export function useScanDiagnosticsModalModel({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useMemo, type MouseEvent } from "react";
|
import { useCallback, useMemo, type MouseEvent } from "react";
|
||||||
import type { ReviewSampleAnalysis } from "../../../../lib/reviewSampleAnalysis";
|
import type { ReviewSampleAnalysis } from "../../../../../lib/reviewSampleAnalysis";
|
||||||
import type { ScanReviewQueueModalProps } from "../types";
|
import type { ScanReviewQueueModalProps } from "../types";
|
||||||
|
|
||||||
export interface ScanReviewQueueRow {
|
export interface ScanReviewQueueRow {
|
||||||
@@ -18,8 +18,10 @@ export interface ScanReviewQueueModalModel {
|
|||||||
|
|
||||||
interface UseScanReviewQueueModalModelInput extends Pick<
|
interface UseScanReviewQueueModalModelInput extends Pick<
|
||||||
ScanReviewQueueModalProps,
|
ScanReviewQueueModalProps,
|
||||||
"setReviewQueueOpen" | "loadReviewQueue"
|
"setReviewQueueOpen"
|
||||||
> {
|
> {
|
||||||
|
// loadReviewQueue is wired through from controller.* by the component.
|
||||||
|
loadReviewQueue: ScanReviewQueueModalProps["controller"]["loadReviewQueue"];
|
||||||
reviewAnalysis: ReviewSampleAnalysis;
|
reviewAnalysis: ReviewSampleAnalysis;
|
||||||
reviewSampleTotal: number;
|
reviewSampleTotal: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, type ChangeEvent, type MouseEvent } from "react";
|
import { useCallback, type ChangeEvent, type MouseEvent } from "react";
|
||||||
import type { CaptureResult, RuntimeInfo } from "../../../../../types/global";
|
import type { CaptureResult, RuntimeInfo } from "../../../../../types/global";
|
||||||
import { clampScanLimit, clampSkipRows } from "../../../../lib/scannerSession";
|
import { clampScanLimit, clampSkipRows } from "../../../../../lib/scannerSession";
|
||||||
|
|
||||||
export interface ScanSettingsModalModel {
|
export interface ScanSettingsModalModel {
|
||||||
closeSettings: () => void;
|
closeSettings: () => void;
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
aspectRatioLabel,
|
||||||
|
detailCropRects,
|
||||||
|
inventoryCountCropRect,
|
||||||
|
inventoryGrid,
|
||||||
|
inventoryRect,
|
||||||
|
isSixteenNine,
|
||||||
|
layoutSupportWarning,
|
||||||
|
profileDetailRect,
|
||||||
|
} from "./layoutProfile";
|
||||||
|
|
||||||
|
const HD = { width: 1920, height: 1080 };
|
||||||
|
const QHD = { width: 2560, height: 1440 };
|
||||||
|
const ULTRAWIDE = { width: 3440, height: 1440 };
|
||||||
|
|
||||||
|
describe("layoutProfile", () => {
|
||||||
|
it("detects 16:9 across common resolutions and rejects ultrawide", () => {
|
||||||
|
expect(isSixteenNine(HD)).toBe(true);
|
||||||
|
expect(isSixteenNine(QHD)).toBe(true);
|
||||||
|
expect(isSixteenNine({ width: 3840, height: 2160 })).toBe(true);
|
||||||
|
expect(isSixteenNine(ULTRAWIDE)).toBe(false);
|
||||||
|
expect(isSixteenNine({ width: 1920, height: 1200 })).toBe(false); // 16:10
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels the aspect ratio", () => {
|
||||||
|
expect(aspectRatioLabel(HD)).toBe("1.78:1");
|
||||||
|
expect(aspectRatioLabel({ width: 0, height: 0 })).toBe("unknown");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns only for non-16:9 resolutions", () => {
|
||||||
|
expect(layoutSupportWarning(HD)).toBe("");
|
||||||
|
expect(layoutSupportWarning(QHD)).toBe("");
|
||||||
|
expect(layoutSupportWarning(ULTRAWIDE)).toContain("nicht 16:9");
|
||||||
|
expect(layoutSupportWarning({ width: 0, height: 0 })).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the detail rect inside the image and on the right half", () => {
|
||||||
|
const rect = profileDetailRect(QHD);
|
||||||
|
expect(rect.x).toBeGreaterThanOrEqual(QHD.width * 0.45);
|
||||||
|
expect(rect.x + rect.width).toBeLessThanOrEqual(QHD.width);
|
||||||
|
expect(rect.y + rect.height).toBeLessThanOrEqual(QHD.height);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces the four artifact crops in top-to-bottom order, all clamped", () => {
|
||||||
|
const detail = profileDetailRect(QHD);
|
||||||
|
const crops = detailCropRects(detail, QHD);
|
||||||
|
expect(crops.map((crop) => crop.id)).toEqual([
|
||||||
|
"artifact-title",
|
||||||
|
"artifact-main-stat",
|
||||||
|
"artifact-substats",
|
||||||
|
"artifact-footer",
|
||||||
|
]);
|
||||||
|
let previousY = -1;
|
||||||
|
for (const crop of crops) {
|
||||||
|
expect(crop.rect.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(crop.rect.y).toBeGreaterThan(previousY);
|
||||||
|
expect(crop.rect.x + crop.rect.width).toBeLessThanOrEqual(QHD.width);
|
||||||
|
expect(crop.rect.y + crop.rect.height).toBeLessThanOrEqual(QHD.height);
|
||||||
|
previousY = crop.rect.y;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places the inventory count crop inside the inventory panel", () => {
|
||||||
|
const detail = profileDetailRect(QHD);
|
||||||
|
const inv = inventoryRect(QHD, detail);
|
||||||
|
const count = inventoryCountCropRect(inv, QHD);
|
||||||
|
expect(count.x).toBeGreaterThanOrEqual(inv.x);
|
||||||
|
expect(count.x + count.width).toBeLessThanOrEqual(QHD.width);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a 5-column inventory grid on the left", () => {
|
||||||
|
const detail = profileDetailRect(QHD);
|
||||||
|
const grid = inventoryGrid(QHD, detail);
|
||||||
|
expect(grid.cols).toBe(5);
|
||||||
|
expect(grid.source).toBe("detected");
|
||||||
|
expect(grid.centers.length).toBeGreaterThanOrEqual(10);
|
||||||
|
expect(grid.centers.every((center) => center.x < detail.x)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing grid when the inventory panel is too small", () => {
|
||||||
|
const tiny = { width: 320, height: 180 };
|
||||||
|
const grid = inventoryGrid(tiny, profileDetailRect(tiny));
|
||||||
|
expect(grid.source).toBe("missing");
|
||||||
|
expect(grid.centers).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
// Resolution-anchored layout geometry for the artifact inventory screen
|
||||||
|
// (ADR-009). Inventory Kamera's proven approach is to require borderless 16:9 and
|
||||||
|
// derive crop/grid coordinates from the client rectangle instead of detecting the
|
||||||
|
// panel by colour each frame. This module is the single, pure, unit-tested source
|
||||||
|
// of that geometry; electron/main.ts consumes it for cropping and keeps a
|
||||||
|
// colour-based detail-rect detector only as a fallback for off-profile setups.
|
||||||
|
//
|
||||||
|
// NOTE: the per-field detail crop fractions below are the current working values.
|
||||||
|
// True IK-style fixed coordinates need calibration against a reference 16:9
|
||||||
|
// screenshot; the structure here is what those calibrated numbers slot into.
|
||||||
|
|
||||||
|
export interface LayoutRect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CropTemplateRect {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
rect: LayoutRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryGridLayout {
|
||||||
|
centers: Array<{ x: number; y: number; row: number; col: number }>;
|
||||||
|
rows: number;
|
||||||
|
cols: number;
|
||||||
|
confidence: number;
|
||||||
|
source: "detected" | "missing";
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIXTEEN_NINE = 16 / 9;
|
||||||
|
|
||||||
|
export function aspectRatio(size: { width: number; height: number }): number {
|
||||||
|
if (!size.height) return 0;
|
||||||
|
return size.width / size.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aspectRatioLabel(size: { width: number; height: number }): string {
|
||||||
|
const ratio = aspectRatio(size);
|
||||||
|
if (ratio === 0) return "unknown";
|
||||||
|
return `${ratio.toFixed(2)}:1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Genshin's UI is authored for 16:9; other aspect ratios letterbox or reflow and
|
||||||
|
// the anchored crops no longer line up. Allow a small tolerance for rounding.
|
||||||
|
export function isSixteenNine(size: { width: number; height: number }, tolerance = 0.02): boolean {
|
||||||
|
const ratio = aspectRatio(size);
|
||||||
|
if (ratio === 0) return false;
|
||||||
|
return Math.abs(ratio - SIXTEEN_NINE) <= SIXTEEN_NINE * tolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty when the client is a supported 16:9; otherwise a warning explaining that
|
||||||
|
// the anchored crops are unreliable off-profile (ADR-009: non-16:9 is explicitly
|
||||||
|
// unsupported for the auto scanner).
|
||||||
|
export function layoutSupportWarning(size: { width: number; height: number }): string {
|
||||||
|
if (size.width <= 0 || size.height <= 0) return "";
|
||||||
|
if (isSixteenNine(size)) return "";
|
||||||
|
return `Aufloesung ${size.width}x${size.height} ist nicht 16:9 (${aspectRatioLabel(size)}). Der Auto-Scan ist auf 16:9 im randlosen Fenstermodus ausgelegt; die Erkennung kann daneben liegen.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampRect(rect: LayoutRect, imageSize: { width: number; height: number }): LayoutRect {
|
||||||
|
const x = Math.max(0, Math.min(imageSize.width - 1, rect.x));
|
||||||
|
const y = Math.max(0, Math.min(imageSize.height - 1, rect.y));
|
||||||
|
const maxWidth = Math.max(1, imageSize.width - x);
|
||||||
|
const maxHeight = Math.max(1, imageSize.height - y);
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: Math.max(1, Math.min(maxWidth, rect.width)),
|
||||||
|
height: Math.max(1, Math.min(maxHeight, rect.height)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anchored guess for the artifact detail panel on the right of the screen. Used
|
||||||
|
// as the primary rect for a clean 16:9 client and as the fallback when colour
|
||||||
|
// detection cannot find the panel.
|
||||||
|
export function profileDetailRect(imageSize: { width: number; height: number }): LayoutRect {
|
||||||
|
const { width, height } = imageSize;
|
||||||
|
if (width <= 0 || height <= 0) return { x: 0, y: 0, width: Math.max(1, width), height: Math.max(1, height) };
|
||||||
|
return clampRect(
|
||||||
|
{
|
||||||
|
x: Math.round(width * 0.5),
|
||||||
|
y: Math.round(height * 0.08),
|
||||||
|
width: Math.round(width * 0.46),
|
||||||
|
height: Math.round(height * 0.74),
|
||||||
|
},
|
||||||
|
imageSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The four OCR crops inside the detail panel, as fractions of the detail rect.
|
||||||
|
export function detailCropRects(detailRect: LayoutRect, imageSize: { width: number; height: number }): CropTemplateRect[] {
|
||||||
|
const templates: CropTemplateRect[] = [
|
||||||
|
{
|
||||||
|
id: "artifact-title",
|
||||||
|
label: "Artifact title",
|
||||||
|
rect: {
|
||||||
|
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
||||||
|
y: Math.round(detailRect.y + detailRect.height * 0.05),
|
||||||
|
width: Math.round(detailRect.width * 0.82),
|
||||||
|
height: Math.round(detailRect.height * 0.16),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "artifact-main-stat",
|
||||||
|
label: "Main stat",
|
||||||
|
rect: {
|
||||||
|
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
||||||
|
y: Math.round(detailRect.y + detailRect.height * 0.2),
|
||||||
|
width: Math.round(detailRect.width * 0.82),
|
||||||
|
height: Math.round(detailRect.height * 0.18),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "artifact-substats",
|
||||||
|
label: "Substats",
|
||||||
|
rect: {
|
||||||
|
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
||||||
|
y: Math.round(detailRect.y + detailRect.height * 0.41),
|
||||||
|
width: Math.round(detailRect.width * 0.82),
|
||||||
|
height: Math.round(detailRect.height * 0.25),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "artifact-footer",
|
||||||
|
label: "Footer",
|
||||||
|
rect: {
|
||||||
|
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
||||||
|
y: Math.round(detailRect.y + detailRect.height * 0.78),
|
||||||
|
width: Math.round(detailRect.width * 0.82),
|
||||||
|
height: Math.round(detailRect.height * 0.16),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return templates.map((template) => ({ ...template, rect: clampRect(template.rect, imageSize) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inventoryCountCropRect(inventoryRect: LayoutRect, imageSize: { width: number; height: number }): LayoutRect {
|
||||||
|
return clampRect(
|
||||||
|
{
|
||||||
|
x: Math.round(inventoryRect.x + inventoryRect.width * 0.62),
|
||||||
|
y: Math.round(inventoryRect.y + inventoryRect.height * 0.02),
|
||||||
|
width: Math.round(inventoryRect.width * 0.34),
|
||||||
|
height: Math.round(inventoryRect.height * 0.09),
|
||||||
|
},
|
||||||
|
imageSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inventoryRect(imageSize: { width: number; height: number }, detailRect: LayoutRect): LayoutRect {
|
||||||
|
const { width, height } = imageSize;
|
||||||
|
const preferredWidth = Math.max(140, Math.round(width * 0.48));
|
||||||
|
const x = Math.round(width * 0.03);
|
||||||
|
const y = Math.round(detailRect.y + detailRect.height * 0.09);
|
||||||
|
const availableWidth = Math.max(100, detailRect.x - Math.round(width * 0.04));
|
||||||
|
const panelWidth = Math.max(100, Math.min(preferredWidth, availableWidth));
|
||||||
|
const safeWidth = panelWidth > width * 0.85 ? Math.round(width * 0.55) : panelWidth;
|
||||||
|
return clampRect(
|
||||||
|
{
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: Math.min(safeWidth, Math.max(width - x - Math.round(width * 0.02), 100)),
|
||||||
|
height: Math.max(140, Math.round(height * 0.7)),
|
||||||
|
},
|
||||||
|
imageSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inventoryGrid(imageSize: { width: number; height: number }, detailRect: LayoutRect): InventoryGridLayout {
|
||||||
|
const rect = inventoryRect(imageSize, detailRect);
|
||||||
|
const cols = 5;
|
||||||
|
if (rect.width < 160 || rect.height < 140) {
|
||||||
|
return { centers: [], rows: 0, cols: 0, confidence: 0, source: "missing" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cellWidth = Math.max(56, Math.round(rect.width / cols));
|
||||||
|
const stepX = Math.round(cellWidth * 0.96);
|
||||||
|
const stepY = Math.round(cellWidth * 1.03);
|
||||||
|
const visibleRows = Math.max(2, Math.min(6, Math.round(rect.height / Math.max(stepY, 1))));
|
||||||
|
|
||||||
|
const startX = rect.x + Math.max(6, Math.round(stepX * 0.45));
|
||||||
|
const startY = rect.y + Math.max(6, Math.round(stepY * 0.45));
|
||||||
|
const centers: InventoryGridLayout["centers"] = [];
|
||||||
|
for (let row = 0; row < visibleRows; row++) {
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
const x = startX + col * stepX;
|
||||||
|
const y = startY + row * stepY;
|
||||||
|
if (x < imageSize.width && y < imageSize.height) {
|
||||||
|
centers.push({ x, y, row, col });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = centers.filter((center) => center.x > 0 && center.y > 0);
|
||||||
|
return {
|
||||||
|
centers: trimmed,
|
||||||
|
rows: visibleRows,
|
||||||
|
cols,
|
||||||
|
confidence: trimmed.length >= cols * 2 ? 76 : trimmed.length >= cols ? 58 : 36,
|
||||||
|
source: "detected",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { binarizeForOcr, computeLuminanceHistogram, otsuThreshold, type Bitmap } from "./ocrPreprocess";
|
||||||
|
|
||||||
|
// Build a BGRA bitmap from a grid of [b,g,r] pixels.
|
||||||
|
function bitmapFrom(pixels: Array<[number, number, number]>, width: number, height: number): Bitmap {
|
||||||
|
const data = Buffer.alloc(width * height * 4);
|
||||||
|
pixels.forEach(([b, g, r], index) => {
|
||||||
|
data[index * 4] = b;
|
||||||
|
data[index * 4 + 1] = g;
|
||||||
|
data[index * 4 + 2] = r;
|
||||||
|
data[index * 4 + 3] = 255;
|
||||||
|
});
|
||||||
|
return { data, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ocrPreprocess", () => {
|
||||||
|
it("computes a luminance histogram over all pixels", () => {
|
||||||
|
const bitmap = bitmapFrom([
|
||||||
|
[0, 0, 0],
|
||||||
|
[255, 255, 255],
|
||||||
|
[0, 0, 0],
|
||||||
|
[255, 255, 255],
|
||||||
|
], 2, 2);
|
||||||
|
const histogram = computeLuminanceHistogram(bitmap);
|
||||||
|
expect(histogram[0]).toBe(2);
|
||||||
|
expect(histogram[255]).toBe(2);
|
||||||
|
expect(histogram.reduce((sum, count) => sum + count, 0)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("otsu splits a clean bimodal image between the two peaks", () => {
|
||||||
|
const histogram = new Array<number>(256).fill(0);
|
||||||
|
histogram[20] = 50;
|
||||||
|
histogram[220] = 50;
|
||||||
|
const threshold = otsuThreshold(histogram);
|
||||||
|
expect(threshold).toBeGreaterThanOrEqual(20);
|
||||||
|
expect(threshold).toBeLessThan(220);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("otsu is safe on an empty histogram", () => {
|
||||||
|
expect(otsuThreshold(new Array<number>(256).fill(0))).toBe(127);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inverts bright foreground to black-on-white by default", () => {
|
||||||
|
// Bright text pixel + dark background pixel.
|
||||||
|
const bitmap = bitmapFrom([
|
||||||
|
[255, 255, 255], // bright -> should become black
|
||||||
|
[0, 0, 0], // dark -> should become white
|
||||||
|
], 2, 1);
|
||||||
|
const out = binarizeForOcr(bitmap, { threshold: 128 });
|
||||||
|
expect([out.data[0], out.data[1], out.data[2]]).toEqual([0, 0, 0]);
|
||||||
|
expect([out.data[4], out.data[5], out.data[6]]).toEqual([255, 255, 255]);
|
||||||
|
expect(out.data[3]).toBe(255);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps bright foreground white when inversion is disabled", () => {
|
||||||
|
const bitmap = bitmapFrom([
|
||||||
|
[255, 255, 255],
|
||||||
|
[0, 0, 0],
|
||||||
|
], 2, 1);
|
||||||
|
const out = binarizeForOcr(bitmap, { threshold: 128, invertBrightForeground: false });
|
||||||
|
expect(out.data[0]).toBe(255);
|
||||||
|
expect(out.data[4]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves dimensions and always emits opaque pixels", () => {
|
||||||
|
const bitmap = bitmapFrom(Array.from({ length: 9 }, () => [100, 100, 100] as [number, number, number]), 3, 3);
|
||||||
|
const out = binarizeForOcr(bitmap);
|
||||||
|
expect(out.width).toBe(3);
|
||||||
|
expect(out.height).toBe(3);
|
||||||
|
for (let pixel = 0; pixel < 9; pixel++) {
|
||||||
|
expect(out.data[pixel * 4 + 3]).toBe(255);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// OCR preprocessing for artifact crops (ADR-009). Tesseract reads a clean, high
|
||||||
|
// contrast, dark-text-on-light image far more reliably than Genshin's native
|
||||||
|
// bright-text-on-dark UI. This binarizes a crop with Otsu thresholding and (by
|
||||||
|
// default) inverts, because artifact text is the bright foreground.
|
||||||
|
//
|
||||||
|
// Works on a raw BGRA bitmap (Electron NativeImage.getBitmap() layout on
|
||||||
|
// Windows). Kept pure and channel-order-agnostic for luminance so it is unit
|
||||||
|
// testable without Electron. Upscaling is done separately via NativeImage.resize
|
||||||
|
// before this runs - interpolated upscaling of small crops is a big Tesseract win
|
||||||
|
// and NativeImage does it better than hand-rolled JS.
|
||||||
|
|
||||||
|
export interface Bitmap {
|
||||||
|
data: Uint8Array | Buffer;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BinarizeOptions {
|
||||||
|
/** Artifact text is the bright foreground, so invert to dark-on-light. */
|
||||||
|
invertBrightForeground?: boolean;
|
||||||
|
/** Override Otsu with a fixed 0-255 luminance threshold. */
|
||||||
|
threshold?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BYTES_PER_PIXEL = 4;
|
||||||
|
|
||||||
|
// Rec. 601 luma. Channel order does not matter for a weighted sum as long as we
|
||||||
|
// read the same three bytes; BGRA and RGBA give the same luminance here because
|
||||||
|
// we weight by position-independent coefficients applied to the actual R/G/B.
|
||||||
|
function luminanceAt(data: Uint8Array | Buffer, index: number): number {
|
||||||
|
// NativeImage on Windows is BGRA: byte0=B, byte1=G, byte2=R.
|
||||||
|
const b = data[index];
|
||||||
|
const g = data[index + 1];
|
||||||
|
const r = data[index + 2];
|
||||||
|
return 0.299 * r + 0.587 * g + 0.114 * b;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeLuminanceHistogram(bitmap: Bitmap): number[] {
|
||||||
|
const histogram = new Array<number>(256).fill(0);
|
||||||
|
const { data, width, height } = bitmap;
|
||||||
|
const pixels = width * height;
|
||||||
|
for (let pixel = 0; pixel < pixels; pixel++) {
|
||||||
|
const value = Math.round(luminanceAt(data, pixel * BYTES_PER_PIXEL));
|
||||||
|
histogram[Math.max(0, Math.min(255, value))]++;
|
||||||
|
}
|
||||||
|
return histogram;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otsu's method: pick the threshold that maximizes between-class variance.
|
||||||
|
export function otsuThreshold(histogram: readonly number[]): number {
|
||||||
|
const total = histogram.reduce((sum, count) => sum + count, 0);
|
||||||
|
if (total === 0) return 127;
|
||||||
|
|
||||||
|
let sumAll = 0;
|
||||||
|
for (let level = 0; level < 256; level++) sumAll += level * histogram[level];
|
||||||
|
|
||||||
|
let sumBackground = 0;
|
||||||
|
let weightBackground = 0;
|
||||||
|
let maxVariance = -1;
|
||||||
|
let threshold = 127;
|
||||||
|
|
||||||
|
for (let level = 0; level < 256; level++) {
|
||||||
|
weightBackground += histogram[level];
|
||||||
|
if (weightBackground === 0) continue;
|
||||||
|
const weightForeground = total - weightBackground;
|
||||||
|
if (weightForeground === 0) break;
|
||||||
|
|
||||||
|
sumBackground += level * histogram[level];
|
||||||
|
const meanBackground = sumBackground / weightBackground;
|
||||||
|
const meanForeground = (sumAll - sumBackground) / weightForeground;
|
||||||
|
const betweenVariance = weightBackground * weightForeground * (meanBackground - meanForeground) ** 2;
|
||||||
|
|
||||||
|
if (betweenVariance > maxVariance) {
|
||||||
|
maxVariance = betweenVariance;
|
||||||
|
threshold = level;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function binarizeForOcr(bitmap: Bitmap, options: BinarizeOptions = {}): Bitmap {
|
||||||
|
const { data, width, height } = bitmap;
|
||||||
|
const invert = options.invertBrightForeground ?? true;
|
||||||
|
const threshold = options.threshold ?? otsuThreshold(computeLuminanceHistogram(bitmap));
|
||||||
|
|
||||||
|
const output = Buffer.alloc(width * height * BYTES_PER_PIXEL);
|
||||||
|
const pixels = width * height;
|
||||||
|
for (let pixel = 0; pixel < pixels; pixel++) {
|
||||||
|
const index = pixel * BYTES_PER_PIXEL;
|
||||||
|
const isBright = luminanceAt(data, index) > threshold;
|
||||||
|
// Bright foreground text -> black; dark background -> white (inverted).
|
||||||
|
const value = invert ? (isBright ? 0 : 255) : (isBright ? 255 : 0);
|
||||||
|
output[index] = value;
|
||||||
|
output[index + 1] = value;
|
||||||
|
output[index + 2] = value;
|
||||||
|
output[index + 3] = 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: output, width, height };
|
||||||
|
}
|
||||||
Vendored
+5
@@ -56,6 +56,11 @@ export interface CaptureResult {
|
|||||||
source: "ocr" | "missing";
|
source: "ocr" | "missing";
|
||||||
text: string;
|
text: string;
|
||||||
};
|
};
|
||||||
|
layout?: {
|
||||||
|
aspect: string;
|
||||||
|
isSixteenNine: boolean;
|
||||||
|
warning: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WindowBounds {
|
export interface WindowBounds {
|
||||||
|
|||||||
Reference in New Issue
Block a user