diff --git a/.gitignore b/.gitignore index d7215323d..473235b62 100644 --- a/.gitignore +++ b/.gitignore @@ -35,5 +35,5 @@ dump notepad.txt -# proprietary game fonts for the CV glyph-atlas builders (scripts/cv) +# proprietary game fonts for the scanner glyph-atlas builders (scripts/scanner) /assets/fonts/ diff --git a/AGENTS.md b/AGENTS.md index c9248738e..b248fb40c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,10 +98,10 @@ - use the template `/github/pull_request_template.md` - do not mention claude or claude code in the description -## CV feature (app/features/cv) +## Scanner feature (app/features/scanner) -- computer-vision match-event detection; full docs in `app/features/cv/README.md` — read it before touching detector/recognition code +- computer-vision match-event detection; full docs in `app/features/scanner/README.md` — read it before touching detector/recognition code - OpenCV ROI-view gotcha: `.data`/`.clone()` are broken on ROI views — always `view.copyTo(freshMat)` before pixel access -- fixture workflow: every live misread becomes a fixture under `app/features/cv/tests/fixtures/`; ground-truth labels are hand-corrected by the maintainer and definitive over any matcher output -- test with `pnpm test:cv`; accuracy report with `pnpm cv:report`; atlas regen commands and the assets-repo/CDN flow are in the README +- fixture workflow: every live misread becomes a fixture under `app/features/scanner/tests/fixtures/`; ground-truth labels are hand-corrected by the maintainer and definitive over any matcher output +- test with `pnpm test:scanner`; accuracy report with `pnpm scanner:report`; atlas regen commands and the assets-repo/CDN flow are in the README - events, snap tables, and fixtures speak sendou ids (`ModeShort`/`StageId`/weapon ids/`Ability`) — never reintroduce English game-name literals outside the generated localized snap tables diff --git a/app/features/cv/cv-search-params.ts b/app/features/cv/cv-search-params.ts deleted file mode 100644 index 7f3d2619c..000000000 --- a/app/features/cv/cv-search-params.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from "zod"; -import * as SearchParams from "~/modules/search-params/search-params"; -import { SP } from "~/modules/search-params/search-params"; - -export const CV_TABS = ["live", "screenshot", "vod"] as const; - -export type CvTab = (typeof CV_TABS)[number]; - -export const cvSearchParams = SearchParams.define({ - tab: SP.param(z.enum(CV_TABS), { default: "live", loader: false }), -}); diff --git a/app/features/ingest/core/Scoreboards.test.ts b/app/features/ingest/core/Scoreboards.test.ts index 665dccc6b..41928d637 100644 --- a/app/features/ingest/core/Scoreboards.test.ts +++ b/app/features/ingest/core/Scoreboards.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import type { CvAbility, CvLobby } from "~/features/cv/cv-types"; +import type { + ScannerAbility, + ScannerLobby, +} from "~/features/scanner/scanner-types"; import type { MainWeaponId, ModeShort, @@ -43,10 +46,10 @@ function testScoreboard({ t?: number; mode?: ModeShort | null; stage?: StageId | null; - lobby?: CvLobby | null; + lobby?: ScannerLobby | null; names?: string[]; weapons?: (MainWeaponId | null)[]; - abilities?: Record; + abilities?: Record; povIndex?: number | null; } = {}): IngestedEventInput { return { @@ -103,7 +106,7 @@ describe("matchedScoreboards", () => { }); it("carries ingested player abilities through to the stored scoreboard", () => { - const build: CvAbility[][] = [ + const build: ScannerAbility[][] = [ ["ISM", "ISS", "ISS", "ISS"], ["QR", "QSJ", "QSJ", "QSJ"], ["SSU", "RSU", "RSU", "RSU"], diff --git a/app/features/ingest/core/Scoreboards.ts b/app/features/ingest/core/Scoreboards.ts index 188e22dde..d2e36912e 100644 --- a/app/features/ingest/core/Scoreboards.ts +++ b/app/features/ingest/core/Scoreboards.ts @@ -1,4 +1,4 @@ -import type { CvAbility } from "~/features/cv/cv-types"; +import type { ScannerAbility } from "~/features/scanner/scanner-types"; import type { MainWeaponId, ModeShort, @@ -70,7 +70,7 @@ export interface IngestedScoreboardPlayer { s: number | null; paint: number | null; /** [head, clothes, shoes] ability rows gathered from the match's death screens */ - abilities?: CvAbility[][]; + abilities?: ScannerAbility[][]; /** set only via povIndex attribution */ userId?: number; } diff --git a/app/features/ingest/core/VodMatches.test.ts b/app/features/ingest/core/VodMatches.test.ts index 1fac3b66c..78b7d6a92 100644 --- a/app/features/ingest/core/VodMatches.test.ts +++ b/app/features/ingest/core/VodMatches.test.ts @@ -61,9 +61,9 @@ describe("prefillVodMatches", () => { expect(parsed.success).toBe(false); }); - it("accepts the `ingest` search param the CV VoD tab sends", () => { - // what the CV VoD tab's "Upload as VoD" button puts in the URL - // (~/features/cv/components/sendou-upload.ts): a { type?, matches } + it("accepts the `ingest` search param the scanner VoD tab sends", () => { + // what the scanner VoD tab's "Upload as VoD" button puts in the URL + // (~/features/scanner/components/sendou-upload.ts): a { type?, matches } // payload in the compressed `ingest` param const href = vodsNewSearchParams.href("/vods/new", { ingest: { type: "CAST", matches: [testMatch()] }, diff --git a/app/features/ingest/core/VodMatches.ts b/app/features/ingest/core/VodMatches.ts index ad760419e..eb1fac827 100644 --- a/app/features/ingest/core/VodMatches.ts +++ b/app/features/ingest/core/VodMatches.ts @@ -13,7 +13,7 @@ export interface PrefillVodMatch { } /** - * Turns the per-match rows a CV VoD scan sends into prefill data for the + * Turns the per-match rows a scanner VoD scan sends into prefill data for the * /vods/new form. The rows already carry sendou ids (validated by * ingestVodPrefillSchema); this only renames fields into the form's shape. */ diff --git a/app/features/ingest/ingest-schemas.ts b/app/features/ingest/ingest-schemas.ts index 3bb7c00f1..2877e7421 100644 --- a/app/features/ingest/ingest-schemas.ts +++ b/app/features/ingest/ingest-schemas.ts @@ -1,32 +1,32 @@ import { z } from "zod"; import { - cvAbilitySchema, - cvDeathDataSchema, - cvMapStartDataSchema, - cvScoreboardDataSchema, - cvScoreboardPlayerSchema, - cvScoreboardReplayDataSchema, -} from "~/features/cv/cv-schemas"; + scannerAbilitySchema, + scannerDeathDataSchema, + scannerMapStartDataSchema, + scannerScoreboardDataSchema, + scannerScoreboardPlayerSchema, + scannerScoreboardReplayDataSchema, +} from "~/features/scanner/scanner-schemas"; import { id } from "~/utils/zod"; const INGEST_MAX_EVENTS_PER_REQUEST = 1000; /** - * The event data shapes come from the producer (~/features/cv/cv-schemas — - * the single source of truth for the CV events domain); this module only + * The event data shapes come from the producer (~/features/scanner/scanner-schemas — + * the single source of truth for the scanner events domain); this module only * adds the ingest-specific envelope and enrichments. */ /** [head, clothes, shoes] ability rows gathered from the match's death screens */ -const scoreboardPlayerSchema = cvScoreboardPlayerSchema.extend({ - abilities: z.array(z.array(cvAbilitySchema)).optional(), +const scoreboardPlayerSchema = scannerScoreboardPlayerSchema.extend({ + abilities: z.array(z.array(scannerAbilitySchema)).optional(), }); -const scoreboardDataSchema = cvScoreboardDataSchema.extend({ +const scoreboardDataSchema = scannerScoreboardDataSchema.extend({ players: z.array(scoreboardPlayerSchema).length(8), }); -const scoreboardReplayDataSchema = cvScoreboardReplayDataSchema.extend({ +const scoreboardReplayDataSchema = scannerScoreboardReplayDataSchema.extend({ players: z.array(scoreboardPlayerSchema).length(8), }); @@ -54,11 +54,11 @@ const ingestedEventSchema = z.discriminatedUnion("type", [ }), eventBaseSchema.extend({ type: z.literal("Death"), - data: cvDeathDataSchema, + data: scannerDeathDataSchema, }), eventBaseSchema.extend({ type: z.literal("MapStart"), - data: cvMapStartDataSchema, + data: scannerMapStartDataSchema, }), ]); diff --git a/app/features/ingest/ingest-vod-schemas.ts b/app/features/ingest/ingest-vod-schemas.ts index 29605cf2d..d99fa147c 100644 --- a/app/features/ingest/ingest-vod-schemas.ts +++ b/app/features/ingest/ingest-vod-schemas.ts @@ -3,10 +3,10 @@ import { mainWeaponIdSchema, modeShortSchema, stageIdSchema, -} from "~/features/cv/cv-schemas"; +} from "~/features/scanner/scanner-schemas"; import { videoMatchTypes } from "~/features/vods/vods-constants"; -/** One detected match of a CV VoD scan (~/features/cv/core/vod-matches.ts). */ +/** One detected match of a scanner VoD scan (~/features/scanner/core/vod-matches.ts). */ const ingestVodMatchSchema = z.object({ /** whole seconds into the video the match starts at */ startsAt: z.number().int().min(0), @@ -25,7 +25,7 @@ const ingestVodMatchSchema = z.object({ }); /** - * The CV VoD tab's "Upload as VoD" button packs this into /vods/new's + * The scanner VoD tab's "Upload as VoD" button packs this into /vods/new's * `ingest` search param (an `SP.json` param, compressed by the search-params * module) to prefill the form: the detected match rows, minus the submission * fields (YouTube URL, title, date) the user fills in the form. `type` is diff --git a/app/features/cv/README.md b/app/features/scanner/README.md similarity index 79% rename from app/features/cv/README.md rename to app/features/scanner/README.md index 9e8f7be1d..220171dd2 100644 --- a/app/features/cv/README.md +++ b/app/features/scanner/README.md @@ -1,6 +1,6 @@ -# CV — Splatoon match-event detection +# Scanner — Splatoon match-event detection -Browser app (route `/cv`, dev-only until promoted) that watches OBS Virtual +Browser app (route `/scanner`, dev-only until promoted) that watches OBS Virtual Camera footage, VoD files, or screenshots, detects Splatoon 3 UI screens with OpenCV.js in a Web Worker, parses them into events speaking sendou.ink ids (`ModeShort`/`StageId`/weapon ids/`Ability`), records them to IndexedDB, and @@ -10,16 +10,16 @@ the emberz repo (kept read-only for archaeology); see `MIGRATION.md` there. ## Commands ```sh -pnpm test:cv # golden-file suite over tests/fixtures/ (Vitest, Node) -pnpm cv:report # accuracy table + name character error rate across fixtures -pnpm cv:fixtures [name-substring] # run detectors over matching fixtures, verbose -pnpm cv:bootstrap-atlas # harvest labeled fixture crops into the glyph atlases -pnpm cv:build-glyph-atlas # add the font-rendered charset (fonts required, see below) -pnpm cv:build-localized-entries # regen localized closed sets from ../splat3 -pnpm cv:build-planner-signatures # regen the minimap stage-ID atlas from the assets repo +pnpm test:scanner # golden-file suite over tests/fixtures/ (Vitest, Node) +pnpm scanner:report # accuracy table + name character error rate across fixtures +pnpm scanner:fixtures [name-substring] # run detectors over matching fixtures, verbose +pnpm scanner:bootstrap-atlas # harvest labeled fixture crops into the glyph atlases +pnpm scanner:build-glyph-atlas # add the font-rendered charset (fonts required, see below) +pnpm scanner:build-localized-entries # regen localized closed sets from ../splat3 +pnpm scanner:build-planner-signatures # regen the minimap stage-ID atlas from the assets repo ``` -The cv scripts run through `vite-node -c scripts/cv/vite-node.config.ts` — the +The scanner scripts run through `vite-node -c scripts/scanner/vite-node.config.ts` — the root vite config pre-bundles `@techstark/opencv-js` for the browser worker, and vite-node must not consume that prebundle (it crashes on `__dirname` in Node). The package itself is pnpm-patched (`patches/`): its CJS export is the @@ -39,13 +39,13 @@ video file → capture/vod-frames (WebCodecs decode, seek fallback) [VoD tab] ``` - `core/` is pure (mats in, events out) and must stay runnable in three - contexts: the worker, the `/cv` Screenshot tab, and Node tests. Keep + contexts: the worker, the `/scanner` Screenshot tab, and Node tests. Keep DOM/browser APIs out of it; Node-only helpers (image IO, fixture loading) live in `node/`. Importing pure data/type modules from `~/modules` and `~/features/build-analyzer/data` is fine — zod and the app config graph are - not (schemas live in `cv-schemas.ts`, consumed by `features/ingest`; + not (schemas live in `scanner-schemas.ts`, consumed by `features/ingest`; detectors only `import type` the shapes). -- The route (`routes/cv.tsx`) is SSR-guarded: everything below it assumes a +- The route (`routes/scanner.tsx`) is SSR-guarded: everything below it assumes a browser (worker, IndexedDB, WebCodecs, getUserMedia), so the client tree loads via `React.lazy` after `useHydrated`. Nothing from `core/worker/capture/store` may be imported at route-module top level. @@ -66,16 +66,16 @@ video file → capture/vod-frames (WebCodecs decode, seek fallback) [VoD tab] - New event types implement `Detector` (`core/detectors/types.ts`): a cheap `gate(mat)` at sample rate plus `parse(mat, t)` when the gate fires. Register in `core/detectors/registry.ts`. Event data shapes are pinned to - `cv-schemas.ts` by compile-time asserts — extend both together. + `scanner-schemas.ts` by compile-time asserts — extend both together. ## Assets (CDN) and fonts Weapon/ability/special/sub template sources are the site's shared game icons in the **sendou-ink/assets repo** under `assets/img/**` (`.avif`; ids -come from `~/modules/in-game-lists`, plus the CV-only `UNKNOWN` ability -badge — `toCvAbility` narrows template ids back to sendou ids). The -CV-specific sets — glyph atlases and the planner signature atlas — live in -this repo under `public/cv/v1/**` (override with `CV_ASSETS_DIR`; the +come from `~/modules/in-game-lists`, plus the scanner-only `UNKNOWN` ability +badge — `toScannerAbility` narrows template ids back to sendou ids). The +scanner-specific sets — glyph atlases and the planner signature atlas — live in +this repo under `public/scanner/v1/**` (override with `SCANNER_ASSETS_DIR`; the version segment bumps on breaking atlas-format changes). xxx: the atlases are in `public/` only while the feature is in development — move them to the assets repo (and the worker back to the CDN base) later: @@ -84,15 +84,15 @@ the assets repo (and the worker back to the CDN base) later: (the base URL rides the worker init message; the DO Space needs CORS — GET, sendou.ink + localhost origins — because the worker `fetch()`es cross-origin, plain `` consumers don't); atlases fetched same-origin - from `/cv/v1/**`. For local dev against fresh icon regens, serve the + from `/scanner/v1/**`. For local dev against fresh icon regens, serve the checkout with CORS — `npx serve /Users/kalle/Developer/assets/assets -l 9100 --cors` — and set `VITE_STATIC_ASSETS_URL=http://localhost:9100` in `.env`. -- Node (tests/scripts): atlases from `public/cv/v1`, icons from the +- Node (tests/scripts): atlases from `public/scanner/v1`, icons from the `../assets` checkout directly, never the CDN. AVIF icons decode through `sharp` (`node/image-io.ts`) — `@napi-rs/canvas` mis-decodes AVIF partial-alpha pixels. -- Atlas regens overwrite `public/cv/v1` in place and ship with the app +- Atlas regens overwrite `public/scanner/v1` in place and ship with the app build; breaking format changes bump `v1`. Fonts are proprietary and gitignored: `BlitzMain.otf`, `BlitzBold.otf`, @@ -101,10 +101,10 @@ root; from the splatoon3-fonts repo). Atlas builders fail loudly without them. Names and row digits use BlitzMain; team totals use BlitzBold; the replay code line and VICTORY/DEFEAT tags use FOT-RowdyStd-EB; the JP death message mixes condensed Kurokane and Rowdy (`death-weapon-ja`). Regeneration -order: `cv:bootstrap-atlas` (fixture crops win via tie-break) → -`cv:build-glyph-atlas`; localized sets via `cv:build-localized-entries` +order: `scanner:bootstrap-atlas` (fixture crops win via tie-break) → +`scanner:build-glyph-atlas`; localized sets via `scanner:build-localized-entries` (expects a splat3 checkout at `../splat3`) then the atlas rebuild; planner -atlas via `cv:build-planner-signatures` (reads the assets repo's +atlas via `scanner:build-planner-signatures` (reads the assets repo's `assets/planner-maps/`, MINI variant). ## Fixtures are the workflow diff --git a/app/features/cv/capture/sampler.ts b/app/features/scanner/capture/sampler.ts similarity index 100% rename from app/features/cv/capture/sampler.ts rename to app/features/scanner/capture/sampler.ts diff --git a/app/features/cv/capture/vod-frames.ts b/app/features/scanner/capture/vod-frames.ts similarity index 100% rename from app/features/cv/capture/vod-frames.ts rename to app/features/scanner/capture/vod-frames.ts diff --git a/app/features/cv/components/AbilityGrid.tsx b/app/features/scanner/components/AbilityGrid.tsx similarity index 86% rename from app/features/cv/components/AbilityGrid.tsx rename to app/features/scanner/components/AbilityGrid.tsx index b68f8e7b2..ba859efd9 100644 --- a/app/features/cv/components/AbilityGrid.tsx +++ b/app/features/scanner/components/AbilityGrid.tsx @@ -5,11 +5,11 @@ import { useState } from "react"; import { Ability } from "~/components/Ability"; -import type { CvAbility } from "../cv-types"; +import type { ScannerAbility } from "../scanner-types"; const ROW_LABELS = ["head", "clothes", "shoes"] as const; -export function AbilityGrid({ abilities }: { abilities: CvAbility[][] }) { +export function AbilityGrid({ abilities }: { abilities: ScannerAbility[][] }) { return ( @@ -32,7 +32,11 @@ export function AbilityGrid({ abilities }: { abilities: CvAbility[][] }) { * Click-to-toggle popover showing a player's ability grid; the trigger is * the head-main ability icon. Closes when the pointer leaves it. */ -export function AbilityPopover({ abilities }: { abilities: CvAbility[][] }) { +export function AbilityPopover({ + abilities, +}: { + abilities: ScannerAbility[][]; +}) { const [open, setOpen] = useState(false); const trigger = abilities[0]?.[0]; if (!trigger) return null; diff --git a/app/features/cv/components/App.tsx b/app/features/scanner/components/App.tsx similarity index 71% rename from app/features/cv/components/App.tsx rename to app/features/scanner/components/App.tsx index 9b4d03ff0..01126b1ae 100644 --- a/app/features/cv/components/App.tsx +++ b/app/features/scanner/components/App.tsx @@ -1,22 +1,26 @@ import { Link } from "react-router"; import { useUser } from "~/features/auth/core/user"; import { useSearchParam } from "~/modules/search-params/hooks"; -import { CV_PAGE } from "~/utils/urls"; -import { CV_TABS, type CvTab, cvSearchParams } from "../cv-search-params"; +import { SCANNER_PAGE } from "~/utils/urls"; +import { + SCANNER_TABS, + type ScannerTab, + scannerSearchParams, +} from "../scanner-search-params"; import { LivePage } from "./LivePage"; import { ScreenshotPage } from "./ScreenshotPage"; import type { SendouUser } from "./sendou-ingest"; import { VodPage } from "./VodPage"; import "./styles.css"; -const TAB_LABELS: Record = { +const TAB_LABELS: Record = { live: "Live", screenshot: "Screenshot", vod: "VoD", }; export function App() { - const [tab] = useSearchParam(cvSearchParams, "tab"); + const [tab] = useSearchParam(scannerSearchParams, "tab"); const rootUser = useUser(); const sendouUser: SendouUser | null = rootUser ? { id: rootUser.id, username: rootUser.username } @@ -32,13 +36,13 @@ export function App() { ); return ( -
+