Rename cv -> scanner

This commit is contained in:
Kalle
2026-08-05 11:15:32 +03:00
parent 7195e7c588
commit d55712c68a
244 changed files with 383 additions and 362 deletions

2
.gitignore vendored
View File

@@ -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/

View File

@@ -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

View File

@@ -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 }),
});

View File

@@ -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<number, CvAbility[][]>;
abilities?: Record<number, ScannerAbility[][]>;
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"],

View File

@@ -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;
}

View File

@@ -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()] },

View File

@@ -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.
*/

View File

@@ -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,
}),
]);

View File

@@ -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

View File

@@ -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 `<img>` 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

View File

@@ -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 (
<table className="players">
<tbody>
@@ -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;

View File

@@ -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<CvTab, string> = {
const TAB_LABELS: Record<ScannerTab, string> = {
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 (
<div className="cv-app">
<div className="scanner-app">
<header className="topbar">
<nav>
{CV_TABS.map((tabOption) => (
{SCANNER_TABS.map((tabOption) => (
<Link
key={tabOption}
to={cvSearchParams.href(CV_PAGE, { tab: tabOption })}
to={scannerSearchParams.href(SCANNER_PAGE, { tab: tabOption })}
className={tab === tabOption ? "active" : ""}
>
{TAB_LABELS[tabOption]}

View File

@@ -25,7 +25,7 @@ import {
SCOREBOARD_OWN_EVENT_TYPE,
type ScoreboardOwnData,
} from "../core/detectors/scoreboard-own/index";
import { cvSearchParams } from "../cv-search-params";
import { scannerSearchParams } from "../scanner-search-params";
import type { SendStatus } from "../store/events";
import { DeathCard } from "./DeathCard";
import type { FixtureData } from "./fixture-export";
@@ -54,7 +54,7 @@ export function EventCard(props: {
onSend?: () => void;
}) {
const { type, t, confidence, data, thumbnail, detectedAt, getFrame } = props;
const [, setTab] = useSearchParam(cvSearchParams, "tab");
const [, setTab] = useSearchParam(scannerSearchParams, "tab");
const onInspect = getFrame
? () =>
void getFrame().then((frame) => {

View File

@@ -5,12 +5,12 @@ import type {
MinimapEnemy,
MinimapTeammate,
} from "../core/detectors/minimap/index";
import type { CvAbility } from "../cv-types";
import type { ScannerAbility } from "../scanner-types";
import { saveFixtureFromEvent } from "./fixture-export";
import { formatTime } from "./format";
import { stageLabel } from "./labels";
function AbilityRow({ abilities }: { abilities: (CvAbility | null)[] }) {
function AbilityRow({ abilities }: { abilities: (ScannerAbility | null)[] }) {
return (
<>
{abilities.map((id, i) =>

View File

@@ -1,6 +1,6 @@
/**
* English display labels for the ids CV events carry. UI-only: events and
* detectors speak sendou ids (§ cv-types.ts); these helpers turn them back
* English display labels for the ids scanner events carry. UI-only: events and
* detectors speak sendou ids (§ scanner-types.ts); these helpers turn them back
* into human-readable names for cards, CSV export, and the fixture
* exporter's informational *Label fields.
*/
@@ -17,7 +17,7 @@ import {
ALL_WEAPON_ENTRIES,
type WeaponType,
} from "../core/detectors/death/weapon-names";
import type { CvLobby } from "../cv-types";
import type { ScannerLobby } from "../scanner-types";
const misc = gameMisc as Record<string, string>;
@@ -47,13 +47,13 @@ export function modeLabel(mode: ModeShort | null): string | null {
return mode === null ? null : (misc[`MODE_LONG_${mode}`] ?? mode);
}
const LOBBY_LABELS: Record<CvLobby, string> = {
const LOBBY_LABELS: Record<ScannerLobby, string> = {
X: "X Battle",
SERIES: "Anarchy Battle (Series)",
OPEN: "Anarchy Battle (Open)",
PRIVATE: "Private Battle",
};
export function lobbyLabel(lobby: CvLobby | null): string | null {
export function lobbyLabel(lobby: ScannerLobby | null): string | null {
return lobby === null ? null : LOBBY_LABELS[lobby];
}

View File

@@ -1,5 +1,5 @@
/**
* Browser client for sendou.ink's /ingest. The CV pages run inside
* Browser client for sendou.ink's /ingest. The scanner pages run inside
* sendou.ink itself, so requests are same-origin: the session cookie rides
* along automatically and the logged-in user comes from the root loader
* (useUser) instead of an identity probe. sendou.ink authenticates the

View File

@@ -1,9 +1,9 @@
/*
CV feature styles, ported from emberz. Scoped under .cv-app so nothing
Scanner feature styles, ported from emberz. Scoped under .scanner-app so nothing
leaks into the host app; design tokens come from sendou.ink's own
app/styles/vars.css (the emberz copies of those tokens were dropped).
*/
.cv-app .topbar {
.scanner-app .topbar {
display: flex;
align-items: center;
gap: 24px;
@@ -12,12 +12,12 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
margin-bottom: 16px;
}
.cv-app .topbar nav {
.scanner-app .topbar nav {
display: flex;
gap: 8px;
}
.cv-app .topbar nav a {
.scanner-app .topbar nav a {
color: var(--color-text-high);
font-size: var(--font-xs);
font-weight: var(--weight-bold);
@@ -26,21 +26,21 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
border-radius: var(--radius-field);
}
.cv-app .topbar nav a:hover {
.scanner-app .topbar nav a:hover {
color: var(--color-text);
}
.cv-app .topbar nav a:focus-visible {
.scanner-app .topbar nav a:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
.cv-app .topbar nav a.active {
.scanner-app .topbar nav a.active {
color: var(--color-text-accent);
background: var(--color-bg-high);
}
.cv-app button {
.scanner-app button {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -60,23 +60,23 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
outline-color: var(--color-text-accent);
}
.cv-app button:focus-visible {
.scanner-app button:focus-visible {
outline-style: solid;
outline-width: 2px;
outline-offset: 1px;
}
.cv-app button:active {
.scanner-app button:active {
transform: translateY(1px);
}
.cv-app button:disabled {
.scanner-app button:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: initial;
}
.cv-app select {
.scanner-app select {
appearance: none;
border: var(--border-style);
border-radius: var(--radius-field);
@@ -97,17 +97,17 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
outline: none;
}
.cv-app select:focus-within {
.scanner-app select:focus-within {
outline: var(--focus-ring);
outline-offset: 1px;
}
.cv-app select:disabled {
.scanner-app select:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.cv-app .controls {
.scanner-app .controls {
display: flex;
gap: 8px;
align-items: center;
@@ -115,7 +115,7 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
flex-wrap: wrap;
}
.cv-app .status {
.scanner-app .status {
padding: 2px 12px;
border-radius: var(--radius-full);
font-size: var(--font-2xs);
@@ -123,50 +123,50 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
border: var(--border-width) solid var(--color-border);
}
.cv-app .status.idle {
.scanner-app .status.idle {
color: var(--color-text-high);
}
.cv-app .status.watching {
.scanner-app .status.watching {
color: var(--color-info-high);
border-color: var(--color-info-low);
background: var(--color-info-low);
}
.cv-app .status.detected {
.scanner-app .status.detected {
color: var(--color-success-high);
border-color: var(--color-success-low);
background: var(--color-success-low);
}
.cv-app .live-layout {
.scanner-app .live-layout {
display: grid;
grid-template-columns: minmax(320px, 640px) 1fr;
gap: 16px;
align-items: start;
}
.cv-app video.preview,
.cv-app canvas.preview {
.scanner-app video.preview,
.scanner-app canvas.preview {
width: 100%;
background: #000;
border-radius: var(--radius-box);
border: var(--border-width) solid var(--color-bg-high);
}
.cv-app .feed {
.scanner-app .feed {
display: flex;
flex-direction: column;
gap: 12px;
}
.cv-app .card {
.scanner-app .card {
background: var(--color-bg-high);
border-radius: var(--radius-box);
padding: 16px;
}
.cv-app .card .meta {
.scanner-app .card .meta {
display: flex;
gap: 12px;
color: var(--color-text-high);
@@ -176,25 +176,25 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
align-items: center;
}
.cv-app .card img.thumb {
.scanner-app .card img.thumb {
width: 160px;
border-radius: var(--radius-field);
}
.cv-app .teams {
.scanner-app .teams {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
/* sendou.ink send status strip under a feed card */
.cv-app .send-wrap {
.scanner-app .send-wrap {
display: flex;
flex-direction: column;
gap: 2px;
}
.cv-app .send-strip {
.scanner-app .send-strip {
display: flex;
gap: 12px;
align-items: center;
@@ -206,43 +206,43 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
color: var(--color-text-high);
}
.cv-app .send-strip.sent {
.scanner-app .send-strip.sent {
color: var(--color-success-high);
background: var(--color-success-low);
}
.cv-app .send-strip.failed {
.scanner-app .send-strip.failed {
color: var(--color-error-high);
background: var(--color-error-low);
}
.cv-app .send-strip.queued,
.cv-app .send-strip.sending {
.scanner-app .send-strip.queued,
.scanner-app .send-strip.sending {
color: var(--color-info-high);
background: var(--color-info-low);
}
.cv-app .score {
.scanner-app .score {
color: var(--color-text-high);
font-size: var(--font-2xs);
}
.cv-app .error {
.scanner-app .error {
color: var(--color-error);
}
.cv-app .send-strip .error {
.scanner-app .send-strip .error {
color: inherit;
font-weight: var(--weight-body);
}
.cv-app .team {
.scanner-app .team {
border-radius: var(--radius-field);
padding: 8px;
background: var(--color-bg);
}
.cv-app .team h3 {
.scanner-app .team h3 {
margin: 0 0 6px;
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
@@ -250,31 +250,31 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
letter-spacing: 0.08em;
}
.cv-app .team.win h3 {
.scanner-app .team.win h3 {
color: var(--color-error-high);
}
.cv-app .team.lose h3 {
.scanner-app .team.lose h3 {
color: var(--color-info-high);
}
.cv-app table.players {
.scanner-app table.players {
width: 100%;
border-collapse: collapse;
font-size: var(--font-xs);
}
.cv-app table.players td {
.scanner-app table.players td {
padding: 2px 6px;
white-space: nowrap;
}
.cv-app table.players td.num {
.scanner-app table.players td.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.cv-app img.weapon-icon {
.scanner-app img.weapon-icon {
width: 28px;
height: 28px;
vertical-align: middle;
@@ -282,18 +282,18 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
border-radius: 4px;
}
.cv-app .weapon-cell {
.scanner-app .weapon-cell {
display: inline-flex;
align-items: center;
gap: 4px;
}
.cv-app .ability-popover {
.scanner-app .ability-popover {
position: relative;
display: inline-flex;
}
.cv-app button.ability-trigger {
.scanner-app button.ability-trigger {
height: auto;
padding: 2px;
background: var(--color-bg-higher);
@@ -301,13 +301,13 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
border-radius: 4px;
}
.cv-app button.ability-trigger img {
.scanner-app button.ability-trigger img {
display: block;
width: 18px;
height: 18px;
}
.cv-app .ability-popover .popover {
.scanner-app .ability-popover .popover {
position: absolute;
top: calc(100% + 4px);
left: 0;
@@ -319,13 +319,13 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
box-shadow: 0 8px 24px rgb(0 0 0 / 0.5);
}
.cv-app .vod-list {
.scanner-app .vod-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.cv-app .vod-item {
.scanner-app .vod-item {
display: flex;
align-items: center;
gap: 12px;
@@ -334,7 +334,7 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
padding: 12px 16px;
}
.cv-app .vod-item .name {
.scanner-app .vod-item .name {
font-size: var(--font-sm);
font-weight: var(--weight-bold);
overflow: hidden;
@@ -342,11 +342,11 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
white-space: nowrap;
}
.cv-app .vod-item .score {
.scanner-app .vod-item .score {
flex: 1;
}
.cv-app .dropzone {
.scanner-app .dropzone {
border: var(--border-width) dashed var(--color-border-high);
border-radius: var(--radius-box);
padding: 32px;
@@ -357,29 +357,29 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
margin-bottom: 16px;
}
.cv-app .dropzone.over {
.scanner-app .dropzone.over {
border-color: var(--color-text-accent);
color: var(--color-text-accent);
}
.cv-app .screenshot-frame {
.scanner-app .screenshot-frame {
position: relative;
margin-bottom: 16px;
}
.cv-app .screenshot-frame canvas {
.scanner-app .screenshot-frame canvas {
width: 100%;
border-radius: var(--radius-box);
border: var(--border-width) solid var(--color-bg-high);
}
.cv-app table.inspector {
.scanner-app table.inspector {
width: 100%;
border-collapse: collapse;
font-size: var(--font-xs);
}
.cv-app table.inspector th {
.scanner-app table.inspector th {
text-align: left;
color: var(--color-text-high);
font-size: var(--font-2xs);
@@ -388,25 +388,25 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
border-bottom: var(--border-width) solid var(--color-border);
}
.cv-app table.inspector td {
.scanner-app table.inspector td {
padding: 6px;
border-bottom: 1px solid var(--color-bg-high);
vertical-align: middle;
}
.cv-app table.inspector canvas {
.scanner-app table.inspector canvas {
display: block;
background: #000;
border-radius: 4px;
}
.cv-app .candidates {
.scanner-app .candidates {
display: flex;
gap: 8px;
align-items: center;
}
.cv-app .candidates .cand {
.scanner-app .candidates .cand {
display: flex;
flex-direction: column;
align-items: center;
@@ -415,12 +415,12 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
color: var(--color-text-high);
}
.cv-app .candidates .cand:first-child {
.scanner-app .candidates .cand:first-child {
color: var(--color-success);
}
/* internal navigation styled like the sibling action buttons */
.cv-app .link-button {
.scanner-app .link-button {
display: inline-flex;
align-items: center;
padding: 0 var(--field-padding);
@@ -432,6 +432,6 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
font-size: var(--font-sm);
text-decoration: none;
}
.cv-app .link-button:focus-visible {
.scanner-app .link-button:focus-visible {
outline: var(--focus-ring);
}

View File

@@ -7,7 +7,7 @@
* and weapon id.
*/
import type { CvAbility } from "../cv-types";
import type { ScannerAbility } from "../scanner-types";
import { DEATH_EVENT_TYPE, type DeathData } from "./detectors/death/index";
import { SCOREBOARD_EVENT_TYPES } from "./detectors/registry";
import type {
@@ -17,7 +17,7 @@ import type {
import type { DetectedEvent } from "./detectors/types";
/** player row index (0-7) → [head, clothes, shoes] ability-id rows */
export type PlayerAbilityMap = Map<number, CvAbility[][]>;
export type PlayerAbilityMap = Map<number, ScannerAbility[][]>;
/**
* Match a death's killer to a scoreboard player row. Both signals are OCR

View File

@@ -25,7 +25,7 @@ import type {
SpecialWeaponId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import { type CvAbility, toCvAbility } from "../../../cv-types";
import { type ScannerAbility, toScannerAbility } from "../../../scanner-types";
import { getCV, type Mat, minMaxLoc } from "../../cv";
import {
type GlyphSet,
@@ -88,7 +88,7 @@ export interface DeathData {
* killer's gear abilities, [head, clothes, shoes] rows of [main, sub...]
* ability ids; rows carry as many sub entries as the gear has slots (1-3)
*/
abilities: CvAbility[][];
abilities: ScannerAbility[][];
/** killer's splash-tag name; null if unreadable */
name: string | null;
}
@@ -630,18 +630,18 @@ export function createDeathDetector(
// 3. ability grid; rows carry 1-3 sub circles (left-aligned, as many
// as the gear has slots), so a sub box without badge ink ends the row
const abilityRows: CvAbility[][] = [];
const abilityRows: ScannerAbility[][] = [];
const abilityDebug: (WeaponMatch | null)[][] = [];
if (abilities) {
for (let row = 0; row < ABILITY_ROWS; row++) {
const ids: CvAbility[] = [];
const ids: ScannerAbility[] = [];
const debug: (WeaponMatch | null)[] = [];
const mainCrop = cropRoi(rgb, abilityMainRoi(row));
const main = matchWeapon(mainCrop, abilities.mains, {
inkThreshold: ABILITY_INK_THRESHOLD,
});
mainCrop.delete();
ids.push(toCvAbility(main.id) ?? "UNKNOWN");
ids.push(toScannerAbility(main.id) ?? "UNKNOWN");
debug.push(main);
confidences.push(Math.max(0, main.score));
for (let slot = 0; slot < ABILITY_SUB_XS.length; slot++) {
@@ -661,7 +661,7 @@ export function createDeathDetector(
inkThreshold: ABILITY_INK_THRESHOLD,
});
crop.delete();
ids.push(toCvAbility(sub.id) ?? "UNKNOWN");
ids.push(toScannerAbility(sub.id) ?? "UNKNOWN");
debug.push(sub);
confidences.push(Math.max(0, sub.score));
}

View File

@@ -1,5 +1,5 @@
/**
* GENERATED by scripts/cv/build-localized-entries.ts from the splat3 repo's
* GENERATED by scripts/scanner/build-localized-entries.ts from the splat3 repo's
* language dumps do not edit by hand; regenerate when the game adds
* content. Per-language death-burst message templates and localized
* weapon names: the "Splatted by\n<weapon>!" burst puts the weapon on line

View File

@@ -22,7 +22,11 @@
*/
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
import { type CvAbility, toCvAbility, toMainWeaponId } from "../../../cv-types";
import {
type ScannerAbility,
toMainWeaponId,
toScannerAbility,
} from "../../../scanner-types";
import { getCV, type Mat } from "../../cv";
import { type GlyphSet, scaleGlyphSet } from "../../glyphs";
import {
@@ -89,7 +93,7 @@ export interface MinimapTeammate {
* the card's three main abilities, [head, clothes, shoes] (null per
* unreadable badge); empty when a respawn cross-out sits over the badges
*/
abilities: (CvAbility | null)[];
abilities: (ScannerAbility | null)[];
}
export interface MinimapEnemy {
@@ -100,7 +104,7 @@ export interface MinimapEnemy {
name: string | null;
/** readable even on struck rows: the cross-out spares the weapon icon */
weaponId: MainWeaponId | null;
abilities: (CvAbility | null)[];
abilities: (ScannerAbility | null)[];
}
export interface MinimapData {
@@ -249,7 +253,7 @@ export function createMinimapDetector(
inkThreshold: number,
confidences: number[],
debugRow: (WeaponMatch | null)[],
): (CvAbility | null)[] {
): (ScannerAbility | null)[] {
if (!badges) return [null, null, null];
return centers.map(([cx, cy]) => {
const crop = cropRoi(rgb, badgeRoi(cx, cy));
@@ -257,7 +261,9 @@ export function createMinimapDetector(
crop.delete();
debugRow.push(match);
confidences.push(Math.max(0, match.score));
return match.score >= ABILITY_MIN_SCORE ? toCvAbility(match.id) : null;
return match.score >= ABILITY_MIN_SCORE
? toScannerAbility(match.id)
: null;
});
}
@@ -331,7 +337,7 @@ export function createMinimapDetector(
let nameRaw = "";
let weapon: WeaponMatch | null = null;
const badgeDebug: (WeaponMatch | null)[] = [];
let abilities: (CvAbility | null)[] = [];
let abilities: (ScannerAbility | null)[] = [];
if (!occluded) {
const parsed = bestNameRead(gray, layout.name);
if (parsed) {
@@ -467,7 +473,7 @@ export function createMinimapDetector(
let nameRaw = "";
let weapon: WeaponMatch | null = null;
const badgeDebug: (WeaponMatch | null)[] = [];
let abilities: (CvAbility | null)[] = [];
let abilities: (ScannerAbility | null)[] = [];
if (!occluded) {
if (nameGlyphs) {
const band = copyRoi(gray, layout.name);
@@ -567,7 +573,7 @@ export function createMinimapDetector(
confidences.push(Math.max(0, weapon.score));
}
const badgeDebug: (WeaponMatch | null)[] = [];
const abilities: (CvAbility | null)[] = occluded
const abilities: (ScannerAbility | null)[] = occluded
? []
: matchBadges(
rgb,

View File

@@ -17,11 +17,11 @@ import type {
StageId,
} from "~/modules/in-game-lists/types";
import {
type CvAbility,
type CvLobby,
toCvAbility,
type ScannerAbility,
type ScannerLobby,
toMainWeaponId,
} from "../../../cv-types";
toScannerAbility,
} from "../../../scanner-types";
import { getCV, type Mat } from "../../cv";
import { type GlyphSet, recognizeText, scaleGlyphSet } from "../../glyphs";
import { copyRoi, cropRoi, maxBrightness, meanBrightness } from "../../image";
@@ -51,7 +51,7 @@ import {
export interface ScoreboardOwnData {
/** from the header tag; null when unreadable */
lobby: CvLobby | null;
lobby: ScannerLobby | null;
mode: ModeShort | null;
stage: StageId | null;
/** the player's main weapon; null if unreadable */
@@ -60,7 +60,7 @@ export interface ScoreboardOwnData {
* own gear abilities, [head, clothes, shoes] rows of
* [main, sub, sub, sub] ability ids
*/
abilities: CvAbility[][];
abilities: ScannerAbility[][];
}
export const SCOREBOARD_OWN_EVENT_TYPE = "ScoreboardOwn";
@@ -194,18 +194,18 @@ export function createScoreboardOwnDetector(
}
// gear-card ability strips: [head, clothes, shoes] x [main, sub, sub, sub]
const abilityRows: CvAbility[][] = [];
const abilityRows: ScannerAbility[][] = [];
const abilityDebug: (WeaponMatch | null)[][] = [];
if (abilities) {
for (let row = 0; row < GEAR_ROWS; row++) {
const ids: CvAbility[] = [];
const ids: ScannerAbility[] = [];
const debug: (WeaponMatch | null)[] = [];
const mainCrop = cropRoi(rgb, gearMainRoi(row));
const main = matchWeapon(mainCrop, abilities.mains, {
inkThreshold: OWN_ABILITY_INK_THRESHOLD,
});
mainCrop.delete();
ids.push(toCvAbility(main.id) ?? "UNKNOWN");
ids.push(toScannerAbility(main.id) ?? "UNKNOWN");
debug.push(main);
confidences.push(Math.max(0, main.score));
for (let slot = 0; slot < 3; slot++) {
@@ -214,7 +214,7 @@ export function createScoreboardOwnDetector(
inkThreshold: OWN_ABILITY_INK_THRESHOLD,
});
crop.delete();
ids.push(toCvAbility(sub.id) ?? "UNKNOWN");
ids.push(toScannerAbility(sub.id) ?? "UNKNOWN");
debug.push(sub);
confidences.push(Math.max(0, sub.score));
}

View File

@@ -9,7 +9,7 @@
* sets shared with the live header.
*/
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type { CvLobby } from "../../../cv-types";
import type { ScannerLobby } from "../../../scanner-types";
import type { Mat } from "../../cv";
import type { GlyphSet } from "../../glyphs";
import { ALL_STAGE_ENTRIES, LOBBY_MODE_COMBOS } from "../../localized";
@@ -20,7 +20,7 @@ import { HEADER_BOTTOM_BAND, HEADER_TOP_BAND } from "./rois";
export interface ParsedReplayHeader {
timestamp: string | null;
stage: StageId | null;
lobby: CvLobby | null;
lobby: ScannerLobby | null;
mode: ModeShort | null;
/** min of the closed-set match scores that were attempted */
confidence: number;
@@ -118,7 +118,7 @@ export function parseReplayHeader(
}
}
let lobby: CvLobby | null = null;
let lobby: ScannerLobby | null = null;
let mode: ModeShort | null = null;
if (bottomMatch && bottomMatch.score >= MIN_MATCH_SCORE) {
lobby = bottomMatch.entry.lobby;

View File

@@ -11,7 +11,7 @@
* sendou.ink ids regardless of the game's language.
*/
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type { CvLobby } from "../../../cv-types";
import type { ScannerLobby } from "../../../scanner-types";
import { getCV, type Mat } from "../../cv";
import {
type GlyphSet,
@@ -24,7 +24,7 @@ import { closestBy } from "../../text";
import { HEADER_LINE_BAND, HEADER_LOBBY_BAND } from "./rois";
export interface ParsedHeader {
lobby: CvLobby | null;
lobby: ScannerLobby | null;
mode: ModeShort | null;
stage: StageId | null;
/** min of the closed-set match scores that were attempted */

View File

@@ -7,7 +7,7 @@ import type {
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import type { CvLobby } from "../../../cv-types";
import type { ScannerLobby } from "../../../scanner-types";
import { getCV, type Mat } from "../../cv";
import { type GlyphSet, scaleGlyphSet } from "../../glyphs";
import { cropRoi, maxBrightness, meanBrightness } from "../../image";
@@ -50,7 +50,7 @@ export interface ScoreboardPlayer {
export interface ScoreboardData {
/** from the header tag; null when unreadable */
lobby: CvLobby | null;
lobby: ScannerLobby | null;
mode: ModeShort | null;
stage: StageId | null;
/** [winning team total, losing team total] as shown ("500 p") */

View File

@@ -6,7 +6,7 @@
* glyph sets (replay rows are smaller, so it passes rescaled sets), and two
* match options.
*/
import { toMainWeaponId } from "../../../cv-types";
import { toMainWeaponId } from "../../../scanner-types";
import type { Mat } from "../../cv";
import type { GlyphSet } from "../../glyphs";
import { cropRoi, type Roi } from "../../image";

View File

@@ -1,16 +1,16 @@
/**
* GENERATED by scripts/cv/build-localized-entries.ts from the splat3 repo's
* GENERATED by scripts/scanner/build-localized-entries.ts from the splat3 repo's
* language dumps do not edit by hand; regenerate when the game adds
* content. Localized versus-UI strings for every game language, each
* mapped to the sendou.ink id it means (core/localized.ts derives the
* flattened match sets detectors snap OCR output against).
*/
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type { CvLobby } from "../cv-types";
import type { ScannerLobby } from "../scanner-types";
export interface LocalizedLobby {
text: string;
lobby: CvLobby;
lobby: ScannerLobby;
}
export interface LocalizedMode {

View File

@@ -8,7 +8,7 @@
* would show.
*/
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type { CvLobby } from "../cv-types";
import type { ScannerLobby } from "../scanner-types";
import {
LANGUAGE_ENTRIES,
type LocalizedLobby,
@@ -85,7 +85,7 @@ export const MODE_STAGE_COMBOS: readonly ModeStageCombo[] = dedupe(
export interface LobbyModeCombo {
text: string;
lobby: CvLobby;
lobby: ScannerLobby;
mode: ModeShort;
}

View File

@@ -99,7 +99,7 @@ export async function assembleScoreboardResources(
icons("special-weapons", specialWeaponIds),
icons("sub-weapons", subWeaponIds),
// UNKNOWN is the garbled-badge template: it competes in matching and
// wins on unreadable slots (see CvAbility in cv-types.ts)
// wins on unreadable slots (see ScannerAbility in scanner-types.ts)
icons("abilities", [
...abilityList.map((ability) => ability.name),
"UNKNOWN",

View File

@@ -1,6 +1,6 @@
/**
* Where Node-side code (tests, atlas builders) reads and writes the CV
* asset sets. CV_ASSETS_DIR holds the CV-specific atlases (glyphs, planner
* 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/**`
@@ -8,9 +8,9 @@
* xxx: atlases temporarily live in this repo's public/ while the feature
* is in development; move them to the assets repo later
*/
export const CV_ASSETS_DIR =
process.env.CV_ASSETS_DIR ??
new URL("../../../../public/cv/v1", import.meta.url).pathname;
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",

View File

@@ -1,7 +1,7 @@
/**
* Fixture discovery and detector execution for tests and tools.
*
* A fixture is a directory under app/features/cv/tests/fixtures/<detector>/<case-name>/ containing
* 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.
*/
@@ -19,7 +19,7 @@ import type {
GateResult,
} from "../core/detectors/types";
import { normalizeFrame, toMat } from "../core/image";
import type { CvAbility, CvLobby } from "../cv-types";
import type { ScannerAbility, ScannerLobby } from "../scanner-types";
import { readImage } from "./image-io";
export const FIXTURES_DIR = new URL("../tests/fixtures", import.meta.url)
@@ -40,7 +40,7 @@ interface ExpectedMinimapTeammate {
/** informational for the human corrector; tests compare weaponId */
weaponLabel?: string | null;
weaponId?: MainWeaponId | null;
abilities?: (CvAbility | null)[];
abilities?: (ScannerAbility | null)[];
}
interface ExpectedMinimapEnemy {
@@ -49,7 +49,7 @@ interface ExpectedMinimapEnemy {
/** informational for the human corrector; tests compare weaponId */
weaponLabel?: string | null;
weaponId?: MainWeaponId | null;
abilities?: (CvAbility | null)[];
abilities?: (ScannerAbility | null)[];
}
interface ExpectedScoreboard {
@@ -62,7 +62,7 @@ interface ExpectedScoreboard {
| "Minimap"
| "none";
data?: {
lobby?: CvLobby;
lobby?: ScannerLobby;
mode?: ModeShort;
stage?: StageId;
/** informational for the human corrector; tests compare `stage` */
@@ -86,7 +86,7 @@ interface ExpectedScoreboard {
weaponId?: number | null;
weaponType?: "MAIN" | "SUB" | "SPECIAL";
/** Death + ScoreboardOwn: 3 gear rows of [main, sub, sub, sub] ability ids */
abilities?: CvAbility[][];
abilities?: ScannerAbility[][];
/** Death only: killer's splash-tag name */
name?: string;
/** Minimap only: casted 8-player spectator map screen (not parsed yet) */

View File

@@ -1,8 +1,8 @@
/**
* Node IO for ScoreboardResources: reads the CV asset sets from the local
* 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
* CV-specific atlases from `cv/v1/**`. What the bundle contains every
* 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.
*/
@@ -16,7 +16,7 @@ import {
import type { ScoreboardResources } from "../core/detectors/scoreboard/index";
import { type AtlasMeta, type GlyphSet, loadGlyphSet } from "../core/glyphs";
import { assembleScoreboardResources } from "../core/resources";
import { CV_ASSETS_DIR as ASSETS_DIR, GAME_IMG_DIR } from "./assets-dir";
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. */

View File

@@ -7,14 +7,14 @@ import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
// `builds` powers the empty/UNKNOWN ability label in <Ability />; the weapon
// and game-misc namespaces the CV cards rely on are always loaded.
// and game-misc namespaces the scanner cards rely on are always loaded.
export const handle: SendouRouteHandle = {
i18n: ["builds"],
};
export const meta: MetaFunction = (args) => {
return metaTags({
title: "CV",
title: "Scanner",
description:
"Detect Splatoon 3 match events (scoreboards, deaths, map screens) from live OBS footage, VoDs, and screenshots",
location: args.location,
@@ -25,12 +25,12 @@ export const meta: MetaFunction = (args) => {
// IndexedDB, WebCodecs, getUserMedia. Nothing from core/worker/capture/store
// may be imported at route-module top level — only from inside this lazily
// imported client component tree, after hydration.
const CvApp = lazy(() =>
import("~/features/cv/components/App").then((m) => ({ default: m.App })),
const ScannerApp = lazy(() =>
import("~/features/scanner/components/App").then((m) => ({ default: m.App })),
);
export default function CvPage() {
export default function ScannerPage() {
const isHydrated = useHydrated();
return <Main bigger>{isHydrated ? <CvApp /> : <Placeholder />}</Main>;
return <Main bigger>{isHydrated ? <ScannerApp /> : <Placeholder />}</Main>;
}

View File

@@ -1,6 +1,6 @@
/**
* Zod schemas for the CV events domain the single source of truth shared
* by the producer (the CV detectors/UI in this feature) and the validator
* Zod schemas for the scanner events domain the single source of truth shared
* by the producer (the scanner detectors/UI in this feature) and the validator
* (features/ingest). Every domain field is a sendou.ink id type; the
* compile-time asserts at the bottom pin each schema to the corresponding
* detector output interface so producer and validator cannot drift.
@@ -26,11 +26,11 @@ import type {
ScoreboardPlayer,
} from "./core/detectors/scoreboard/index";
import type { ScoreboardReplayData } from "./core/detectors/scoreboard-replay/index";
import { CV_LOBBIES } from "./cv-types";
import { SCANNER_LOBBIES } from "./scanner-types";
const detectionText = z.string().max(500);
const cvLobbySchema = z.enum(CV_LOBBIES);
const scannerLobbySchema = z.enum(SCANNER_LOBBIES);
export const modeShortSchema = z.enum(modesShort);
export const stageIdSchema = z.literal(stageIds);
export const mainWeaponIdSchema = z.literal(mainWeaponIds);
@@ -39,12 +39,12 @@ const specialWeaponIdSchema = z.literal(specialWeaponIds);
const abilityNames = abilities.map((ability) => ability.name) as Ability[];
/** a sendou ability id, or the detectors' explicit unrecognized marker */
export const cvAbilitySchema = z.union([
export const scannerAbilitySchema = z.union([
z.literal(abilityNames),
z.literal("UNKNOWN"),
]);
export const cvScoreboardPlayerSchema = z.object({
export const scannerScoreboardPlayerSchema = z.object({
name: detectionText,
/** sendou main-weapon id; null when the row's weapon was unreadable */
weaponId: mainWeaponIdSchema.nullable(),
@@ -54,32 +54,33 @@ export const cvScoreboardPlayerSchema = z.object({
s: z.number().nullable(),
});
export const cvScoreboardDataSchema = z.object({
lobby: cvLobbySchema.nullable(),
export const scannerScoreboardDataSchema = z.object({
lobby: scannerLobbySchema.nullable(),
mode: modeShortSchema.nullable(),
stage: stageIdSchema.nullable(),
scores: z.tuple([z.number().nullable(), z.number().nullable()]),
players: z.array(cvScoreboardPlayerSchema).length(8),
players: z.array(scannerScoreboardPlayerSchema).length(8),
povIndex: z.number().int().min(0).max(7).nullable(),
});
export const cvScoreboardReplayDataSchema = cvScoreboardDataSchema.extend({
timestamp: detectionText.nullable(),
replayCode: detectionText.nullable(),
matchScores: z.tuple([z.number().nullable(), z.number().nullable()]),
});
export const scannerScoreboardReplayDataSchema =
scannerScoreboardDataSchema.extend({
timestamp: detectionText.nullable(),
replayCode: detectionText.nullable(),
matchScores: z.tuple([z.number().nullable(), z.number().nullable()]),
});
export const cvDeathDataSchema = z.object({
export const scannerDeathDataSchema = z.object({
/** sendou weapon id (main/sub/special id space per weaponType) */
weaponId: z
.union([mainWeaponIdSchema, subWeaponIdSchema, specialWeaponIdSchema])
.nullable(),
weaponType: z.enum(["MAIN", "SUB", "SPECIAL"]).nullable(),
abilities: z.array(z.array(cvAbilitySchema)),
abilities: z.array(z.array(scannerAbilitySchema)),
name: detectionText.nullable(),
});
export const cvMapStartDataSchema = z.object({
export const scannerMapStartDataSchema = z.object({
mode: modeShortSchema.nullable(),
stage: stageIdSchema.nullable(),
});
@@ -95,19 +96,22 @@ type MutuallyAssignable<A, B> = [A] extends [B]
// `true satisfies …` fails to compile the moment a schema and its detector
// interface disagree in either direction.
true satisfies MutuallyAssignable<
z.infer<typeof cvScoreboardPlayerSchema>,
z.infer<typeof scannerScoreboardPlayerSchema>,
ScoreboardPlayer
>;
true satisfies MutuallyAssignable<
z.infer<typeof cvScoreboardDataSchema>,
z.infer<typeof scannerScoreboardDataSchema>,
ScoreboardData
>;
true satisfies MutuallyAssignable<
z.infer<typeof cvScoreboardReplayDataSchema>,
z.infer<typeof scannerScoreboardReplayDataSchema>,
ScoreboardReplayData
>;
true satisfies MutuallyAssignable<z.infer<typeof cvDeathDataSchema>, DeathData>;
true satisfies MutuallyAssignable<
z.infer<typeof cvMapStartDataSchema>,
z.infer<typeof scannerDeathDataSchema>,
DeathData
>;
true satisfies MutuallyAssignable<
z.infer<typeof scannerMapStartDataSchema>,
MapStartData
>;

View File

@@ -3,16 +3,16 @@ import {
assertDecodesToDefault,
assertRoundTrips,
} from "~/modules/search-params/search-params-test-utils";
import { cvSearchParams } from "./cv-search-params";
import { scannerSearchParams } from "./scanner-search-params";
describe("cvSearchParams", () => {
describe("scannerSearchParams", () => {
it("round-trips", () => {
assertRoundTrips(cvSearchParams, {
assertRoundTrips(scannerSearchParams, {
tab: ["live", "screenshot", "vod"],
});
});
it("malformed values decode to defaults", () => {
assertDecodesToDefault(cvSearchParams, "tab", [["garbage"], ["LIVE"]]);
assertDecodesToDefault(scannerSearchParams, "tab", [["garbage"], ["LIVE"]]);
});
});

View File

@@ -0,0 +1,11 @@
import { z } from "zod";
import * as SearchParams from "~/modules/search-params/search-params";
import { SP } from "~/modules/search-params/search-params";
export const SCANNER_TABS = ["live", "screenshot", "vod"] as const;
export type ScannerTab = (typeof SCANNER_TABS)[number];
export const scannerSearchParams = SearchParams.define({
tab: SP.param(z.enum(SCANNER_TABS), { default: "live", loader: false }),
});

View File

@@ -1,5 +1,5 @@
/**
* Shared domain vocabulary for the CV feature: events, snap tables, and
* Shared domain vocabulary for the scanner feature: events, snap tables, and
* constants speak sendou.ink's id types (ModeShort, StageId, weapon ids,
* Ability) canonical English strings live only inside the OCR snap
* layer and never leave a detector.
@@ -9,15 +9,15 @@ import type { Ability, MainWeaponId } from "~/modules/in-game-lists/types";
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
/** The scoreboard header's lobby tag. PRIVATE marks tournament games. */
export const CV_LOBBIES = ["X", "SERIES", "OPEN", "PRIVATE"] as const;
export type CvLobby = (typeof CV_LOBBIES)[number];
export const SCANNER_LOBBIES = ["X", "SERIES", "OPEN", "PRIVATE"] as const;
export type ScannerLobby = (typeof SCANNER_LOBBIES)[number];
/**
* A detected gear ability: a sendou ability id, or the explicit
* unrecognized marker (the UNKNOWN template in the shared img/abilities
* set distinct from null, which means the badge was covered/absent).
*/
export type CvAbility = Ability | "UNKNOWN"; // xxx: why not AbilityWithUnknown
export type ScannerAbility = Ability | "UNKNOWN"; // xxx: why not AbilityWithUnknown
const MAIN_WEAPON_ID_SET: ReadonlySet<number> = new Set(mainWeaponIds);
const ABILITY_SET: ReadonlySet<string> = new Set(abilities.map((a) => a.name));
@@ -28,8 +28,8 @@ export function toMainWeaponId(id: number | string): MainWeaponId | null {
return MAIN_WEAPON_ID_SET.has(n) ? (n as MainWeaponId) : null;
}
/** Narrow an ability-template id to a CvAbility; null when unknown. */
export function toCvAbility(id: string): CvAbility | null {
/** Narrow an ability-template id to a ScannerAbility; null when unknown. */
export function toScannerAbility(id: string): ScannerAbility | null {
if (id === "UNKNOWN") return id;
return ABILITY_SET.has(id) ? (id as Ability) : null;
}

View File

@@ -14,15 +14,15 @@ import {
} from "../core/detectors/death/index";
import { SCOREBOARD_EVENT_TYPE } from "../core/detectors/scoreboard/index";
import type { DetectedEvent } from "../core/detectors/types";
import type { CvAbility } from "../cv-types";
import type { ScannerAbility } from "../scanner-types";
import test from "./node-test-compat";
const GRID_A: CvAbility[][] = [
const GRID_A: ScannerAbility[][] = [
["ISM", "ISM", "ISM", "ISM"],
["RSU", "RSU", "RSU", "RSU"],
["SSU", "SSU", "SSU", "SSU"],
];
const GRID_B: CvAbility[][] = [
const GRID_B: ScannerAbility[][] = [
["QR", "QR", "QR", "QR"],
["QSJ", "QSJ", "QSJ", "QSJ"],
["IRU", "IRU", "IRU", "IRU"],
@@ -36,7 +36,7 @@ function death(
t: number,
name: string | null,
weaponId: MainWeaponId | null,
abilities: CvAbility[][] = GRID_A,
abilities: ScannerAbility[][] = GRID_A,
): DetectedEvent<DeathData> {
return {
type: DEATH_EVENT_TYPE,

View File

@@ -7,7 +7,7 @@ import {
import type { DeathData } from "../core/detectors/death/index";
import type { ScoreboardData } from "../core/detectors/scoreboard/index";
import type { DetectedEvent } from "../core/detectors/types";
import type { CvAbility, CvLobby } from "../cv-types";
import type { ScannerAbility, ScannerLobby } from "../scanner-types";
import test from "./node-test-compat";
const NAMES = ["w1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"];
@@ -24,7 +24,7 @@ function mapStart(t: number): DetectedEvent {
function death(
t: number,
name: string,
abilities: CvAbility[][] = [["ISM", "ISS", "ISS", "ISS"]],
abilities: ScannerAbility[][] = [["ISM", "ISS", "ISS", "ISS"]],
): DetectedEvent {
const data: DeathData = {
weaponId: null,
@@ -37,7 +37,7 @@ function death(
function scoreboard(
t: number,
{ lobby = "PRIVATE" as CvLobby | null } = {},
{ lobby = "PRIVATE" as ScannerLobby | null } = {},
): DetectedEvent {
const data: ScoreboardData = {
lobby,
@@ -76,7 +76,7 @@ test("groups map start, deaths and scoreboard into one batch", () => {
});
test("enriches the scoreboard players with abilities from the batch's deaths", () => {
const build: CvAbility[][] = [
const build: ScannerAbility[][] = [
["ISM", "ISS", "ISS", "ISS"],
["QR", "QSJ", "QSJ", "QSJ"],
["SSU", "RSU", "RSU", "RSU"],

View File

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 4.1 MiB

Some files were not shown because too many files have changed in this diff Show More