mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 23:58:36 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
18
apps/web-react/app/features/scanner/node/assets-dir.ts
Normal file
18
apps/web-react/app/features/scanner/node/assets-dir.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Where Node-side code (tests, atlas builders) reads and writes the scanner
|
||||
* asset sets. SCANNER_ASSETS_DIR holds the scanner-specific atlases (glyphs, planner
|
||||
* signatures; overridable with the env var of the same name) — its version
|
||||
* segment must match the worker-side ATLAS_BASE (worker/resources.ts).
|
||||
* GAME_IMG_DIR is the sibling sendou-ink/assets checkout's shared `img/**`
|
||||
* tree the game icons are read from — the same files the CDN mirrors.
|
||||
* xxx: atlases temporarily live in this repo's public/ while the feature
|
||||
* is in development; move them to the assets repo later
|
||||
*/
|
||||
export const SCANNER_ASSETS_DIR =
|
||||
process.env.SCANNER_ASSETS_DIR ??
|
||||
new URL("../../../../public/scanner/v1", import.meta.url).pathname;
|
||||
|
||||
export const GAME_IMG_DIR = new URL(
|
||||
"../../../../../assets/assets/img",
|
||||
import.meta.url,
|
||||
).pathname;
|
||||
196
apps/web-react/app/features/scanner/node/fixtures.ts
Normal file
196
apps/web-react/app/features/scanner/node/fixtures.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Fixture discovery and detector execution for tests and tools.
|
||||
*
|
||||
* A fixture is a directory under app/features/scanner/tests/fixtures/<detector>/<case-name>/ containing
|
||||
* frame.png or frame.jpg (raw capture, any resolution — normalization happens
|
||||
* inside the pipeline under test) and expected.json.
|
||||
*/
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
AbilityWithUnknown,
|
||||
MainWeaponId,
|
||||
ModeShort,
|
||||
StageId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import type { ScoreboardData } from "../core/detectors/scoreboard/index";
|
||||
import type {
|
||||
DetectedEvent,
|
||||
Detector,
|
||||
GateResult,
|
||||
} from "../core/detectors/types";
|
||||
import { normalizeFrame, toMat } from "../core/image";
|
||||
import type { ScannerLobby } from "../scanner-types";
|
||||
import { readImage } from "./image-io";
|
||||
|
||||
export const FIXTURES_DIR = new URL("../tests/fixtures", import.meta.url)
|
||||
.pathname;
|
||||
|
||||
interface ExpectedPlayer {
|
||||
name?: string;
|
||||
weaponId?: MainWeaponId | null;
|
||||
paint?: number;
|
||||
ka?: number;
|
||||
d?: number;
|
||||
s?: number;
|
||||
}
|
||||
|
||||
interface ExpectedMinimapTeammate {
|
||||
slot?: "up" | "left" | "right" | "self" | "down";
|
||||
name?: string | null;
|
||||
/** informational for the human corrector; tests compare weaponId */
|
||||
weaponLabel?: string | null;
|
||||
weaponId?: MainWeaponId | null;
|
||||
abilities?: (AbilityWithUnknown | null)[];
|
||||
/** struck through with the respawn cross-out */
|
||||
dead?: boolean;
|
||||
/** on the light camo surface of a charged special */
|
||||
specialReady?: boolean;
|
||||
}
|
||||
|
||||
interface ExpectedMinimapEnemy {
|
||||
/** spectator frames only: the screen shows bravo-team names */
|
||||
name?: string | null;
|
||||
/** informational for the human corrector; tests compare weaponId */
|
||||
weaponLabel?: string | null;
|
||||
weaponId?: MainWeaponId | null;
|
||||
abilities?: (AbilityWithUnknown | null)[];
|
||||
/** struck through with the respawn cross-out */
|
||||
dead?: boolean;
|
||||
/** on the light camo surface of a charged special */
|
||||
specialReady?: boolean;
|
||||
}
|
||||
|
||||
interface ExpectedScoreboard {
|
||||
event:
|
||||
| "Scoreboard"
|
||||
| "ScoreboardBattleLogReplay"
|
||||
| "ScoreboardBattleLog"
|
||||
| "ScoreboardOwn"
|
||||
| "Death"
|
||||
| "MapStart"
|
||||
| "Minimap"
|
||||
| "Objective"
|
||||
| "PlayerStatus"
|
||||
| "StripWeapons"
|
||||
| "none";
|
||||
data?: {
|
||||
lobby?: ScannerLobby;
|
||||
mode?: ModeShort;
|
||||
stage?: StageId;
|
||||
/** informational for the human corrector; tests compare `stage` */
|
||||
stageLabel?: string;
|
||||
/** ScoreboardBattleLogReplay + ScoreboardBattleLog only */
|
||||
timestamp?: string;
|
||||
/** ScoreboardBattleLogReplay only */
|
||||
replayCode?: string;
|
||||
/** the "Score:" banner game scores, [winner, loser]; KO winner = 100 */
|
||||
matchScores?: [number | null, number | null];
|
||||
players?: ExpectedPlayer[];
|
||||
/** index of the yellow POV-arrow row in `players`; null = no arrow */
|
||||
povIndex?: number | null;
|
||||
/**
|
||||
* Death: killer's weapon id and kind (ids are unique per kind).
|
||||
* ScoreboardOwn: the player's own main weapon (weaponType unused).
|
||||
* weaponLabel is informational for the human corrector.
|
||||
*/
|
||||
weaponLabel?: string;
|
||||
weaponId?: number | null;
|
||||
weaponType?: "MAIN" | "SUB" | "SPECIAL";
|
||||
/** Death + ScoreboardOwn: 3 gear rows of [main, sub, sub, sub] ability ids */
|
||||
abilities?: AbilityWithUnknown[][];
|
||||
/** Death only: killer's splash-tag name */
|
||||
name?: string;
|
||||
/** Objective only: match-timer seconds ("3:35" = 215); null = unreadable */
|
||||
time?: number | null;
|
||||
/** Objective only: displayed counter per team, [alpha, bravo] */
|
||||
score?: [number | null, number | null];
|
||||
/** Objective only: penalty pill value per team; null = no pill */
|
||||
penalty?: [number | null, number | null];
|
||||
/** Objective only: which team currently holds the objective */
|
||||
control?: [boolean, boolean];
|
||||
/** PlayerStatus only: special held per slot, [left team, right team] */
|
||||
special?: [boolean[], boolean[]];
|
||||
/** PlayerStatus only: splatted per slot, [left team, right team] */
|
||||
dead?: [boolean[], boolean[]];
|
||||
/** PlayerStatus + StripWeapons: which icon-strip geometry the frame shows */
|
||||
layout?: "pov" | "cast" | "cast-mirror";
|
||||
/**
|
||||
* StripWeapons only: the true weapon per slot, [left team, right
|
||||
* team], null = slot skipped (splatted icon). weaponLabels is
|
||||
* informational for the human corrector.
|
||||
*/
|
||||
weapons?: [(MainWeaponId | null)[], (MainWeaponId | null)[]];
|
||||
weaponLabels?: [(string | null)[], (string | null)[]];
|
||||
/** Minimap only: casted 8-player spectator map screen (not parsed yet) */
|
||||
spectator?: boolean;
|
||||
/** Minimap only: own-team callout cards in slot order */
|
||||
teammates?: ExpectedMinimapTeammate[];
|
||||
/** Minimap only: enemy panel rows, top to bottom */
|
||||
enemies?: ExpectedMinimapEnemy[];
|
||||
};
|
||||
options?: {
|
||||
/** glob-ish field paths to skip, e.g. "players.*.name", "scores" */
|
||||
skipFields?: string[];
|
||||
/** free-form context for humans (why fields are skipped, capture quirks) */
|
||||
notes?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Fixture {
|
||||
name: string;
|
||||
dir: string;
|
||||
framePath: string;
|
||||
expected: ExpectedScoreboard;
|
||||
}
|
||||
|
||||
export function loadFixtures(detector: string): Fixture[] {
|
||||
const root = join(FIXTURES_DIR, detector);
|
||||
if (!existsSync(root)) return [];
|
||||
return readdirSync(root, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => {
|
||||
const dir = join(root, e.name);
|
||||
const framePath = ["frame.png", "frame.jpg", "frame.jpeg"]
|
||||
.map((f) => join(dir, f))
|
||||
.find(existsSync);
|
||||
if (!framePath) throw new Error(`fixture ${e.name}: no frame.png/jpg`);
|
||||
const expected = JSON.parse(
|
||||
readFileSync(join(dir, "expected.json"), "utf8"),
|
||||
) as ExpectedScoreboard;
|
||||
return { name: e.name, dir, framePath, expected };
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export function isFieldSkipped(fixture: Fixture, field: string): boolean {
|
||||
const skips = fixture.expected.options?.skipFields ?? [];
|
||||
return skips.some((pattern) => {
|
||||
const re = new RegExp(
|
||||
`^${pattern
|
||||
.split("*")
|
||||
.map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join("[^.]*")}$`,
|
||||
);
|
||||
return re.test(field);
|
||||
});
|
||||
}
|
||||
|
||||
export interface FixtureRun<TData = ScoreboardData> {
|
||||
gate: GateResult;
|
||||
events: DetectedEvent<TData>[];
|
||||
}
|
||||
|
||||
/** Run gate+parse the way the live pipeline would. Caller must have loaded OpenCV. */
|
||||
export async function runDetectorOnFixture<TData = ScoreboardData>(
|
||||
detector: Detector<TData>,
|
||||
fixture: Fixture,
|
||||
): Promise<FixtureRun<TData>> {
|
||||
const src = toMat(await readImage(fixture.framePath));
|
||||
const frame = normalizeFrame(src);
|
||||
src.delete();
|
||||
const gate = detector.gate(frame);
|
||||
const events = gate.pass ? detector.parse(frame, 0, gate) : [];
|
||||
frame.delete();
|
||||
return { gate, events };
|
||||
}
|
||||
43
apps/web-react/app/features/scanner/node/image-io.ts
Normal file
43
apps/web-react/app/features/scanner/node/image-io.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Node-only image decode/encode (@napi-rs/canvas). Never imported from src/core.
|
||||
*
|
||||
* Decoding goes through a canvas, whose backing store is alpha-premultiplied;
|
||||
* RGB at partial-alpha pixels can shift by ±1. Everything we read is either
|
||||
* fully opaque (frames, atlases) or consumed premultiplied anyway (weapon
|
||||
* icons are composited over a background), so this is lossless in practice.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { createCanvas, Image, ImageData } from "@napi-rs/canvas";
|
||||
import sharp from "sharp";
|
||||
import type { FrameData } from "../core/image";
|
||||
|
||||
export async function readImage(path: string): Promise<FrameData> {
|
||||
// @napi-rs/canvas mis-decodes AVIF at partial-alpha pixels (white-matte
|
||||
// leak); sharp returns clean straight-alpha RGBA for those
|
||||
if (path.endsWith(".avif")) {
|
||||
const { data, info } = await sharp(path)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
return {
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
data: new Uint8ClampedArray(data),
|
||||
};
|
||||
}
|
||||
const img = new Image();
|
||||
img.src = readFileSync(path);
|
||||
await img.decode();
|
||||
const canvas = createCanvas(img.width, img.height);
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const { width, height, data } = ctx.getImageData(0, 0, img.width, img.height);
|
||||
return { width, height, data };
|
||||
}
|
||||
|
||||
export function writePng(path: string, frame: FrameData): void {
|
||||
const canvas = createCanvas(frame.width, frame.height);
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.putImageData(new ImageData(frame.data, frame.width, frame.height), 0, 0);
|
||||
writeFileSync(path, canvas.toBuffer("image/png"));
|
||||
}
|
||||
51
apps/web-react/app/features/scanner/node/resources.ts
Normal file
51
apps/web-react/app/features/scanner/node/resources.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Node IO for ScoreboardResources: reads the scanner asset sets from the local
|
||||
* sendou-ink/assets checkout (tests and atlas builders never touch the
|
||||
* CDN). Game icons come from the checkout's shared `img/**` tree, the
|
||||
* scanner-specific atlases from `scanner/v1/**`. What the bundle contains — every
|
||||
* key, template option set, and atlas name — lives in core/resources.ts,
|
||||
* shared with the worker's HTTP loader.
|
||||
*/
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
loadPlannerStages,
|
||||
type PlannerManifest,
|
||||
type PlannerStage,
|
||||
} from "../core/detectors/minimap/stage";
|
||||
import type { ScoreboardResources } from "../core/detectors/scoreboard/index";
|
||||
import { type AtlasMeta, type GlyphSet, loadGlyphSet } from "../core/glyphs";
|
||||
import { assembleScoreboardResources } from "../core/resources";
|
||||
import { SCANNER_ASSETS_DIR as ASSETS_DIR, GAME_IMG_DIR } from "./assets-dir";
|
||||
import { readImage } from "./image-io";
|
||||
|
||||
/** Decode eagerly, defer the (CPU-heavy) glyph slicing to first access. */
|
||||
async function loadAtlasLazy(name: string): Promise<() => GlyphSet | null> {
|
||||
const png = join(ASSETS_DIR, "glyphs", `${name}.png`);
|
||||
const json = join(ASSETS_DIR, "glyphs", `${name}.json`);
|
||||
if (!existsSync(png) || !existsSync(json)) return () => null;
|
||||
const meta = JSON.parse(readFileSync(json, "utf8")) as AtlasMeta;
|
||||
const image = await readImage(png);
|
||||
let set: GlyphSet | null = null;
|
||||
return () => (set ??= loadGlyphSet(image, meta));
|
||||
}
|
||||
|
||||
/** Planner stage signatures; the (CPU) tile slicing runs on first access. */
|
||||
async function loadPlannerStagesLazy(): Promise<() => PlannerStage[] | null> {
|
||||
const png = join(ASSETS_DIR, "planner", "signatures.png");
|
||||
const json = join(ASSETS_DIR, "planner", "manifest.json");
|
||||
if (!existsSync(png) || !existsSync(json)) return () => null;
|
||||
const manifest = JSON.parse(readFileSync(json, "utf8")) as PlannerManifest;
|
||||
const atlas = await readImage(png);
|
||||
let stages: PlannerStage[] | null = null;
|
||||
return () => (stages ??= loadPlannerStages(atlas, manifest));
|
||||
}
|
||||
|
||||
/** Requires loadOpenCV() to have resolved. */
|
||||
export function loadScoreboardResources(): Promise<ScoreboardResources> {
|
||||
return assembleScoreboardResources({
|
||||
readIcon: (dir, id) => readImage(join(GAME_IMG_DIR, dir, `${id}.avif`)),
|
||||
loadAtlas: loadAtlasLazy,
|
||||
loadPlannerStages: loadPlannerStagesLazy,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user