From e0cf94bf34549eff1a8fc6c0463f674959739481 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:58:28 +0300 Subject: [PATCH] Initial --- .env.example | 2 - app/components/layout/Footer.tsx | 2 + app/components/layout/TopNavMenus.tsx | 1 + app/components/layout/nav-items.ts | 4 + app/config.ts | 3 - app/features/admin/admin-constants.ts | 3 - app/features/changelog/changelog-constants.ts | 1 + .../actions/scanner-ingest.server.ts | 13 +- app/features/scanner-ingest/tests/harness.ts | 25 - .../tests/ingest-scenarios.test.ts | 16 - app/features/scanner/README.md | 156 ++- app/features/scanner/capture/ring-buffer.ts | 392 ++++++++ app/features/scanner/capture/sampler.ts | 156 ++- app/features/scanner/capture/vod-clips.ts | 167 ++++ app/features/scanner/capture/vod-frames.ts | 32 +- .../scanner/components/ClipDialog.module.css | 32 + .../scanner/components/ClipDialog.tsx | 115 +++ .../scanner/components/ClipStrip.module.css | 119 +++ app/features/scanner/components/ClipStrip.tsx | 104 ++ .../scanner/components/ClipsView.module.css | 125 +++ app/features/scanner/components/ClipsView.tsx | 206 ++++ app/features/scanner/components/DeathCard.tsx | 2 +- .../scanner/components/Dropzone.module.css | 24 + app/features/scanner/components/Dropzone.tsx | 34 + .../scanner/components/EventCard.module.css | 39 - app/features/scanner/components/EventCard.tsx | 73 +- .../components/EventsSummary.module.css | 37 - .../scanner/components/EventsSummary.tsx | 65 -- .../scanner/components/ExportMenu.tsx | 75 ++ .../scanner/components/FixturesPage.tsx | 4 +- app/features/scanner/components/KillCard.tsx | 3 +- .../scanner/components/LandingView.module.css | 200 ++++ .../scanner/components/LandingView.tsx | 277 ++++++ app/features/scanner/components/LivePage.tsx | 634 ------------ .../scanner/components/LiveView.module.css | 52 + app/features/scanner/components/LiveView.tsx | 180 ++++ .../scanner/components/MapStartCard.tsx | 2 +- .../scanner/components/MatchCard.module.css | 632 ++++++------ app/features/scanner/components/MatchCard.tsx | 838 +++++++++------- .../components/MatchLobbyTabs.module.css | 5 - .../scanner/components/MatchLobbyTabs.tsx | 135 --- app/features/scanner/components/MetaChips.tsx | 2 +- .../scanner/components/MinimapCard.tsx | 2 +- .../scanner/components/NotFound.module.css | 11 + app/features/scanner/components/NotFound.tsx | 20 + .../scanner/components/ObjectiveCard.tsx | 3 +- .../components/PastSessionView.module.css | 9 + .../scanner/components/PastSessionView.tsx | 121 +++ .../scanner/components/PlayerStatusCard.tsx | 3 +- .../components/RawDetections.module.css | 23 + .../scanner/components/RawDetections.tsx | 61 ++ .../scanner/components/ScannerApp.module.css | 86 +- .../scanner/components/ScannerApp.tsx | 107 +- .../components/ScannerChrome.module.css | 137 --- .../scanner/components/ScannerChrome.tsx | 102 -- .../scanner/components/ScoreboardCard.tsx | 2 +- .../scanner/components/ScoreboardOwnCard.tsx | 7 +- .../scanner/components/ScreenshotPage.tsx | 25 +- .../components/SessionHeader.module.css | 60 ++ .../scanner/components/SessionHeader.tsx | 37 + .../scanner/components/SessionView.module.css | 43 + .../scanner/components/SessionView.tsx | 283 ++++++ .../components/SettingsPopover.module.css | 45 + .../scanner/components/SettingsPopover.tsx | 125 +++ ...age.module.css => SourceSelect.module.css} | 42 +- .../scanner/components/SourceSelect.tsx | 97 ++ .../scanner/components/StripWeaponsCard.tsx | 5 +- .../scanner/components/UploadChip.module.css | 90 ++ .../scanner/components/UploadChip.tsx | 166 ++++ .../scanner/components/VodPage.module.css | 114 --- app/features/scanner/components/VodPage.tsx | 913 ------------------ .../scanner/components/VodView.module.css | 140 +++ app/features/scanner/components/VodView.tsx | 376 ++++++++ app/features/scanner/components/clips-feed.ts | 44 + app/features/scanner/components/download.ts | 14 + app/features/scanner/components/errors.ts | 29 + .../scanner/components/events-feed.ts | 164 ++++ .../scanner/components/fixture-export.ts | 12 +- app/features/scanner/components/format.ts | 15 - .../scanner/components/live-session.ts | 430 +++++++++ .../scanner/components/sendou-ingest.ts | 168 ++-- .../scanner/components/session-data.ts | 20 + app/features/scanner/components/settings.ts | 84 ++ app/features/scanner/components/upload.ts | 103 ++ app/features/scanner/components/use-debug.ts | 11 + app/features/scanner/components/vod-scan.ts | 532 ++++++++++ app/features/scanner/components/vods-feed.ts | 39 + app/features/scanner/core/clips/scoring.ts | 114 +++ app/features/scanner/core/csv/csv.ts | 18 + .../events-csv.ts => core/csv/events.ts} | 84 +- app/features/scanner/core/csv/matches.ts | 107 ++ app/features/scanner/core/format.ts | 26 + .../scanner/{components => core}/labels.ts | 0 app/features/scanner/core/match-builder.ts | 38 +- app/features/scanner/core/sessions.ts | 103 ++ app/features/scanner/routes/scanner.tsx | 12 +- .../scanner/scanner-search-params.test.ts | 11 +- app/features/scanner/scanner-search-params.ts | 29 +- .../scanner/store/clips.browser.test.ts | 102 ++ app/features/scanner/store/clips.ts | 154 +++ app/features/scanner/store/db.ts | 119 ++- app/features/scanner/store/events.ts | 169 ++-- .../scanner/store/vods.browser.test.ts | 56 -- app/features/scanner/store/vods.ts | 95 +- .../scanner/tests/logic/clip-scoring.test.ts | 116 +++ .../scanner/tests/logic/match-builder.test.ts | 38 + .../scanner/tests/logic/matches-csv.test.ts | 115 +++ .../scanner/tests/logic/sessions.test.ts | 128 +++ app/modules/permissions/mapper.server.ts | 13 +- app/modules/permissions/types.ts | 1 - app/modules/permissions/utils.ts | 7 - app/utils/urls.ts | 1 + changelog/2026-09-19-scanner.md | 12 + e2e/pages/scanner/scanner-page.ts | 4 + e2e/public-pages.spec.ts | 6 +- locales/da/common.json | 1 + locales/de/common.json | 1 + locales/en/common.json | 1 + locales/es-ES/common.json | 1 + locales/es-US/common.json | 1 + locales/fr-CA/common.json | 1 + locales/fr-EU/common.json | 1 + locales/he/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/ko/common.json | 1 + locales/nl/common.json | 1 + locales/pl/common.json | 1 + locales/pt-BR/common.json | 1 + locales/ru/common.json | 1 + locales/zh/common.json | 1 + scripts/scanner/scan-vod.ts | 2 +- scripts/scanner/status-audit.ts | 14 +- vitest.unit.config.ts | 4 - 134 files changed, 7969 insertions(+), 3652 deletions(-) create mode 100644 app/features/scanner/capture/ring-buffer.ts create mode 100644 app/features/scanner/capture/vod-clips.ts create mode 100644 app/features/scanner/components/ClipDialog.module.css create mode 100644 app/features/scanner/components/ClipDialog.tsx create mode 100644 app/features/scanner/components/ClipStrip.module.css create mode 100644 app/features/scanner/components/ClipStrip.tsx create mode 100644 app/features/scanner/components/ClipsView.module.css create mode 100644 app/features/scanner/components/ClipsView.tsx create mode 100644 app/features/scanner/components/Dropzone.module.css create mode 100644 app/features/scanner/components/Dropzone.tsx delete mode 100644 app/features/scanner/components/EventCard.module.css delete mode 100644 app/features/scanner/components/EventsSummary.module.css delete mode 100644 app/features/scanner/components/EventsSummary.tsx create mode 100644 app/features/scanner/components/ExportMenu.tsx create mode 100644 app/features/scanner/components/LandingView.module.css create mode 100644 app/features/scanner/components/LandingView.tsx delete mode 100644 app/features/scanner/components/LivePage.tsx create mode 100644 app/features/scanner/components/LiveView.module.css create mode 100644 app/features/scanner/components/LiveView.tsx delete mode 100644 app/features/scanner/components/MatchLobbyTabs.module.css delete mode 100644 app/features/scanner/components/MatchLobbyTabs.tsx create mode 100644 app/features/scanner/components/NotFound.module.css create mode 100644 app/features/scanner/components/NotFound.tsx create mode 100644 app/features/scanner/components/PastSessionView.module.css create mode 100644 app/features/scanner/components/PastSessionView.tsx create mode 100644 app/features/scanner/components/RawDetections.module.css create mode 100644 app/features/scanner/components/RawDetections.tsx delete mode 100644 app/features/scanner/components/ScannerChrome.module.css delete mode 100644 app/features/scanner/components/ScannerChrome.tsx create mode 100644 app/features/scanner/components/SessionHeader.module.css create mode 100644 app/features/scanner/components/SessionHeader.tsx create mode 100644 app/features/scanner/components/SessionView.module.css create mode 100644 app/features/scanner/components/SessionView.tsx create mode 100644 app/features/scanner/components/SettingsPopover.module.css create mode 100644 app/features/scanner/components/SettingsPopover.tsx rename app/features/scanner/components/{LivePage.module.css => SourceSelect.module.css} (54%) create mode 100644 app/features/scanner/components/SourceSelect.tsx create mode 100644 app/features/scanner/components/UploadChip.module.css create mode 100644 app/features/scanner/components/UploadChip.tsx delete mode 100644 app/features/scanner/components/VodPage.module.css delete mode 100644 app/features/scanner/components/VodPage.tsx create mode 100644 app/features/scanner/components/VodView.module.css create mode 100644 app/features/scanner/components/VodView.tsx create mode 100644 app/features/scanner/components/clips-feed.ts create mode 100644 app/features/scanner/components/download.ts create mode 100644 app/features/scanner/components/errors.ts create mode 100644 app/features/scanner/components/events-feed.ts create mode 100644 app/features/scanner/components/live-session.ts create mode 100644 app/features/scanner/components/session-data.ts create mode 100644 app/features/scanner/components/settings.ts create mode 100644 app/features/scanner/components/upload.ts create mode 100644 app/features/scanner/components/use-debug.ts create mode 100644 app/features/scanner/components/vod-scan.ts create mode 100644 app/features/scanner/components/vods-feed.ts create mode 100644 app/features/scanner/core/clips/scoring.ts create mode 100644 app/features/scanner/core/csv/csv.ts rename app/features/scanner/{components/events-csv.ts => core/csv/events.ts} (76%) create mode 100644 app/features/scanner/core/csv/matches.ts create mode 100644 app/features/scanner/core/format.ts rename app/features/scanner/{components => core}/labels.ts (100%) create mode 100644 app/features/scanner/core/sessions.ts create mode 100644 app/features/scanner/store/clips.browser.test.ts create mode 100644 app/features/scanner/store/clips.ts delete mode 100644 app/features/scanner/store/vods.browser.test.ts create mode 100644 app/features/scanner/tests/logic/clip-scoring.test.ts create mode 100644 app/features/scanner/tests/logic/matches-csv.test.ts create mode 100644 app/features/scanner/tests/logic/sessions.test.ts create mode 100644 changelog/2026-09-19-scanner.md diff --git a/.env.example b/.env.example index 6a2d1ae82..521474a34 100644 --- a/.env.example +++ b/.env.example @@ -43,8 +43,6 @@ SQL_LOG=none VITE_SHOW_LUTI_NAV_ITEM=false -// If false the scanner page and its ingest endpoint are admin only -VITE_SCANNER_ENABLED=false // Push notification. Generate values here https://vapidkeys.com/ VITE_VAPID_PUBLIC_KEY= diff --git a/app/components/layout/Footer.tsx b/app/components/layout/Footer.tsx index 6622ed3e3..0bf715163 100644 --- a/app/components/layout/Footer.tsx +++ b/app/components/layout/Footer.tsx @@ -37,6 +37,7 @@ import { PLANNER_URL, PLUS_SUGGESTIONS_PAGE, PLUS_VOTING_RESULTS_PAGE, + SCANNER_PAGE, SENDOU_INK_DISCORD_URL, SENDOU_INK_GITHUB_URL, SENDOU_LOVE_EMOJI_PATH, @@ -110,6 +111,7 @@ const SITEMAP_COLUMNS = [ { name: "maps", url: MAPS_URL }, { name: "tier-list-maker", url: TIER_LIST_MAKER_URL }, { name: "xsearch", url: XSEARCH_PAGE }, + { name: "scanner", url: SCANNER_PAGE }, { name: "settings", url: SETTINGS_PAGE }, ], }, diff --git a/app/components/layout/TopNavMenus.tsx b/app/components/layout/TopNavMenus.tsx index 37a7bb1a9..b61e88d35 100644 --- a/app/components/layout/TopNavMenus.tsx +++ b/app/components/layout/TopNavMenus.tsx @@ -52,6 +52,7 @@ const NAV_CATEGORIES = [ { name: "maps", url: "maps" }, { name: "tier-list-maker", url: "tier-list-maker" }, { name: "xsearch", url: "xsearch" }, + { name: "scanner", url: "scanner" }, { name: "admin", url: "admin", diff --git a/app/components/layout/nav-items.ts b/app/components/layout/nav-items.ts index 3d6b92e9f..8b36d2f66 100644 --- a/app/components/layout/nav-items.ts +++ b/app/components/layout/nav-items.ts @@ -71,6 +71,10 @@ export const navItems = [ name: "vods", url: "vods", }, + { + name: "scanner", + url: "scanner", + }, { name: "art", url: "art", diff --git a/app/config.ts b/app/config.ts index ef9659684..6e9a06940 100644 --- a/app/config.ts +++ b/app/config.ts @@ -32,7 +32,6 @@ const schema = v.object({ VITE_PROD_MODE: v.optional(envBoolean, "false"), VITE_SHOW_LUTI_NAV_ITEM: v.optional(envBoolean, "false"), VITE_FUSE_ENABLED: v.optional(envBoolean, "false"), - VITE_SCANNER_ENABLED: v.optional(envBoolean, "false"), VITE_LEAGUE_GOOGLE_FORM_URL: v.optional(v.string()), VITE_SHOW_BANNER_FOR_SEASON: v.optional(v.string()), @@ -57,8 +56,6 @@ export const Config = { prodMode: values.VITE_PROD_MODE, showLutiNavItem: values.VITE_SHOW_LUTI_NAV_ITEM, fuseEnabled: values.VITE_FUSE_ENABLED, - /** While false only the admin and devs can use the scanner page and its ingest endpoint. */ - scannerEnabled: values.VITE_SCANNER_ENABLED, leagueGoogleFormUrl: values.VITE_LEAGUE_GOOGLE_FORM_URL, /** Season to show the registration banner for. */ showBannerForSeason: values.VITE_SHOW_BANNER_FOR_SEASON, diff --git a/app/features/admin/admin-constants.ts b/app/features/admin/admin-constants.ts index 415684dda..32f8f19ef 100644 --- a/app/features/admin/admin-constants.ts +++ b/app/features/admin/admin-constants.ts @@ -9,9 +9,6 @@ export const STAFF_IDS = [11329, 9719, 9342, 20774, 23094]; export const DEV_IDS = [27883]; // hfcRed Dreamy Cafy Acing export const QA_IDS: number[] = [27883, 38176, 10654, 9342]; -// xi -/** Users who can access the scanner while it is not yet enabled for everyone */ -export const SCANNER_TESTER_IDS: number[] = [30228]; export const STAFF_DISCORD_IDS = [ "138757634500067328", diff --git a/app/features/changelog/changelog-constants.ts b/app/features/changelog/changelog-constants.ts index 5ebfed60a..fad179bad 100644 --- a/app/features/changelog/changelog-constants.ts +++ b/app/features/changelog/changelog-constants.ts @@ -31,6 +31,7 @@ export const DISCORD_EMOJI_NAMES: Record = { associations: "associations", badges: "badges", luti: "luti", + scanner: "scanner", }; /** Stands in for the sendou.ink logo, used by entries without a nav item. */ diff --git a/app/features/scanner-ingest/actions/scanner-ingest.server.ts b/app/features/scanner-ingest/actions/scanner-ingest.server.ts index 9b98f0d5f..ff6681f32 100644 --- a/app/features/scanner-ingest/actions/scanner-ingest.server.ts +++ b/app/features/scanner-ingest/actions/scanner-ingest.server.ts @@ -1,12 +1,10 @@ import { subDays } from "date-fns"; import type { ActionFunction } from "react-router"; -import { Config } from "~/config"; import { requireUser } from "~/features/auth/core/user.server"; import type { ScannerMatch } from "~/features/scanner/core/scanner-match"; -import { isAdmin, isDev, isScannerTester } from "~/modules/permissions/utils"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import { logger } from "~/utils/logger"; -import { forbidden, parseBody } from "~/utils/remix.server"; +import { parseBody } from "~/utils/remix.server"; import * as Scoreboards from "../core/Scoreboards"; import * as ScannerIngestRepository from "../ScannerIngestRepository.server"; import { @@ -21,15 +19,6 @@ const CONTENT_RESOLUTION_WINDOW_DAYS = 365; export const action: ActionFunction = async ({ request }) => { const user = requireUser(); - if ( - !Config.scannerEnabled && - !isAdmin(user) && - !isDev(user) && - !isScannerTester(user) - ) { - forbidden(); - } - const data = await parseBody({ request, schema: ingestBodySchema }); const povUserId = user.id; diff --git a/app/features/scanner-ingest/tests/harness.ts b/app/features/scanner-ingest/tests/harness.ts index 6f30ed670..e6c9f9785 100644 --- a/app/features/scanner-ingest/tests/harness.ts +++ b/app/features/scanner-ingest/tests/harness.ts @@ -1,8 +1,6 @@ /** Everything ingest scenario cases need: world builders (arrange), ingest wrapper (act), page-loader wrappers and row fetchers (assert). See README.md. */ import { addHours, addMinutes, subDays, subMinutes } from "date-fns"; import * as R from "remeda"; -import { afterAll, beforeAll } from "vitest"; -import { Config } from "~/config"; import { backdate } from "~/db/seed/core/backdate"; import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; @@ -75,29 +73,6 @@ export interface ScannedOptions extends Partial { partial?: boolean; } -/** Forces the scanner gate open for the suite (`import.meta.env` may not carry the config's `test.env`), restoring it afterwards. Call at the top of the test file. */ -export function setupScannerGate() { - let original: boolean; - beforeAll(() => { - original = Config.scannerEnabled; - Config.scannerEnabled = true; - }); - afterAll(() => { - Config.scannerEnabled = original; - }); -} - -/** Runs `fn` with the scanner gate closed, restoring it even on a throw. */ -export async function withScannerDisabled(fn: () => Promise) { - const original = Config.scannerEnabled; - Config.scannerEnabled = false; - try { - await fn(); - } finally { - Config.scannerEnabled = original; - } -} - /** 8 users with deterministic in-game names (`Alpha1#1111`, …) and an unreported SendouQ match between them; `conclude()` plays it out (alpha sweeps), making its maps linkable, and returns the refreshed map rows. */ export async function sendouqWorld( options: { diff --git a/app/features/scanner-ingest/tests/ingest-scenarios.test.ts b/app/features/scanner-ingest/tests/ingest-scenarios.test.ts index 6437c950f..722a15f9d 100644 --- a/app/features/scanner-ingest/tests/ingest-scenarios.test.ts +++ b/app/features/scanner-ingest/tests/ingest-scenarios.test.ts @@ -16,28 +16,12 @@ import { renamed, scannedGame, sendouqWorld, - setupScannerGate, tournamentMatchPage, tournamentWorld, WEAPONS, - withScannerDisabled, } from "./harness"; -setupScannerGate(); - describe("gating & request filtering", () => { - test("G1 gate closed: scanner disabled and non-privileged user → 403, nothing stored", async () => { - const w = await sendouqWorld(); - - await withScannerDisabled(async () => { - await expect( - ingest(w.bravoUsers[1]!, [w.scanned(w.maps[0]!)]), - ).rejects.toThrow("403"); - }); - - expect(await fetchIngestedMatches()).toHaveLength(0); - }); - test("G2 non-private lobby: only X-battle matches in the request → skipped entirely", async () => { const w = await sendouqWorld(); await w.conclude(); diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index 2b0f2677b..7afe9129d 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -1,19 +1,103 @@ # Scanner — Splatoon match-event detection -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, and parses them into events speaking -sendou.ink ids (`ModeShort`/`StageId`/weapon ids/`Ability`). Events aggregate -client-side into `ScannerMatch` objects (`core/scanner-match.ts`) — one -detected game per object, every field nullable — which feed `/ingest` -(features/scanner-ingest) and the `/vods/new` prefill. Imported from the -emberz repo; see `MIGRATION.md` there. +Browser app (route `/scanner`, linked from the Tools menu) that watches a +capture card or OBS Virtual Camera while you play, or scans VoD files, +detects Splatoon 3 UI screens with OpenCV.js in a Web Worker, and parses them +into events speaking sendou.ink ids (`ModeShort`/`StageId`/weapon ids/`Ability`). +Events aggregate client-side into `ScannerMatch` objects +(`core/scanner-match.ts`) — one detected game per object, every field +nullable — which feed `/ingest` (features/scanner-ingest), the `/vods/new` +prefill, the match cards and the clip cutter. Imported from the emberz repo; +see `MIGRATION.md` there. -Deliberate convention exceptions (dev tool, ported wholesale): the UI is -English-only (no i18next), `tests/node-test-compat.ts` uses a default export -to stay a `node:test` drop-in, and the suites assert with `node:assert/strict` -rather than the repo-wide `expect`. Keep whichever file you touch on the -idiom it already uses — a half-migration would leave three idioms behind. +Deliberate convention exceptions (ported wholesale): the UI is English-only +(no i18next; the public release should flip that), `tests/node-test-compat.ts` +uses a default export to stay a `node:test` drop-in, and the suites assert +with `node:assert/strict` rather than the repo-wide `expect`. Keep whichever +file you touch on the idiom it already uses — a half-migration would leave +three idioms behind. + +## Product shape + +Anyone can capture, scan files and get clips locally; only uploading needs a +login (`components/upload.ts` mirrors the root loader's user for the +controllers). The landing (`components/LandingView.tsx`) is two entry cards +(Live / File), the clip history strip and the sessions list; everything else +is one component, `components/SessionView.tsx`, rendered identically for the +running capture (`LiveView`), a past session (`PastSessionView`) and a +scanned VoD (`VodView`): header, clip strip, then match cards +(`components/MatchCard.tsx`) newest first. Views are picked by the `view` +search param (`scanner-search-params.ts`: `live`, `session&id=`, +`vod&name=`, `clips`, and the debug-gated `debug` / dev-only `fixtures`). + +- **Controllers are module singletons**, not view state: the capture + (`components/live-session.ts`) and a running VoD scan + (`components/vod-scan.ts`) are mounted above the view switch, so moving + between views never stops them; a capture runs until Stop (even off the + page), a scan until it finishes or the scanner page is left. + Views subscribe through `useSyncExternalStore` hooks, as they do to the + event feed (`components/events-feed.ts`, sessions built once per refresh), + the clip list (`clips-feed.ts`), the VoD list (`vods-feed.ts`) and the + localStorage settings (`settings.ts`: source, upload toggle, clip toggle). +- **Sessions** are client-only and derived at render (`core/sessions.ts`): + live events ordered by `detectedAt`, split wherever a gap of ≥ 2 h opens, + keyed by the first event's `detectedAt` (the URL id). A VoD is its own + session keyed by file name. Live `t` is wall-clock seconds (`Date.now()`) + so events from different page loads share one timeline; the ring buffer + stamps footage the same way. +- **Retention** (`store/events.ts`, on a throttled pass at every save): + whole sessions older than 30 days or beyond the newest 20 go; full-res + frames are kept for everyone — a misread is only reportable while the + frame exists — bounded by 72 h and `MAX_FRAMES`, the event staying with + `hasFrame: false`. Clips have their own cap and outlive session deletion. +- **Upload** is on by default when logged in (settings toggle, persisted). + Live: a scoreboard closes its match and sends it, a 15 s tick retries + unlinked matches on a backoff (`sendou-ingest.ts`) and flushes closed + matches whose send was skipped; Stop sends what is left. VoD: the whole + scan sends once saved. Both write per-event send statuses to their own + store (`updateEventsSend` takes the store), so the cards' upload chip + (`UploadChip.tsx`) and the per-card Retry/Upload are one code path. +- **CSV** is a normal feature: `⇩ CSV` in every session header offers + `Matches` (`core/csv/matches.ts`, one row per game, the rows the cards + render) and `Raw detections` (`core/csv/events.ts`, one row per event). + Column names stay English keys. +- **Debug gate** (`use-debug.ts`: DEV/ADMIN role or `?debug=true`): the + image/screenshot view (`ScreenshotPage.tsx`), the dev-only fixtures view, + `Save frame as fixture`, `?telemetry=true`, and the `Raw detections` + disclosure inside a match card (the per-event cards with Inspect). + +## Clips + +Clips are real video with audio, cut in the browser. `core/clips/scoring.ts` +is the pure, swappable scorer: only the POV player's own kills feed it +(`ScannerMatch.kills`, off the kill feed); a streak is consecutive kills with +no POV death between them and no pause over `STREAK_MAX_GAP_S`, cut where the +clip would outgrow `MAX_CLIP_SECONDS`; `MIN_KILLS` (4) makes it a window, +scored `kills² + kills / span`. Both controllers run the same +`scoreWindows(match, deaths)` → cut → `store/clips.ts` path: + +- **Live** (`capture/ring-buffer.ts`): the stream's video track runs through + a `MediaStreamTrackProcessor` → `VideoEncoder` (hardware H.264, ~16 Mbps, + keyframe every 2 s) into a ring of GOPs holding the last + `RING_BUFFER_SECONDS`; the audio track through an `AudioEncoder` (AAC, + else Opus) into the same ring. Packets are stamped with the wall clock on + arrival. A window is cut once `windowClosed` (no kill can join and the + tail is captured): the GOP at or before its start through its end, muxed + to MP4 with mediabunny's `EncodedVideoPacketSource` — no decode. Audio is + the chosen source's own input (`audioInputFor`: same `groupId`, else a + shared label prefix); OBS Virtual Camera carries none, so its clips are + silent, which the source select says. +- **VoD** (`capture/vod-clips.ts`): packets from the keyframe at or before + the window start are copied into a fresh MP4 (video + audio, no + re-encode, so a minute of 1080p takes well under a second). mediabunny's + `Conversion` with `trim` always transcodes; keep using the packet copy. +- **Buckets** (`store/clips.ts`): `session` holds the running session's + clips (nothing evicted while you play); Stop rolls them into `history`, + where `MAX_HISTORY_CLIPS` (20) applies by score — download to keep. A + file's clips (`vod`) live for one visit and are purged on the next page + load; the file is on disk. Clip records carry the real `start`/`end` + seconds (a packet-copied clip starts at a keyframe), and a card's deaths + and kills get a ▶ when a clip covers their `t`. ## Commands @@ -23,7 +107,7 @@ pnpm test:unit:browser # includes tests/logic/ — the fixture- pnpm scanner:report # accuracy table + name character error rate across fixtures pnpm scanner:fixtures [name-substring] # run detectors over matching fixtures, verbose pnpm scanner:replay # replay ffmpeg-extracted frames through the scheduler+detectors -pnpm scanner:scan-vod