Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,13 @@
button.abilityTrigger {
height: auto;
padding: var(--s-0-5);
background: var(--color-bg-higher);
border: 1px solid var(--color-border-high);
border-radius: var(--radius-selector);
& img {
display: block;
width: var(--field-size-icon);
height: var(--field-size-icon);
}
}

View File

@@ -0,0 +1,63 @@
/**
* Gear-ability grid (3 rows: head/clothes/shoes, each [main, sub, sub, sub])
* shared by the death card and the scoreboard player popover.
*/
import { Button } from "react-aria-components";
import { Ability } from "~/components/Ability";
import { SendouPopover } from "~/components/elements/Popover";
import type { AbilityWithUnknown } from "~/modules/in-game-lists/types";
import styles from "./AbilityGrid.module.css";
import eventCardStyles from "./EventCard.module.css";
const ROW_LABELS = ["head", "clothes", "shoes"] as const;
export function AbilityGrid({
abilities,
}: {
abilities: AbilityWithUnknown[][];
}) {
return (
<table className={eventCardStyles.players}>
<tbody>
{abilities.map((row, i) => (
<tr key={i}>
<td>{ROW_LABELS[i]}</td>
{row.map((id, j) => (
<td key={j}>
<Ability ability={id} size={j === 0 ? "SUBTINY" : "TINY"} />
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
/**
* Click-to-toggle popover showing a player's ability grid; the trigger is
* the head-main ability icon.
*/
export function AbilityPopover({
abilities,
}: {
abilities: AbilityWithUnknown[][];
}) {
const trigger = abilities[0]?.[0];
if (!trigger) return null;
return (
<SendouPopover
trigger={
<Button
className={styles.abilityTrigger}
aria-label="Show abilities (from death events)"
>
<Ability ability={trigger} size="TINY" />
</Button>
}
>
<AbilityGrid abilities={abilities} />
</SendouPopover>
);
}

View File

@@ -0,0 +1,40 @@
.body {
display: flex;
align-items: center;
gap: var(--s-3);
padding-block: var(--s-0-5);
}
.info {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
min-width: 0;
}
.name {
font-size: var(--font-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.weapon {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.abilities {
display: flex;
align-items: center;
gap: var(--s-2-5);
margin-inline-start: auto;
flex-wrap: wrap;
}
.gear {
display: flex;
align-items: center;
gap: var(--s-0-5);
}

View File

@@ -0,0 +1,82 @@
import clsx from "clsx";
import { Ability } from "~/components/Ability";
import { WeaponImage } from "~/components/Image";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import {
DEATH_EVENT_TYPE,
type DeathData,
} from "../core/detectors/death/index";
import styles from "./DeathCard.module.css";
import eventCardStyles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { weaponLabel } from "./labels";
import { MetaPills } from "./MetaChips";
export function DeathCard(props: {
t: number;
confidence: number;
data: DeathData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
onInspect?: () => void;
}) {
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
props;
const weaponName = weaponLabel(data.weaponType, data.weaponId);
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={eventCardStyles.card}>
<div className={eventCardStyles.meta}>
<MetaPills
t={t}
confidence={confidence}
type={DEATH_EVENT_TYPE}
label="death"
/>
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: "Death" }}
/>
</div>
<div className={clsx(eventCardStyles.teams, eventCardStyles.death)}>
<div className={eventCardStyles.team}>
<div className={styles.body}>
{data.weaponId !== null && data.weaponType === "MAIN" ? (
<WeaponImage
weaponSplId={data.weaponId as MainWeaponId}
variant="build"
size={28}
className={eventCardStyles.weaponIcon}
/>
) : null}
<div className={styles.info}>
<span className={styles.name}>
splatted by <b>{data.name ?? "?"}</b>
</span>
<span className={styles.weapon}>{weaponName ?? "?"}</span>
</div>
<div className={styles.abilities}>
{data.abilities.map((row, i) => (
<div key={i} className={styles.gear}>
{row.map((id, j) => (
<Ability
key={j}
ability={id}
size={j === 0 ? "SUBTINY" : "TINY"}
/>
))}
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,142 @@
/* card chrome shared by every detected-event card, plus the sendou.ink send
status strip the feed pages hang under one */
.card {
background: var(--color-bg-high);
border-radius: var(--radius-box);
/* MatchCard's nested event list tightens this */
padding: var(--scanner-card-padding, var(--s-4));
min-width: 0;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: var(--s-2) var(--s-4);
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
font-variant-numeric: tabular-nums;
margin-bottom: var(--s-2);
align-items: center;
}
.teams {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
gap: var(--s-2);
}
/* single hug-content box (own-results cards) */
.solo {
grid-template-columns: max-content;
}
/* single full-width box (death cards) */
.death {
grid-template-columns: 1fr;
}
/* lighter than a solid slab: translucent fill + hairline so the boxes read
as grouping, not chrome */
.team {
border-radius: var(--radius-field);
padding: var(--s-2) var(--s-2-5);
background: color-mix(in oklab, var(--color-bg) 45%, transparent);
border: 1px solid color-mix(in oklab, var(--color-border) 55%, transparent);
min-width: 0;
overflow-x: auto;
& h3 {
margin: 0 0 var(--s-1-5);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-high);
}
&.win h3 {
color: var(--color-error-high);
}
&.lose h3 {
color: var(--color-info-high);
}
}
.players {
width: 100%;
border-collapse: collapse;
font-size: var(--font-xs);
& td {
padding: var(--s-0-5) var(--s-1-5);
white-space: nowrap;
&.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
}
}
.solo .players {
width: auto;
}
.weaponCell {
display: inline-flex;
align-items: center;
gap: var(--s-1);
}
/* round black pucks, same language as the match card's weapon row */
.weaponIcon {
width: 28px;
height: 28px;
vertical-align: middle;
background: var(--color-bg-badge);
border-radius: var(--radius-full);
padding: var(--s-0-5);
}
/* sendou.ink send status strip under a feed card */
.sendWrap {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.sendStrip {
display: flex;
gap: var(--s-3);
align-items: center;
padding: var(--s-1) var(--s-4);
border-radius: var(--radius-box);
background: var(--color-bg-high);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.sent {
color: var(--color-success-high);
background: var(--color-success-low);
}
.failed {
color: var(--color-error-high);
background: var(--color-error-low);
}
.queued,
.sending {
color: var(--color-info-high);
background: var(--color-info-low);
}
.sendError {
color: inherit;
font-weight: var(--weight-body);
}

View File

@@ -0,0 +1,175 @@
/**
* Single dispatch point from a detected event to its card component, shared
* by the live feed and the VoD feed. Frames are loaded lazily through
* `getFrame` (IndexedDB keeps them out of the listed records); the Inspect
* action (open the frame in the screenshot page in a new browser tab, so
* the running scan is left undisturbed) is derived from it here so pages
* don't duplicate the wiring.
*/
import clsx from "clsx";
import { SCANNER_PAGE } from "~/utils/urls";
import type { PlayerAbilityMap } from "../core/ability-harvest";
import {
DEATH_EVENT_TYPE,
type DeathData,
} from "../core/detectors/death/index";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "../core/detectors/map-start/index";
import {
MINIMAP_EVENT_TYPE,
type MinimapData,
} from "../core/detectors/minimap/index";
import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
} from "../core/detectors/objective/player-status";
import {
STRIP_WEAPONS_EVENT_TYPE,
type StripWeaponsData,
} from "../core/detectors/objective/strip-weapons";
import type { ScoreboardData } from "../core/detectors/scoreboard/index";
import {
SCOREBOARD_OWN_EVENT_TYPE,
type ScoreboardOwnData,
} from "../core/detectors/scoreboard-own/index";
import { scannerSearchParams } from "../scanner-search-params";
import type { SendStatus } from "../store/events";
import { newInspectKey, putInspectFrame } from "../store/inspect";
import { DeathCard } from "./DeathCard";
import styles from "./EventCard.module.css";
import type { FixtureData } from "./fixture-export";
import { useEventTimeFormatter } from "./format";
import { MapStartCard } from "./MapStartCard";
import { MinimapCard } from "./MinimapCard";
import { ObjectiveCard } from "./ObjectiveCard";
import { PlayerStatusCard } from "./PlayerStatusCard";
import { ScoreboardCard } from "./ScoreboardCard";
import { ScoreboardOwnCard } from "./ScoreboardOwnCard";
import { StripWeaponsCard } from "./StripWeaponsCard";
export type GetFrame = () => Promise<Blob | null | undefined>;
export function EventCard(props: {
type: string;
t: number;
confidence: number;
data: FixtureData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame; enables Inspect + fixture export */
getFrame?: GetFrame;
/** Scoreboard only: abilities harvested from the match's death events */
abilities?: PlayerAbilityMap;
/** sendou.ink /ingest status of this event; absent = never attempted */
send?: SendStatus;
/** when set, shows a Send/Retry button that sends this event's match batch */
onSend?: () => void;
}) {
const { type, t, confidence, data, thumbnail, detectedAt, getFrame } = props;
// window.open must run synchronously in the click gesture (popup blockers);
// the frame write catches up and the new tab polls for it
const onInspect = getFrame
? () => {
const key = newInspectKey();
window.open(
scannerSearchParams.href(SCANNER_PAGE, {
tab: "screenshot",
inspect: key,
}),
"_blank",
);
void getFrame().then((frame) => {
if (frame) void putInspectFrame(key, frame);
});
}
: undefined;
const shared = { t, confidence, thumbnail, detectedAt, getFrame, onInspect };
const card = renderCard(type, data, shared, props.abilities);
if (!props.send && !props.onSend) return card;
return (
<div className={styles.sendWrap}>
{card}
<SendStrip send={props.send} onSend={props.onSend} />
</div>
);
}
const SEND_LABELS: Record<SendStatus["state"], string> = {
queued: "queued",
sending: "sending…",
sent: "sent",
unlinked: "waiting for report",
failed: "failed",
};
function SendStrip({
send,
onSend,
}: {
send?: SendStatus;
onSend?: () => void;
}) {
const state = send?.state;
const formatSentAt = useEventTimeFormatter();
return (
<div className={clsx(styles.sendStrip, state ? styles[state] : null)}>
<span>
sendou.ink: {state ? SEND_LABELS[state] : "not sent"}
{state === "sent" && send ? ` ${formatSentAt(send.at)}` : null}
</span>
{send?.error ? (
<span className={styles.sendError}>{send.error}</span>
) : null}
{onSend && state !== "sent" && state !== "sending" ? (
<button type="button" onClick={onSend}>
{state === "failed" ? "Retry" : "Send"}
</button>
) : null}
</div>
);
}
function renderCard(
type: string,
data: FixtureData,
shared: {
t: number;
confidence: number;
thumbnail?: string;
detectedAt?: number;
getFrame?: GetFrame;
onInspect?: () => void;
},
abilities?: PlayerAbilityMap,
) {
return type === DEATH_EVENT_TYPE ? (
<DeathCard {...shared} data={data as DeathData} />
) : type === MAP_START_EVENT_TYPE ? (
<MapStartCard {...shared} data={data as MapStartData} />
) : type === SCOREBOARD_OWN_EVENT_TYPE ? (
<ScoreboardOwnCard {...shared} data={data as ScoreboardOwnData} />
) : type === MINIMAP_EVENT_TYPE ? (
<MinimapCard {...shared} data={data as MinimapData} />
) : type === OBJECTIVE_EVENT_TYPE ? (
<ObjectiveCard {...shared} data={data as ObjectiveData} />
) : type === PLAYER_STATUS_EVENT_TYPE ? (
<PlayerStatusCard {...shared} data={data as PlayerStatusData} />
) : type === STRIP_WEAPONS_EVENT_TYPE ? (
<StripWeaponsCard {...shared} data={data as StripWeaponsData} />
) : (
<ScoreboardCard
{...shared}
eventType={type}
data={data as ScoreboardData}
abilities={abilities}
/>
);
}

View File

@@ -0,0 +1,47 @@
/**
* Shared lucide glyph per detected-event type, so the events summary line
* and the event cards mark each type with the same icon.
*/
import {
CircleHelp,
History,
type LucideIcon,
Map as MapIcon,
Play,
RotateCcw,
Skull,
Target,
Trophy,
User,
} from "lucide-react";
import { DEATH_EVENT_TYPE } from "../core/detectors/death/index";
import { MAP_START_EVENT_TYPE } from "../core/detectors/map-start/index";
import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index";
import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index";
import { SCOREBOARD_EVENT_TYPE } from "../core/detectors/scoreboard/index";
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log/index";
import { SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log-replay/index";
import { SCOREBOARD_OWN_EVENT_TYPE } from "../core/detectors/scoreboard-own/index";
const EVENT_TYPE_ICONS: Record<string, LucideIcon> = {
[MAP_START_EVENT_TYPE]: Play,
[DEATH_EVENT_TYPE]: Skull,
[MINIMAP_EVENT_TYPE]: MapIcon,
[OBJECTIVE_EVENT_TYPE]: Target,
[SCOREBOARD_EVENT_TYPE]: Trophy,
[SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE]: RotateCcw,
[SCOREBOARD_BATTLE_LOG_EVENT_TYPE]: History,
[SCOREBOARD_OWN_EVENT_TYPE]: User,
};
export function EventTypeIcon({
type,
size = 12,
}: {
type: string;
size?: number;
}) {
const Icon = EVENT_TYPE_ICONS[type] ?? CircleHelp;
return <Icon size={size} aria-hidden />;
}

View File

@@ -0,0 +1,37 @@
.summary {
display: flex;
align-items: center;
gap: var(--s-2-5);
flex-wrap: wrap;
padding: var(--s-0-5) var(--s-1);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.type {
display: inline-flex;
align-items: center;
gap: 5px;
font-variant-numeric: tabular-nums;
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: var(--radius-full);
background: var(--color-bg-higher);
color: var(--color-text-high);
}
button.toggle {
border: none;
background: transparent;
color: var(--color-text-accent);
height: auto;
padding: 0;
font-size: var(--font-2xs);
}

View File

@@ -0,0 +1,65 @@
/**
* One light line summarizing raw detections as per-type counts, with a
* toggle for their event card feed. Callers pass only events not covered
* by a match card — a fallback for detections not yet grouped, hidden
* entirely once everything lives in a match.
*/
import * as R from "remeda";
import { DEATH_EVENT_TYPE } from "../core/detectors/death/index";
import { MAP_START_EVENT_TYPE } from "../core/detectors/map-start/index";
import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index";
import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index";
import { SCOREBOARD_EVENT_TYPE } from "../core/detectors/scoreboard/index";
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log/index";
import { SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log-replay/index";
import { SCOREBOARD_OWN_EVENT_TYPE } from "../core/detectors/scoreboard-own/index";
import styles from "./EventsSummary.module.css";
import { EventTypeIcon } from "./EventTypeIcon";
const EVENT_TYPE_LABELS: Record<string, string> = {
[MAP_START_EVENT_TYPE]: "map start",
[DEATH_EVENT_TYPE]: "death",
[MINIMAP_EVENT_TYPE]: "minimap",
[OBJECTIVE_EVENT_TYPE]: "objective",
[SCOREBOARD_EVENT_TYPE]: "scoreboard",
[SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE]: "replay scoreboard",
[SCOREBOARD_BATTLE_LOG_EVENT_TYPE]: "battle log",
[SCOREBOARD_OWN_EVENT_TYPE]: "own result",
};
export function EventsSummary({
events,
open,
onToggle,
}: {
events: ReadonlyArray<{ type: string }>;
open: boolean;
onToggle: () => void;
}) {
const counts = R.countBy(events, (event) => event.type);
const sorted = Object.entries(counts).toSorted((a, b) => b[1] - a[1]);
return (
<div className={styles.summary}>
{sorted.map(([type, count]) => {
const label = EVENT_TYPE_LABELS[type] ?? type;
return (
<span
key={type}
className={styles.type}
title={`${count} ${label}${count === 1 ? "" : "s"}`}
>
<span className={styles.icon}>
<EventTypeIcon type={type} />
</span>
×{count}
</span>
);
})}
<button type="button" className={styles.toggle} onClick={onToggle}>
{open ? "Hide events" : "Show events"}
</button>
</div>
);
}

View File

@@ -0,0 +1,39 @@
/* an unstyled button so the image itself is the target */
button.thumbButton {
height: auto;
padding: 0;
border: none;
background: none;
margin-inline-start: auto;
border-radius: var(--radius-field);
&:hover {
filter: brightness(1.2);
}
}
.thumb {
display: block;
height: 40px;
width: auto;
border-radius: var(--radius-field);
}
.dialog {
width: max-content;
max-width: min(96vw, 1400px);
}
.frameFull {
display: block;
max-width: 100%;
max-height: calc(80dvh - 9rem);
border-radius: var(--radius-field);
}
.frameActions {
display: flex;
justify-content: center;
gap: var(--s-3);
margin-block-start: var(--s-3);
}

View File

@@ -0,0 +1,109 @@
/**
* Analyzed-frame preview in an event card's meta row. Clicking it opens the
* frame big in a dialog — the exact lossless frame once its lazy loader
* resolves, the thumbnail as a stand-in until then. The frame actions
* (Inspect, Save fixture) live under the dialog image.
*/
import { ExternalLink, FlaskConical } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import styles from "./FrameThumb.module.css";
import { type FixtureData, saveFixtureFromEvent } from "./fixture-export";
export function FrameThumb({
thumbnail,
getFrame,
onInspect,
fixture,
}: {
thumbnail?: string;
getFrame?: () => Promise<Blob | null | undefined>;
/** opens the frame in the screenshot page in a new browser tab */
onInspect?: () => void;
/** enables Save fixture: the event's payload and fixture type label */
fixture?: { data: FixtureData; type: string };
}) {
const [open, setOpen] = useState(false);
const [frameUrl, setFrameUrl] = useState<string | null>(null);
const frameUrlRef = useRef<string | null>(null);
useEffect(
() => () => {
if (frameUrlRef.current) URL.revokeObjectURL(frameUrlRef.current);
},
[],
);
if (!thumbnail) return null;
const show = () => {
setOpen(true);
if (!getFrame || frameUrlRef.current) return;
void getFrame().then((frame) => {
if (!frame || frameUrlRef.current) return;
frameUrlRef.current = URL.createObjectURL(frame);
setFrameUrl(frameUrlRef.current);
});
};
const onSaveFixture =
getFrame && fixture
? () =>
void getFrame().then(
(frame) =>
frame && saveFixtureFromEvent(frame, fixture.data, fixture.type),
)
: undefined;
return (
<>
<button
type="button"
className={styles.thumbButton}
title="View frame"
onClick={show}
>
<img className={styles.thumb} src={thumbnail} alt="analyzed frame" />
</button>
{open ? (
<SendouDialog
isDismissable
aria-label="Analyzed frame"
className={styles.dialog}
onClose={() => setOpen(false)}
>
<img
className={styles.frameFull}
src={frameUrl ?? thumbnail}
alt="analyzed frame"
/>
{onInspect || onSaveFixture ? (
<div className={styles.frameActions}>
{onInspect ? (
<SendouButton
size="small"
icon={<ExternalLink />}
onPress={onInspect}
>
Inspect
</SendouButton>
) : null}
{onSaveFixture ? (
<SendouButton
size="small"
variant="outlined"
icon={<FlaskConical />}
onPress={onSaveFixture}
>
Save fixture
</SendouButton>
) : null}
</div>
) : null}
</SendouDialog>
) : null}
</>
);
}

View File

@@ -0,0 +1,34 @@
/* the global select is full width, which would break the controls row */
.deviceSelect {
appearance: none;
width: auto;
max-width: 20rem;
border: var(--border-style);
border-radius: var(--radius-field);
background-color: var(--color-bg);
background-image: var(--field-icon-chevron);
background-position: center right var(--field-padding);
background-size: var(--field-size-icon) auto;
background-repeat: no-repeat;
color: var(--color-text);
cursor: pointer;
font-family: inherit;
font-size: var(--font-xs);
height: var(--field-size-sm);
padding: 0 calc(var(--field-size-icon) + var(--field-padding) + var(--s-2)) 0
var(--field-padding);
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
outline: none;
&:focus-within {
outline: var(--focus-ring);
outline-offset: 1px;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}

View File

@@ -0,0 +1,581 @@
import clsx from "clsx";
import { Camera, Ellipsis, FileText, Send, Trash2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { SendouButton } from "~/components/elements/Button";
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
import { GameTimeline } from "~/components/GameTimeline";
import {
listVideoInputs,
openVirtualCamera,
startSampler,
} from "../capture/sampler";
import { connectAbilities } from "../core/ability-harvest";
import { DEATH_EVENT_TYPE } from "../core/detectors/death/index";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "../core/detectors/map-start/index";
import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index";
import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index";
import { PLAYER_STATUS_EVENT_TYPE } from "../core/detectors/objective/player-status";
import { STRIP_WEAPONS_EVENT_TYPE } from "../core/detectors/objective/strip-weapons";
import { SCOREBOARD_EVENT_TYPES } from "../core/detectors/registry";
import type { DetectedEvent, GateResult } from "../core/detectors/types";
import type { BuiltMatch } from "../core/match-builder";
import {
buildScannerMatches,
ingestSkipReasons,
invalidObjectiveEvents,
} from "../core/match-builder";
import { TimelineBuilder } from "../core/timeline/index";
import scannerStyles from "../scanner.module.css";
import {
clearEvents,
deleteEvents,
listEvents,
loadEventFrame,
type StoredEvent,
saveEvent,
updateEventsSend,
} from "../store/events";
import { AnalyzerClient } from "../worker/client";
import { withoutRepeatEvents } from "./dedupe-events";
import { EventCard } from "./EventCard";
import { EventsSummary } from "./EventsSummary";
import { downloadEventsCsv } from "./events-csv";
import { type FixtureData, saveFixture } from "./fixture-export";
import styles from "./LivePage.module.css";
import { MatchCard } from "./MatchCard";
import { MatchLobbyTabs } from "./MatchLobbyTabs";
import { playerStatusTeams } from "./player-status-view";
import {
aggregateSendStatus,
matchContaining,
retryableUnlinkedMatches,
type SendouUser,
sendMatches,
unsentMatches,
} from "./sendou-ingest";
import { thumbnailFromBlob } from "./thumbnail";
const SAMPLE_FPS = 2;
/**
* How often a running capture rechecks whether a match sendou.ink could not
* link yet is due for another attempt (the backoff itself lives in
* sendou-ingest.ts).
*/
const UNLINKED_RETRY_TICK_MS = 15_000;
/** The scan knows the on-screen sides only, not who is playing. */
const SCANNER_TEAM_LABELS = ["Alpha", "Bravo"] as const;
/** Event types the ingested matches are built from — the only ones with a send status. */
const INGESTABLE_TYPES = [
MAP_START_EVENT_TYPE,
DEATH_EVENT_TYPE,
MINIMAP_EVENT_TYPE,
...SCOREBOARD_EVENT_TYPES,
];
type Status = "idle" | "loading" | "watching" | "detected" | "error";
export function LivePage({
sendouUser,
}: {
/** sendou.ink login shown in the header; undefined = probing, null = not logged in */
sendouUser: SendouUser | null | undefined;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const clientRef = useRef<AnalyzerClient | null>(null);
const timelineRef = useRef(new TimelineBuilder());
const storedIdsRef = useRef(new WeakMap<DetectedEvent, number>());
const latestParseRef = useRef<{ type: string; data: FixtureData } | null>(
null,
);
const gatesRef = useRef(new Map<string, GateResult>());
const stopRef = useRef<(() => void) | null>(null);
const retryTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// the open match is known to be a non-SZ mode, so counter reads are
// misreads of another mode's overlay and are not collected at all
const objectiveBlockedRef = useRef(false);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [deviceId, setDeviceId] = useState<string>("");
const [status, setStatus] = useState<Status>("idle");
const [gateScore, setGateScore] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [feed, setFeed] = useState<StoredEvent[]>([]);
const [running, setRunning] = useState(false);
const [sendouError, setSendouError] = useState<string | null>(null);
const [eventsOpen, setEventsOpen] = useState(false);
const [liveSend, setLiveSend] = useState(false);
const liveSendRef = useRef(false);
const sendingRef = useRef(false);
const refreshFeed = useCallback(() => {
void (async () => {
const events = await listEvents();
// objective reads grouped into a known non-SZ match slipped past the
// live block (e.g. the mode read arrived after them) — delete them
const invalid = new Set(
invalidObjectiveEvents(buildScannerMatches(events)),
);
if (invalid.size > 0) {
await deleteEvents(
[...invalid]
.map((event) => event.id)
.filter((id): id is number => id !== undefined),
);
}
setFeed(
events
.filter((event) => !invalid.has(event))
.sort(
(a, b) => b.detectedAt - a.detectedAt || (b.id ?? 0) - (a.id ?? 0),
),
);
})();
}, []);
useEffect(() => {
refreshFeed();
return () => {
stopRef.current?.();
if (retryTimerRef.current) clearInterval(retryTimerRef.current);
clientRef.current?.dispose();
};
}, [refreshFeed]);
/** Sends the matches `include` selects; serialized so sends never overlap. */
const send = async (
include: (built: BuiltMatch<StoredEvent>) => boolean,
{ manual = false } = {},
) => {
if (sendingRef.current) return;
sendingRef.current = true;
if (manual) setSendouError(null);
try {
const events = await listEvents();
const { sentMatches, failedMatches } = await sendMatches({
events,
include,
onStatus: refreshFeed,
});
if (manual && sentMatches + failedMatches === 0) {
setSendouError("nothing to send — no complete match selected");
}
} finally {
sendingRef.current = false;
refreshFeed();
}
};
/** `withLiveSend`: send each match to sendou.ink as it closes. */
const start = async (withLiveSend: boolean) => {
setError(null);
setSendouError(null);
setStatus("loading");
liveSendRef.current = withLiveSend;
setLiveSend(withLiveSend);
// a restart may land mid-another-match; collect until its mode is known
objectiveBlockedRef.current = false;
try {
const video = videoRef.current!;
const stream = await openVirtualCamera(deviceId || undefined);
video.srcObject = stream;
await video.play();
setDevices(await listVideoInputs());
clientRef.current ??= new AnalyzerClient(
(result) => {
// one result arrives per detector per frame; status reflects
// whether any of them fired
gatesRef.current.set(result.detector, result.gate);
const gates = [...gatesRef.current.values()];
setGateScore(Math.max(...gates.map((g) => g.score)));
if (!result.gate.pass) {
if (!gates.some((g) => g.pass)) setStatus("watching");
return;
}
setStatus("detected");
for (const event of result.events as DetectedEvent<FixtureData>[]) {
latestParseRef.current = { type: event.type, data: event.data };
if (
(event.type === OBJECTIVE_EVENT_TYPE ||
event.type === PLAYER_STATUS_EVENT_TYPE) &&
objectiveBlockedRef.current
) {
continue;
}
const action = timelineRef.current.push(event);
if (action.action === "added" || action.action === "replaced") {
if (event.type === MAP_START_EVENT_TYPE) {
const mode = (event.data as MapStartData).mode;
objectiveBlockedRef.current = mode !== null && mode !== "SZ";
} else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) {
objectiveBlockedRef.current = false;
}
const stale =
action.action === "replaced"
? storedIdsRef.current.get(action.replaced)
: undefined;
void (async () => {
const thumbnail = result.frame
? await thumbnailFromBlob(result.frame)
: undefined;
// reusing the replaced event's id keeps match card keys
// stable, so repeat detections don't remount the cards
const id = await saveEvent(
event,
thumbnail,
result.frame,
stale,
);
storedIdsRef.current.set(event, id);
if (
liveSendRef.current &&
INGESTABLE_TYPES.includes(event.type)
) {
if (SCOREBOARD_EVENT_TYPES.includes(event.type)) {
// a scoreboard closes its match — send it
refreshFeed();
await send(
(built) =>
matchContaining(id)(built) && unsentMatches(built),
);
} else {
await updateEventsSend([id], {
state: "queued",
at: Date.now(),
});
}
}
refreshFeed();
})();
}
}
},
(message) => {
setError(message);
setStatus("error");
},
);
await clientRef.current.whenReady();
stopRef.current = startSampler(video, SAMPLE_FPS, (bitmap, t) => {
clientRef.current?.analyze(bitmap, t);
});
// a match sent the moment its scoreboard closed usually beats the
// players to reporting the game, so sendou.ink had nothing to link
// it to; give those another go while the capture runs
retryTimerRef.current ??= setInterval(() => {
if (liveSendRef.current) void send(retryableUnlinkedMatches);
}, UNLINKED_RETRY_TICK_MS);
setStatus("watching");
setRunning(true);
} catch (e) {
setError(String(e));
setStatus("error");
}
};
const builtMatches = buildScannerMatches(feed);
const skipReasons = ingestSkipReasons(builtMatches);
const groupedEvents = new Set(builtMatches.flatMap((b) => b.sources));
// strip weapon evidence is assignment input, not a detection worth a card
const ungroupedFeed = feed.filter(
(e) => !groupedEvents.has(e) && e.type !== STRIP_WEAPONS_EVENT_TYPE,
);
const abilityMap = connectAbilities(feed);
const stop = () => {
stopRef.current?.();
stopRef.current = null;
if (retryTimerRef.current) clearInterval(retryTimerRef.current);
retryTimerRef.current = null;
const video = videoRef.current;
if (video?.srcObject instanceof MediaStream) {
for (const track of video.srcObject.getTracks()) track.stop();
video.srcObject = null;
}
setRunning(false);
setStatus("idle");
// the scan ending is the last match boundary — flush what's unsent
// (partials are safe: the server merges them into fuller resends)
if (liveSendRef.current) void send(unsentMatches);
liveSendRef.current = false;
setLiveSend(false);
};
return (
<div>
<div className={scannerStyles.controls}>
{!running ? (
<>
<button
type="button"
disabled={!sendouUser}
title={sendouUser ? undefined : "Log in on sendou.ink first"}
onClick={() => void start(true)}
>
<Send aria-hidden />
Start capture
</button>
<button
type="button"
className={scannerStyles.outlined}
onClick={() => void start(false)}
>
Start capture (no sending)
</button>
</>
) : (
<>
<button type="button" onClick={stop}>
Stop
</button>
<button
type="button"
className={scannerStyles.outlined}
disabled={!sendouUser}
title={sendouUser ? undefined : "Log in on sendou.ink first"}
onClick={() => {
liveSendRef.current = !liveSend;
setLiveSend(!liveSend);
}}
>
<Send aria-hidden />
{liveSend ? "Stop sending" : "Start sending"}
</button>
</>
)}
<select
className={styles.deviceSelect}
value={deviceId}
onChange={(e) => setDeviceId(e.target.value)}
>
<option value="">Default camera (OBS Virtual Camera)</option>
{devices.map((d) => (
<option key={d.deviceId} value={d.deviceId}>
{d.label || d.deviceId.slice(0, 8)}
</option>
))}
</select>
<span
className={clsx(scannerStyles.status, {
[scannerStyles.detected]: status === "detected",
[scannerStyles.watching]: status === "watching",
[scannerStyles.idle]:
status !== "detected" && status !== "watching",
})}
>
{status}
{gateScore !== null ? ` · gate ${gateScore.toFixed(2)}` : null}
</span>
{liveSend ? (
<span className={clsx(scannerStyles.status, scannerStyles.watching)}>
sending matches live
</span>
) : null}
<LiveMenu
canSaveFixture={running}
canSend={Boolean(sendouUser) && feed.length > 0}
hasEvents={feed.length > 0}
onSaveFixture={() =>
void saveFixture(videoRef.current!, latestParseRef.current)
}
onDownloadCsv={() =>
downloadEventsCsv(
`live-events-${new Date().toISOString().slice(0, 19).replaceAll(":", "-")}.csv`,
// feed is newest-first for display; export in chronological order
feed.toSorted(
(a, b) =>
a.detectedAt - b.detectedAt || (a.id ?? 0) - (b.id ?? 0),
),
)
}
onSendUnsent={() => void send(unsentMatches, { manual: true })}
onClearFeed={() => {
if (!window.confirm("Clear all detected events?")) return;
void clearEvents().then(refreshFeed);
}}
/>
</div>
{error ? <p className={scannerStyles.error}>{error}</p> : null}
{sendouError ? (
<p className={scannerStyles.error}>{sendouError}</p>
) : null}
<div className={scannerStyles.liveLayout}>
<video
ref={videoRef}
className={scannerStyles.preview}
muted
playsInline
/>
<div className={scannerStyles.feed}>
{feed.length === 0 ? (
<p className={scannerStyles.score}>No detections yet.</p>
) : null}
<MatchLobbyTabs
matches={builtMatches}
keyOf={(built) => built.sources[0]!.id!}
renderMatch={(built, justFormed) => {
const id = built.sources[0]!.id!;
const skipReason = skipReasons.get(built);
// counter reads render as one timeline chart, not a card each --
// from the builder's samples, whose sides are team-stable (raw
// reads follow the specced player on casts); a non-SZ match's
// reads (objective null) are never shown
const objectiveEvents = (
built.match.objective?.samples ?? []
).map((sample) => ({ t: sample.t, data: sample }));
const statusSamples = built.match.playerStatus?.samples ?? [];
const cardEvents = withoutRepeatEvents(built.sources).filter(
(e) =>
e.type !== OBJECTIVE_EVENT_TYPE &&
e.type !== PLAYER_STATUS_EVENT_TYPE &&
e.type !== STRIP_WEAPONS_EVENT_TYPE,
);
const newest = built === builtMatches.at(-1);
return (
<MatchCard
match={built.match}
live={running && newest && built.match.winner === null}
inProgress={newest && built.match.winner === null}
skipReason={skipReason}
justFormed={justFormed}
send={aggregateSendStatus(built.sources)}
onSend={
sendouUser && !skipReason
? () => void send(matchContaining(id), { manual: true })
: undefined
}
>
<GameTimeline
objectiveEvents={objectiveEvents}
playerStatusSamples={statusSamples}
teams={playerStatusTeams(built.match, SCANNER_TEAM_LABELS)}
/>
{cardEvents.map((e) => (
<EventCard
key={e.id}
type={e.type}
t={e.t}
confidence={e.confidence}
data={e.data as FixtureData}
abilities={abilityMap.get(e)}
thumbnail={e.thumbnail}
detectedAt={e.detectedAt}
getFrame={
e.hasFrame && e.id !== undefined
? () => loadEventFrame(e.id!)
: undefined
}
/>
))}
</MatchCard>
);
}}
/>
{ungroupedFeed.length > 0 ? (
<EventsSummary
events={ungroupedFeed}
open={eventsOpen}
onToggle={() => setEventsOpen(!eventsOpen)}
/>
) : null}
{eventsOpen
? ungroupedFeed.map((e) => (
<EventCard
key={e.id}
type={e.type}
t={e.t}
confidence={e.confidence}
data={e.data as FixtureData}
abilities={abilityMap.get(e)}
thumbnail={e.thumbnail}
detectedAt={e.detectedAt}
getFrame={
e.hasFrame && e.id !== undefined
? () => loadEventFrame(e.id!)
: undefined
}
send={e.send}
onSend={
sendouUser &&
e.id !== undefined &&
INGESTABLE_TYPES.includes(e.type)
? () =>
void send(matchContaining(e.id!), { manual: true })
: undefined
}
/>
))
: null}
</div>
</div>
</div>
);
}
/** The capture's occasional actions, behind one icon-only menu. */
function LiveMenu({
canSaveFixture,
canSend,
hasEvents,
onSaveFixture,
onDownloadCsv,
onSendUnsent,
onClearFeed,
}: {
canSaveFixture: boolean;
canSend: boolean;
hasEvents: boolean;
onSaveFixture: () => void;
onDownloadCsv: () => void;
onSendUnsent: () => void;
onClearFeed: () => void;
}) {
return (
<SendouMenu
trigger={
<SendouButton
icon={<Ellipsis />}
className={scannerStyles.iconMenu}
aria-label="More actions"
/>
}
>
<SendouMenuItem
icon={<Camera />}
isDisabled={!canSaveFixture}
onAction={onSaveFixture}
>
Save frame as fixture
</SendouMenuItem>
<SendouMenuItem
icon={<FileText />}
isDisabled={!hasEvents}
onAction={onDownloadCsv}
>
CSV
</SendouMenuItem>
<SendouMenuItem
icon={<Send />}
isDisabled={!canSend}
onAction={onSendUnsent}
>
Send unsent to sendou.ink
</SendouMenuItem>
<SendouMenuItem
icon={<Trash2 />}
isDestructive
isDisabled={!hasEvents}
onAction={onClearFeed}
>
Clear feed
</SendouMenuItem>
</SendouMenu>
);
}

View File

@@ -0,0 +1,46 @@
import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "../core/detectors/map-start/index";
import styles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { modeLabel, stageLabel } from "./labels";
import { MetaPills } from "./MetaChips";
export function MapStartCard(props: {
t: number;
confidence: number;
data: MapStartData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
onInspect?: () => void;
}) {
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
props;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={styles.card}>
<div className={styles.meta}>
<MetaPills
t={t}
confidence={confidence}
type={MAP_START_EVENT_TYPE}
label="map start"
/>
<span>
<b>{modeLabel(data.mode) ?? "?"}</b> · {stageLabel(data.stage) ?? "?"}
</span>
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: "MapStart" }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,391 @@
.group {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.matchCard {
container: match-card / inline-size;
position: sticky;
top: var(--layout-sticky-top);
z-index: 1;
overflow: hidden;
border-radius: var(--radius-box);
background-color: var(--color-bg-high);
/* only cards the scan just formed animate in — see MatchCard's `enter` */
&.enter {
animation: scanner-card-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
}
/* one overlay element all send-state effects draw on */
&::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
}
&.sending {
box-shadow: inset 0 0 0 1px var(--color-info-low);
&::after {
background-image: linear-gradient(
105deg,
transparent 40%,
rgb(255 255 255 / 0.09) 50%,
transparent 60%
);
background-size: 250% 100%;
animation: scanner-sheen 1.4s linear infinite;
}
}
&.flashSent::after {
animation: scanner-sent-pop 0.8s ease-out both;
}
&.live {
box-shadow: inset 0 0 0 1px var(--color-info-low);
}
}
@keyframes scanner-card-in {
from {
opacity: 0;
transform: translateY(10px) scale(0.98);
}
}
@keyframes scanner-sheen {
from {
background-position: 130% 0;
}
to {
background-position: -130% 0;
}
}
@keyframes scanner-sent-pop {
0% {
opacity: 1;
box-shadow:
inset 0 0 0 2px var(--color-success),
inset 0 0 32px var(--color-success-low);
}
100% {
opacity: 0;
box-shadow:
inset 0 0 0 2px var(--color-success),
inset 0 0 32px var(--color-success-low);
}
}
@keyframes scanner-shake {
20% {
transform: translateX(-4px);
}
50% {
transform: translateX(4px);
}
80% {
transform: translateX(-2px);
}
}
.main {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--s-2) var(--s-3);
padding: var(--s-3) var(--s-4);
min-height: 66px;
}
.matchCard.flashFailed .main {
animation: scanner-shake 0.35s ease;
}
.mode {
flex-shrink: 0;
filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.5));
}
.headline {
display: flex;
flex-direction: column;
gap: var(--s-1);
/* claims row space so the score/chips can never crush the stage name
out of view in a narrow feed column; wraps instead */
flex: 1 1 0;
min-width: 150px;
}
.title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--s-1) var(--s-2);
}
.stage {
font-size: var(--font-sm);
font-weight: var(--weight-extra);
line-height: 1.1;
}
.meta {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.weapons {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--s-0-5);
& .vs {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
margin: 0 5px;
}
}
/* a team is one wrapping unit: the eight never break up mid-team */
.weaponRow {
display: flex;
align-items: center;
gap: var(--s-0-5);
}
.weapon {
flex-shrink: 0;
width: 32px;
height: 32px;
vertical-align: middle;
background: var(--color-bg-badge);
border-radius: var(--radius-full);
padding: var(--s-0-5);
&.pov {
outline: var(--border-style-high);
background-color: var(--color-bg-high);
outline-offset: 1px;
}
}
.side {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--s-1-5);
margin-inline-start: auto;
align-self: stretch;
flex-shrink: 0;
}
.matchScore {
font-weight: var(--weight-extra);
font-variant-numeric: tabular-nums;
line-height: 1;
padding: 5px var(--s-3);
border-radius: var(--radius-full);
background: color-mix(in oklab, var(--color-bg) 72%, transparent);
backdrop-filter: blur(8px);
& .win {
color: var(--color-error-high);
}
& .lose {
color: var(--color-info-high);
}
}
/* undo the scanner-wide solid button look for the banner icon button */
button.expand {
flex-shrink: 0;
margin-block-start: auto;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: var(--radius-full);
background: color-mix(in oklab, var(--color-bg) 55%, transparent);
backdrop-filter: blur(8px);
color: var(--color-text-high);
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
& svg {
width: var(--field-size-icon);
height: var(--field-size-icon);
}
&:hover {
color: var(--color-text);
background: color-mix(in oklab, var(--color-bg) 78%, transparent);
}
&.expanded {
transform: rotate(180deg);
}
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--s-1-5);
padding: var(--s-0-5) var(--s-2-5);
border-radius: var(--radius-full);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
border: var(--border-style);
background-color: var(--color-bg);
color: var(--color-text-high);
white-space: nowrap;
& .dot {
width: 7px;
height: 7px;
border-radius: var(--radius-full);
background: currentColor;
}
&.live {
color: var(--color-error);
border-color: var(--color-error-low);
text-transform: uppercase;
letter-spacing: 0.08em;
& .dot {
animation: scanner-pulse 1.4s ease-in-out infinite;
}
}
&.inProgress .dot {
animation: scanner-pulse 1.4s ease-in-out infinite;
}
&.queued,
&.sending {
color: var(--color-info-high);
border-color: var(--color-info-low);
background-color: var(--color-info-low);
& .dot {
animation: scanner-pulse 1.4s ease-in-out infinite;
}
}
&.sent {
color: var(--color-success-high);
border-color: var(--color-success-low);
background-color: var(--color-success-low);
& a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
color: var(--color-text-high);
}
}
}
&.unlinked {
color: var(--color-warning-high);
border-color: var(--color-warning-low);
background-color: var(--color-warning-low);
}
&.failed {
color: var(--color-error-high);
border-color: var(--color-error-low);
background-color: var(--color-error-low);
}
}
@keyframes scanner-pulse {
50% {
opacity: 0.25;
transform: scale(0.8);
}
}
.error {
padding: 0 var(--s-4) var(--s-2-5);
font-size: var(--font-2xs);
color: var(--color-error);
}
/* source event cards, nested under their match card via an indent rail */
.events {
--scanner-card-padding: var(--s-2-5) 14px;
display: flex;
flex-direction: column;
gap: var(--s-2);
margin-inline-start: var(--s-2-5);
padding-inline-start: var(--s-3);
border-inline-start: var(--border-width) solid var(--color-border);
animation: scanner-detail-in 0.25s ease both;
}
@keyframes scanner-detail-in {
from {
opacity: 0;
transform: translateY(-4px);
}
}
.setDivider {
display: flex;
align-items: center;
gap: var(--s-2-5);
margin-top: var(--s-1-5);
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
text-transform: uppercase;
letter-spacing: 0.08em;
&::before,
&::after {
content: "";
flex: 1;
height: var(--border-width);
background-color: var(--color-border);
}
}
/* the two teams' weapons no longer fit beside each other: one row each */
@container match-card (width < 460px) {
.weapons {
flex-direction: column;
align-items: flex-start;
row-gap: var(--s-1);
& .vs {
display: none;
}
}
}
@media (prefers-reduced-motion: reduce) {
.matchCard,
.matchCard.sending::after,
.matchCard.flashSent::after,
.matchCard.flashFailed .main,
.events,
.chip.live .dot,
.chip.inProgress .dot,
.chip.queued .dot,
.chip.sending .dot {
animation: none;
}
}

View File

@@ -0,0 +1,352 @@
/**
* Glanceable card for one ScannerMatch in the live feed: stage banner
* background, mode + stage, score, team weapons, and the match's /ingest
* status. Expanding the card reveals the source event cards below it,
* so the raw per-event view stays one click away.
*/
import clsx from "clsx";
import { ChevronDown } from "lucide-react";
import type * as React from "react";
import { useState } from "react";
import { SendouButton } from "~/components/elements/Button";
import { ModeImage, WeaponImage } from "~/components/Image";
import { matchScoresFromObjective } from "~/components/objective-timeline-utils";
import { StageBannerBox } from "~/components/StageBannerBox";
import type { IngestedMatchLink } from "~/features/scanner-ingest/scanner-ingest-schemas";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { sendouQMatchPage, tournamentMatchPage } from "~/utils/urls";
import type { IngestSkipReason } from "../core/match-builder";
import type { ScannerMatch } from "../core/scanner-match";
import type { SendStatus } from "../store/events";
import { formatTime, useEventTimeFormatter } from "./format";
import { lobbyLabel, modeLabel, stageLabel } from "./labels";
import styles from "./MatchCard.module.css";
/** the game score a knockout wins at */
const KO_MATCH_SCORE = 100;
const SEND_CHIP_LABELS: Record<Exclude<SendStatus["state"], "sent">, string> = {
queued: "queued",
sending: "sending…",
unlinked: "waiting for report",
failed: "failed",
};
export function MatchCard({
match,
send,
onSend,
live = false,
inProgress = false,
skipReason,
justFormed = false,
children,
}: {
match: ScannerMatch;
/** the match's /ingest status, aggregated from its source events */
send?: SendStatus;
/** when set, shows a Send/Retry button for this match */
onSend?: () => void;
/** still being played: no closing scoreboard yet and the scan is running */
live?: boolean;
/**
* the newest match, still being formulated (no result yet) — shows an
* "in progress" chip in the score slot; at most one card should get this
*/
inProgress?: boolean;
/** set = ingestSkipReasons held the match back from /ingest */
skipReason?: IngestSkipReason;
/** the scan just formed this match — play the enter animation */
justFormed?: boolean;
/** expandable detail content, typically the source event cards */
children?: React.ReactNode;
}) {
const [expanded, setExpanded] = useState(false);
// fixed at mount: re-rendering must not cut the animation short, and a card
// remounting for another reason (switching lobby tabs) must not replay it
const [enter] = useState(justFormed);
// one-shot flash animations only on a state *change*, so already-sent
// matches don't replay the glow on every mount
const [prevSendState, setPrevSendState] = useState(send?.state);
const [flash, setFlash] = useState<"sent" | "failed" | null>(null);
if (prevSendState !== send?.state) {
setPrevSendState(send?.state);
setFlash(
send?.state === "sent" || send?.state === "failed" ? send.state : null,
);
}
const meta = [
modeLabel(match.mode),
skipReason === "lobby" ? lobbyLabel(match.lobby) : null,
match.startsAt !== null ? timeRangeLabel(match) : null,
match.replayCode,
match.cast ? "cast" : null,
]
.filter(Boolean)
.join(" · ");
const inner = (
<>
<div className={styles.main}>
{match.mode !== null ? (
<ModeImage mode={match.mode} size={30} className={styles.mode} />
) : null}
<div className={styles.headline}>
<div className={styles.title}>
<div className={styles.stage}>
{stageLabel(match.stage) ?? "Unknown stage"}
</div>
<StatusChip send={send} skipReason={skipReason} live={live} />
</div>
{meta ? <div className={styles.meta}>{meta}</div> : null}
<TeamWeapons match={match} />
</div>
<div className={styles.side}>
{live ? (
<span className={clsx(styles.chip, styles.live)}>
<span className={styles.dot} />
live
</span>
) : (
<Score match={match} inProgress={inProgress} />
)}
{onSend && send?.state !== "sent" && send?.state !== "sending" ? (
<button type="button" onClick={onSend}>
{send?.state === "failed" || send?.state === "unlinked"
? "Retry"
: "Send"}
</button>
) : null}
{children ? (
<SendouButton
variant="minimal"
size="small"
shape="circle"
icon={<ChevronDown />}
className={clsx(styles.expand, { [styles.expanded]: expanded })}
aria-expanded={expanded}
aria-label={expanded ? "Hide events" : "Show events"}
onPress={() => setExpanded(!expanded)}
/>
) : null}
</div>
</div>
{send?.state === "failed" && send.error ? (
<div className={styles.error}>{send.error}</div>
) : null}
</>
);
const className = clsx(
styles.matchCard,
send?.state ? styles[send.state] : null,
{
[styles.enter]: enter,
[styles.live]: live,
[styles.flashSent]: flash === "sent",
[styles.flashFailed]: flash === "failed",
},
);
const card =
match.stage !== null ? (
<StageBannerBox stageId={match.stage} className={className}>
{inner}
</StageBannerBox>
) : (
<div className={className}>{inner}</div>
);
if (!children) return card;
return (
<div className={styles.group}>
{card}
{expanded ? <div className={styles.events}>{children}</div> : null}
</div>
);
}
/** Labeled rule above the newest card of each set in the feed. */
export function SetDivider({ number }: { number: number }) {
return <div className={styles.setDivider}>Set {number}</div>;
}
function timeRangeLabel(match: ScannerMatch): string {
const start = formatTime(match.startsAt!);
return match.endsAt !== null && match.endsAt !== match.startsAt
? `${start}–${formatTime(match.endsAt)}`
: start;
}
function Score({
match,
inProgress,
}: {
match: ScannerMatch;
inProgress: boolean;
}) {
if (match.matchScores === null) {
if (!inProgress) return null;
return (
<span className={clsx(styles.chip, styles.inProgress)}>
<span className={styles.dot} />
in progress
</span>
);
}
const [alpha, bravo] = match.matchScores;
const objectiveScores = matchScoresFromObjective(
match.objective?.samples ?? [],
);
// scoreboard-sourced matches list the winners first
const winnerKnown = match.winner !== null;
return (
<div className={styles.matchScore}>
<span className={winnerKnown ? styles.win : undefined}>
{scoreLabel(alpha, objectiveScores[0])}
</span>
<span> – </span>
<span className={winnerKnown ? styles.lose : undefined}>
{scoreLabel(bravo, objectiveScores[1])}
</span>
</div>
);
}
/**
* A 100 only happens on a knockout, shown the way players say it. A knockout's
* loser gets no score of its own, so the objective counter's last read stands
* in — parenthesized, since the scan read it off the video and may have lost
* sight of the counter before the game ended.
*/
function scoreLabel(
score: number | null,
objectiveScore: number | null,
): string {
if (score !== null && score > 0) {
return score === KO_MATCH_SCORE ? "KO" : String(score);
}
if (objectiveScore !== null) {
return objectiveScore === KO_MATCH_SCORE ? "(KO)" : `(${objectiveScore})`;
}
return score === null ? "?" : String(score);
}
interface TeamWeapon {
weaponId: MainWeaponId;
/** the scan's own player — highlighted among the eight */
pov: boolean;
}
function TeamWeapons({ match }: { match: ScannerMatch }) {
const weaponsOf = (team: 0 | 1): TeamWeapon[] =>
match.teams[team].players
.map((player, index) => ({
weaponId: player.weaponId,
pov: match.pov?.team === team && match.pov.index === index,
}))
.filter((weapon): weapon is TeamWeapon => weapon.weaponId !== null);
const alpha = weaponsOf(0);
const bravo = weaponsOf(1);
if (alpha.length + bravo.length === 0) return null;
return (
<div className={styles.weapons}>
{alpha.length > 0 ? <WeaponRow weapons={alpha} /> : null}
{alpha.length > 0 && bravo.length > 0 ? (
<span className={styles.vs}>vs</span>
) : null}
{bravo.length > 0 ? <WeaponRow weapons={bravo} /> : null}
</div>
);
}
/** One team's weapons, kept together when the card is too narrow for both. */
function WeaponRow({ weapons }: { weapons: TeamWeapon[] }) {
return (
<div className={styles.weaponRow}>
{weapons.map((weapon, i) => (
<WeaponImage
key={i}
weaponSplId={weapon.weaponId}
variant="build"
size={22}
className={clsx(styles.weapon, { [styles.pov]: weapon.pov })}
/>
))}
</div>
);
}
function StatusChip({
send,
skipReason,
live,
}: {
send?: SendStatus;
skipReason?: IngestSkipReason;
live: boolean;
}) {
const formatSentAt = useEventTimeFormatter();
if (skipReason) {
return (
<span className={styles.chip}>
{skipReason === "disconnect" ? "disconnect" : "not ingested"}
</span>
);
}
if (send?.state === "sent") {
return (
<span
className={clsx(styles.chip, styles.sent)}
title={`ingested ${formatSentAt(send.at)}`}
>
✓
{send.link ? (
<a
href={ingestedMatchUrl(send.link)}
target="_blank"
rel="noreferrer"
>
{ingestedMatchLabel(send.link)}
</a>
) : null}
</span>
);
}
if (send) {
return (
<span
className={clsx(styles.chip, styles[send.state])}
title={send.error}
>
{send.state === "queued" || send.state === "sending" ? (
<span className={styles.dot} />
) : null}
{SEND_CHIP_LABELS[send.state]}
</span>
);
}
if (live) return null;
return <span className={styles.chip}>not sent</span>;
}
function ingestedMatchUrl(link: IngestedMatchLink): string {
return link.type === "tournament"
? tournamentMatchPage({
tournamentId: link.tournamentId,
matchId: link.matchId,
})
: sendouQMatchPage(link.groupMatchId);
}
function ingestedMatchLabel(link: IngestedMatchLink): string {
return link.type === "tournament"
? `Match ID #${link.matchId}`
: `SQ Match ID #${link.groupMatchId}`;
}

View File

@@ -0,0 +1,5 @@
.matchList {
display: flex;
flex-direction: column;
gap: var(--s-3);
}

View File

@@ -0,0 +1,138 @@
/**
* Lobby tabs over the match feed: private battles (the games /ingest cares
* about) split from X Battles and everything else, so a scan that picked up
* ranked play between tournament sets stays readable. Sets are a private
* battle concept — consecutive games between the same two teams — so only
* that tab gets set dividers, and set numbers are assigned within the tab.
*/
import { Fragment, useEffect, useRef } from "react";
import {
SendouTab,
SendouTabList,
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import type { DetectedEvent } from "../core/detectors/types";
import type { BuiltMatch } from "../core/match-builder";
import { assignMatchSets } from "../core/match-sets";
import type { ScannerLobby } from "../scanner-types";
import { SetDivider } from "./MatchCard";
import styles from "./MatchLobbyTabs.module.css";
type LobbyGroup = "private" | "x" | "other";
const LOBBY_GROUPS: LobbyGroup[] = ["private", "x", "other"];
const LOBBY_GROUP_LABELS: Record<LobbyGroup, string> = {
private: "Private Battle",
x: "X Battle",
other: "Other",
};
const NO_KEYS: ReadonlySet<React.Key> = new Set();
export function MatchLobbyTabs<E extends DetectedEvent>({
matches,
keyOf,
renderMatch,
}: {
/** built matches in chronological order (oldest first) */
matches: readonly BuiltMatch<E>[];
/** stable render key for one match, typically its first source event's id */
keyOf: (built: BuiltMatch<E>) => React.Key;
/** `justFormed`: the match appeared while the page was open (enter animation) */
renderMatch: (built: BuiltMatch<E>, justFormed: boolean) => React.ReactNode;
}) {
const justFormedKeys = useJustFormedKeys(matches.map(keyOf));
const groups = LOBBY_GROUPS.map((group) => ({
group,
matches: matches.filter((built) => lobbyGroup(built.match.lobby) === group),
})).filter(({ matches: groupMatches }) => groupMatches.length > 0);
if (groups.length === 0) return null;
return (
<SendouTabs>
<SendouTabList>
{groups.map(({ group, matches: groupMatches }) => (
<SendouTab key={group} id={group} number={groupMatches.length}>
{LOBBY_GROUP_LABELS[group]}
</SendouTab>
))}
</SendouTabList>
{groups.map(({ group, matches: groupMatches }) => (
<SendouTabPanel key={group} id={group} className={styles.matchList}>
<MatchList
matches={groupMatches}
sets={group === "private"}
keyOf={keyOf}
justFormedKeys={justFormedKeys}
renderMatch={renderMatch}
/>
</SendouTabPanel>
))}
</SendouTabs>
);
}
function lobbyGroup(lobby: ScannerLobby | null): LobbyGroup {
if (lobby === "PRIVATE") return "private";
if (lobby === "X") return "x";
return "other";
}
/**
* Keys of the matches that showed up since the previous render — the ones a
* scan just formed. A list arriving whole (the live feed loaded from storage,
* a saved VoD opened) is not "just formed": every card would animate in at
* once for something the user did not watch happen.
*/
function useJustFormedKeys(keys: React.Key[]): ReadonlySet<React.Key> {
const seenRef = useRef<Set<React.Key> | null>(null);
const seen = seenRef.current;
// after commit, not during render: under StrictMode the render runs twice
// and the second pass would find every key already seen
useEffect(() => {
seenRef.current = new Set(keys);
});
if (seen === null) return NO_KEYS;
const justFormed = keys.filter((key) => !seen.has(key));
return justFormed.length === keys.length && keys.length > 1
? NO_KEYS
: new Set(justFormed);
}
function MatchList<E extends DetectedEvent>({
matches,
sets,
keyOf,
justFormedKeys,
renderMatch,
}: {
matches: readonly BuiltMatch<E>[];
sets: boolean;
keyOf: (built: BuiltMatch<E>) => React.Key;
justFormedKeys: ReadonlySet<React.Key>;
renderMatch: (built: BuiltMatch<E>, justFormed: boolean) => React.ReactNode;
}) {
const setNumbers = sets ? assignMatchSets(matches.map((b) => b.match)) : [];
const showSetDividers = (setNumbers.at(-1) ?? 1) > 1;
// newest match on top; the builder keeps ascending time order
return [...matches].reverse().map((built, reverseIndex) => {
const index = matches.length - 1 - reverseIndex;
const key = keyOf(built);
return (
<Fragment key={key}>
{showSetDividers && setNumbers[index + 1] !== setNumbers[index] ? (
<SetDivider number={setNumbers[index]!} />
) : null}
{renderMatch(built, justFormedKeys.has(key))}
</Fragment>
);
});
}

View File

@@ -0,0 +1,27 @@
/* time + confidence + event type read as one unit, the detail text after it
sits further away */
.pills {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-1);
}
/* neutral chip, the icon carries the accent and documents the value — full
label on hover via title */
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: var(--s-0-5) var(--s-2-5);
border-radius: var(--radius-full);
border: var(--border-style);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
& svg {
color: var(--color-text-accent);
}
}

View File

@@ -0,0 +1,39 @@
/**
* The pill group every event card's meta row opens with: video timestamp,
* detection confidence and the event type, kept tight together so the free-form
* detail text that follows reads as a separate group.
*/
import { Clock, Gauge } from "lucide-react";
import { EventTypeIcon } from "./EventTypeIcon";
import { formatTime } from "./format";
import styles from "./MetaChips.module.css";
export function MetaPills({
t,
confidence,
type,
label,
}: {
t: number;
confidence: number;
type: string;
label: string;
}) {
return (
<div className={styles.pills}>
<span className={styles.chip} title="Video timestamp">
<Clock size={12} aria-hidden />
{formatTime(t)}
</span>
<span className={styles.chip} title="Detection confidence">
<Gauge size={12} aria-hidden />
{(confidence * 100).toFixed(0)}%
</span>
<span className={styles.chip}>
<EventTypeIcon type={type} />
{label}
</span>
</div>
);
}

View File

@@ -0,0 +1,96 @@
.player {
display: flex;
align-items: center;
gap: var(--s-2);
padding-block: 3px;
font-size: var(--font-xs);
}
.weaponMissing {
flex-shrink: 0;
width: 24px;
text-align: center;
color: var(--color-text-high);
}
/* the minimap cards are tighter than the scanner's default weapon puck */
.weapon {
flex-shrink: 0;
width: 24px;
height: 24px;
vertical-align: middle;
background: var(--color-bg-badge);
border-radius: var(--radius-full);
padding: var(--s-0-5);
}
.name {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.abilities {
display: flex;
align-items: center;
gap: var(--s-0-5);
flex-shrink: 0;
}
.statusChip {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: var(--radius-full);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
line-height: 1;
&.dead {
background: var(--color-error-low);
color: var(--color-error-high);
}
&.special {
background: var(--color-warning-low);
color: var(--color-warning-high);
}
}
/* d-pad chevron / face-button pucks, same round-solid language as weapon icons */
.slotMarker {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: var(--radius-full);
background: var(--color-bg-badge);
color: #fff;
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
line-height: 1;
& svg {
width: 13px;
height: 13px;
}
&.right svg {
transform: rotate(90deg);
}
&.down svg {
transform: rotate(180deg);
}
&.left svg {
transform: rotate(-90deg);
}
}

View File

@@ -0,0 +1,159 @@
import clsx from "clsx";
import { ChevronUp } from "lucide-react";
import type { ReactNode } from "react";
import { Ability } from "~/components/Ability";
import { WeaponImage } from "~/components/Image";
import type { AbilityWithUnknown } from "~/modules/in-game-lists/types";
import {
MINIMAP_EVENT_TYPE,
type MinimapData,
type MinimapEnemy,
type MinimapTeammate,
} from "../core/detectors/minimap/index";
import type { CardSlot } from "../core/detectors/minimap/rois";
import eventCardStyles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { stageLabel } from "./labels";
import { MetaPills } from "./MetaChips";
import styles from "./MinimapCard.module.css";
const ENEMY_SLOT_LETTERS = ["A", "B", "X", "Y"] as const;
function AbilityRow({
abilities,
}: {
abilities: (AbilityWithUnknown | null)[];
}) {
return (
<>
{abilities.map((id, i) =>
id ? (
<Ability key={i} ability={id} size="TINY" />
) : (
<span key={i} title="unreadable badge">
?
</span>
),
)}
</>
);
}
function TeammateMarker({ slot }: { slot: CardSlot }) {
if (slot === "self") {
return <span className={styles.slotMarker}>●</span>;
}
return (
<span className={clsx(styles.slotMarker, styles[slot])}>
<ChevronUp strokeWidth={3.5} aria-label={slot} role="img" />
</span>
);
}
function PlayerRow({
marker,
player,
}: {
marker: ReactNode;
player: MinimapTeammate | MinimapEnemy;
}) {
return (
<div className={styles.player}>
{marker}
{player.weaponId !== null ? (
<WeaponImage
weaponSplId={player.weaponId}
variant="build"
size={24}
className={styles.weapon}
/>
) : (
<span className={styles.weaponMissing}>?</span>
)}
<span className={styles.name}>{player.name ?? ""}</span>
{player.dead ? (
<span
className={clsx(styles.statusChip, styles.dead)}
title="respawning (struck out)"
>
✕
</span>
) : null}
{player.specialReady ? (
<span
className={clsx(styles.statusChip, styles.special)}
title="special ready (camo)"
>
★
</span>
) : null}
<span className={styles.abilities}>
<AbilityRow abilities={player.abilities} />
</span>
</div>
);
}
export function MinimapCard(props: {
t: number;
confidence: number;
data: MinimapData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
onInspect?: () => void;
}) {
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
props;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={eventCardStyles.card}>
<div className={eventCardStyles.meta}>
<MetaPills
t={t}
confidence={confidence}
type={MINIMAP_EVENT_TYPE}
label="minimap"
/>
{data.stage !== null ? <span>{stageLabel(data.stage)}</span> : null}
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: "Minimap" }}
/>
</div>
<div className={eventCardStyles.teams}>
<div className={eventCardStyles.team}>
<h3>Team</h3>
{data.teammates.map((p) => (
<PlayerRow
key={p.slot}
marker={<TeammateMarker slot={p.slot} />}
player={p}
/>
))}
</div>
{data.enemies.length > 0 ? (
<div className={eventCardStyles.team}>
<h3>Enemies</h3>
{data.enemies.map((p, i) => (
<PlayerRow
key={i}
marker={
<span className={styles.slotMarker}>
{ENEMY_SLOT_LETTERS[i] ?? i + 1}
</span>
}
player={p}
/>
))}
</div>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import styles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { formatClock, useEventTimeFormatter } from "./format";
import { MetaPills } from "./MetaChips";
export function ObjectiveCard(props: {
t: number;
confidence: number;
data: ObjectiveData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
onInspect?: () => void;
}) {
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
props;
const side = (index: 0 | 1) => {
const score = data.score[index] ?? "?";
const penalty =
data.penalty[index] !== null ? ` (+${data.penalty[index]})` : "";
return `${score}${penalty}`;
};
const holder = data.control.findIndex(Boolean);
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={styles.card}>
<div className={styles.meta}>
<MetaPills
t={t}
confidence={confidence}
type={OBJECTIVE_EVENT_TYPE}
label="objective"
/>
<span>
{data.time !== null ? `${formatClock(data.time)} · ` : null}
<b>
{side(0)} – {side(1)}
</b>
{holder >= 0
? ` · ${holder === 0 ? "alpha" : "bravo"} in control`
: null}
</span>
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: OBJECTIVE_EVENT_TYPE }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
} from "../core/detectors/objective/player-status";
import styles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { formatClock, useEventTimeFormatter } from "./format";
import { MetaPills } from "./MetaChips";
export function PlayerStatusCard(props: {
t: number;
confidence: number;
data: PlayerStatusData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
onInspect?: () => void;
}) {
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
props;
const side = (index: 0 | 1) =>
data.dead[index]
.map((dead, slot) => (dead ? "✕" : data.special[index][slot] ? "★" : "·"))
.join("");
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={styles.card}>
<div className={styles.meta}>
<MetaPills
t={t}
confidence={confidence}
type={PLAYER_STATUS_EVENT_TYPE}
label={`players (${data.layout})`}
/>
<span>
{data.time !== null ? `${formatClock(data.time)} · ` : null}
<b>
{side(0)} – {side(1)}
</b>
</span>
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: PLAYER_STATUS_EVENT_TYPE }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
.topbar {
display: flex;
align-items: center;
gap: var(--s-6);
padding-bottom: var(--s-3);
border-bottom: var(--border-width) solid var(--color-bg-high);
margin-bottom: var(--s-4);
& nav {
display: flex;
gap: var(--s-2);
& a {
color: var(--color-text-high);
font-size: var(--font-xs);
font-weight: var(--weight-bold);
text-decoration: none;
padding: var(--s-1) var(--s-2-5);
border-radius: var(--radius-field);
&:hover {
color: var(--color-text);
}
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
&.active {
color: var(--color-text-accent);
background: var(--color-bg-high);
}
}
}
}

View File

@@ -0,0 +1,58 @@
import clsx from "clsx";
import { Link } from "react-router";
import { useUser } from "~/features/auth/core/user";
import { useSearchParam } from "~/modules/search-params/hooks";
import { SCANNER_PAGE } from "~/utils/urls";
import scannerStyles from "../scanner.module.css";
import {
SCANNER_TABS,
type ScannerTab,
scannerSearchParams,
} from "../scanner-search-params";
import { LivePage } from "./LivePage";
import styles from "./ScannerApp.module.css";
import { ScreenshotPage } from "./ScreenshotPage";
import type { SendouUser } from "./sendou-ingest";
import { VodPage } from "./VodPage";
const TAB_LABELS: Record<ScannerTab, string> = {
live: "Live",
screenshot: "Screenshot",
vod: "VoD",
};
export function ScannerApp() {
const [tab] = useSearchParam(scannerSearchParams, "tab");
const rootUser = useUser();
const sendouUser: SendouUser | null = rootUser
? { id: rootUser.id, username: rootUser.username }
: null;
const page =
tab === "screenshot" ? (
<ScreenshotPage />
) : tab === "vod" ? (
<VodPage sendouUser={sendouUser} />
) : (
<LivePage sendouUser={sendouUser} />
);
return (
<div className={scannerStyles.app}>
<header className={styles.topbar}>
<nav>
{SCANNER_TABS.map((tabOption) => (
<Link
key={tabOption}
to={scannerSearchParams.href(SCANNER_PAGE, { tab: tabOption })}
className={clsx({ [styles.active]: tab === tabOption })}
>
{TAB_LABELS[tabOption]}
</Link>
))}
</nav>
</header>
{page}
</div>
);
}

View File

@@ -0,0 +1,145 @@
import clsx from "clsx";
import { WeaponImage } from "~/components/Image";
import type { PlayerAbilityMap } from "../core/ability-harvest";
import type {
ScoreboardData,
ScoreboardPlayer,
} from "../core/detectors/scoreboard/index";
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log/index";
import { SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log-replay/index";
import scannerStyles from "../scanner.module.css";
import { AbilityPopover } from "./AbilityGrid";
import styles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import type { CardData } from "./fixture-export";
import { useEventTimeFormatter } from "./format";
import { lobbyLabel, modeLabel, stageLabel } from "./labels";
import { MetaPills } from "./MetaChips";
function PlayerRows({
players,
offset,
abilities,
}: {
players: ScoreboardPlayer[];
/** index of the first row within the full 8-player list */
offset: number;
abilities?: PlayerAbilityMap;
}) {
return (
<table className={styles.players}>
<tbody>
{players.map((p, i) => (
<tr key={i}>
<td>
<span className={styles.weaponCell}>
{p.weaponId !== null ? (
<WeaponImage
weaponSplId={p.weaponId}
variant="build"
size={28}
className={styles.weaponIcon}
/>
) : null}
{abilities?.has(offset + i) ? (
<AbilityPopover abilities={abilities.get(offset + i)!} />
) : null}
</span>
</td>
<td>{p.name || "?"}</td>
<td className={styles.num}>{p.paint ?? "?"}p</td>
<td className={styles.num}>
{p.ka ?? "?"}/{p.d ?? "?"}/{p.s ?? "?"}
</td>
</tr>
))}
</tbody>
</table>
);
}
function teamHeading(label: string, data: CardData, side: 0 | 1): string {
const score = data.matchScores[side];
return score !== null ? `${label} — ${score}` : label;
}
export function ScoreboardCard(props: {
t: number;
confidence: number;
data: ScoreboardData;
/** DetectedEvent type; replay cards add timestamp/code details */
eventType?: string;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
/** when set, shows an Inspect button (open this match in the screenshot page) */
onInspect?: () => void;
/** abilities harvested from this match's death events, by player index */
abilities?: PlayerAbilityMap;
}) {
const { t, confidence, thumbnail, detectedAt, getFrame, onInspect } = props;
const eventType = props.eventType ?? "Scoreboard";
const data = props.data as CardData;
const isReplay = eventType === SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE;
const isScoreboardBattleLog = eventType === SCOREBOARD_BATTLE_LOG_EVENT_TYPE;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={styles.card}>
<div className={styles.meta}>
<MetaPills
t={t}
confidence={confidence}
type={eventType}
label={
isReplay
? "replay scoreboard"
: isScoreboardBattleLog
? "battle log"
: "scoreboard"
}
/>
{data.mode !== null || data.stage !== null ? (
<span>
{[
lobbyLabel(data.lobby),
modeLabel(data.mode),
stageLabel(data.stage),
]
.filter(Boolean)
.join(" · ")}
</span>
) : null}
{data.timestamp ? <span>{data.timestamp}</span> : null}
{data.replayCode ? (
<span className={scannerStyles.score}>{data.replayCode}</span>
) : null}
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: eventType }}
/>
</div>
<div className={styles.teams}>
<div className={clsx(styles.team, styles.win)}>
<h3>{teamHeading("Victory", data, 0)}</h3>
<PlayerRows
players={data.players.slice(0, 4)}
offset={0}
abilities={props.abilities}
/>
</div>
<div className={clsx(styles.team, styles.lose)}>
<h3>{teamHeading("Defeat", data, 1)}</h3>
<PlayerRows
players={data.players.slice(4, 8)}
offset={4}
abilities={props.abilities}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import clsx from "clsx";
import { WeaponImage } from "~/components/Image";
import {
SCOREBOARD_OWN_EVENT_TYPE,
type ScoreboardOwnData,
} from "../core/detectors/scoreboard-own/index";
import { AbilityGrid } from "./AbilityGrid";
import styles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { lobbyLabel, mainWeaponLabel, modeLabel, stageLabel } from "./labels";
import { MetaPills } from "./MetaChips";
export function ScoreboardOwnCard(props: {
t: number;
confidence: number;
data: ScoreboardOwnData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
onInspect?: () => void;
}) {
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
props;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={styles.card}>
<div className={styles.meta}>
<MetaPills
t={t}
confidence={confidence}
type={SCOREBOARD_OWN_EVENT_TYPE}
label="own results"
/>
<span>
{[
lobbyLabel(data.lobby),
modeLabel(data.mode),
stageLabel(data.stage),
]
.map((v) => v ?? "?")
.join(" · ")}
</span>
<span>
weapon <b>{mainWeaponLabel(data.weaponId) ?? "?"}</b>
</span>
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: "ScoreboardOwn" }}
/>
</div>
<div className={clsx(styles.teams, styles.solo)}>
<div className={styles.team}>
{data.weaponId !== null ? (
<WeaponImage
weaponSplId={data.weaponId}
variant="build"
size={28}
className={styles.weaponIcon}
/>
) : null}
<AbilityGrid abilities={data.abilities} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,233 @@
.frame {
position: relative;
margin-bottom: var(--s-4);
& canvas {
width: 100%;
border-radius: var(--radius-box);
border: var(--border-width) solid var(--color-bg-high);
}
}
/* ---- per-detector gate results ---- */
.gateList {
display: grid;
grid-template-columns: repeat(3, max-content) 1fr;
column-gap: var(--s-4);
row-gap: var(--s-1-5);
align-items: baseline;
font-size: var(--font-xs);
margin-block: var(--s-4);
}
.gateRow {
display: contents;
}
.gateBadge {
justify-self: start;
align-self: center;
padding: 0 var(--s-1-5);
border-radius: var(--radius-selector);
background: var(--color-bg-high);
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
text-transform: uppercase;
white-space: nowrap;
}
.gateRow:not(.fired) .gateName {
color: var(--color-text-high);
}
.gateRow.fired {
& .gateBadge {
background: var(--color-success-low);
color: var(--color-success-high);
}
& .gateName {
font-weight: var(--weight-bold);
}
}
.gateScore {
font-variant-numeric: tabular-nums;
color: var(--color-text-high);
font-size: var(--font-2xs);
}
.gateNote {
color: var(--color-text-high);
font-size: var(--font-2xs);
}
/* ---- fired detector's parse detail ---- */
.detail {
display: flex;
flex-direction: column;
gap: var(--s-3);
margin-block: var(--s-4);
}
.detailStats {
display: flex;
flex-wrap: wrap;
gap: var(--s-1-5);
}
.stat {
display: inline-flex;
align-items: baseline;
gap: var(--s-1-5);
padding: var(--s-1) var(--s-2);
border-radius: var(--radius-field);
background: var(--color-bg-high);
font-size: var(--font-xs);
}
.statLabel {
color: var(--color-text-high);
font-size: var(--font-2xs);
white-space: nowrap;
}
.statValue {
font-weight: var(--weight-bold);
}
.statRaw {
color: var(--color-text-high);
font-size: var(--font-2xs);
}
.detailCrops {
display: flex;
flex-wrap: wrap;
gap: var(--s-3);
align-items: end;
& figure {
margin: 0;
display: flex;
flex-direction: column;
gap: var(--s-1);
min-width: 0;
}
& canvas {
display: block;
max-width: 100%;
background: #000;
border-radius: var(--radius-selector);
border: var(--border-width) solid var(--color-bg-high);
}
& figcaption {
color: var(--color-text-high);
font-size: var(--font-2xs);
}
}
/* one pill per player slot */
.statusSlots {
display: inline-flex;
gap: var(--s-2);
align-self: center;
}
.statusSide {
display: inline-flex;
gap: var(--s-0-5);
}
.statusSlot {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 1.25rem;
block-size: 1.25rem;
border-radius: var(--radius-selector);
background: var(--color-bg-higher);
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
&.dead {
background: var(--color-error-low);
color: var(--color-error-high);
}
&.special {
background: var(--color-warning-low);
color: var(--color-warning-high);
}
}
/* ---- per-row parse inspector ---- */
.inspector {
width: 100%;
border-collapse: collapse;
font-size: var(--font-xs);
& th {
text-align: left;
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
padding: var(--s-1-5);
border-bottom: var(--border-style);
}
& td {
padding: var(--s-1-5);
border-bottom: 1px solid var(--color-bg-high);
vertical-align: middle;
}
& canvas {
display: block;
background: #000;
border-radius: var(--radius-selector);
}
}
.candidates {
display: flex;
gap: var(--s-2);
align-items: center;
}
.candidate {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-0-5);
font-size: var(--font-2xs);
color: var(--color-text-high);
}
.weaponCandidates .candidate:first-child {
color: var(--color-success);
}
.weaponIcon {
width: 28px;
height: 28px;
vertical-align: middle;
background: var(--color-bg-badge);
border-radius: var(--radius-full);
padding: var(--s-0-5);
}
/* the ROI crops set the width; scroll them instead of the whole page */
@container scanner (width < 700px) {
.inspector {
display: block;
overflow-x: auto;
}
}

View File

@@ -0,0 +1,967 @@
import clsx from "clsx";
import {
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { useSearchParam } from "~/modules/search-params/hooks";
import { mainWeaponImageUrl } from "~/utils/urls";
import { CANONICAL_HEIGHT, CANONICAL_WIDTH, type Roi } from "../core/canonical";
import type { DeathData } from "../core/detectors/death/index";
import * as death from "../core/detectors/death/rois";
import type { MapStartData } from "../core/detectors/map-start/index";
import * as mapStart from "../core/detectors/map-start/rois";
import type { MinimapData } from "../core/detectors/minimap/index";
import * as minimap from "../core/detectors/minimap/rois";
import type { ObjectiveData } from "../core/detectors/objective/index";
import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
type PlayerStatusLayout,
} from "../core/detectors/objective/player-status";
import * as objective from "../core/detectors/objective/rois";
import type { ScoreboardRowDebug } from "../core/detectors/scoreboard/index";
import * as sb from "../core/detectors/scoreboard/rois";
import * as bl from "../core/detectors/scoreboard-battle-log/rois";
import * as replay from "../core/detectors/scoreboard-battle-log-replay/rois";
import type { ScoreboardOwnData } from "../core/detectors/scoreboard-own/index";
import * as own from "../core/detectors/scoreboard-own/rois";
import type { DetectedEvent } from "../core/detectors/types";
import scannerStyles from "../scanner.module.css";
import { scannerSearchParams } from "../scanner-search-params";
import { claimInspectFrame } from "../store/inspect";
import { AnalyzerClient } from "../worker/client";
import type { WorkerResponse } from "../worker/protocol";
import { downloadEventsCsv } from "./events-csv";
import { type CardData, downloadExpectedJson } from "./fixture-export";
import {
lobbyLabel,
mainWeaponLabel,
modeLabel,
stageLabel,
weaponLabel,
} from "./labels";
import styles from "./ScreenshotPage.module.css";
type Result = Extract<WorkerResponse, { kind: "result" }>;
/** Draw a ROI crop from the normalized frame, scaled up. */
function RoiCrop(props: {
frame: HTMLCanvasElement;
roi: Roi;
scale?: number;
}) {
const { frame, roi, scale = 1.5 } = props;
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current!;
canvas.width = Math.round(roi.w * scale);
canvas.height = Math.round(roi.h * scale);
const ctx = canvas.getContext("2d")!;
ctx.imageSmoothingEnabled = false;
ctx.drawImage(
frame,
roi.x,
roi.y,
roi.w,
roi.h,
0,
0,
canvas.width,
canvas.height,
);
}, [frame, roi.x, roi.y, roi.w, roi.h, scale]);
return <canvas ref={ref} />;
}
function Stat(props: { label: string; raw?: unknown; children: ReactNode }) {
return (
<span className={styles.stat}>
<span className={styles.statLabel}>{props.label}</span>
<span className={styles.statValue}>{props.children}</span>
{props.raw != null && props.raw !== "" ? (
<span className={styles.statRaw}>raw: {String(props.raw)}</span>
) : null}
</span>
);
}
function LabeledCrop(props: {
label: string;
frame: HTMLCanvasElement;
roi: Roi;
scale?: number;
}) {
return (
<figure>
<RoiCrop frame={props.frame} roi={props.roi} scale={props.scale} />
<figcaption>{props.label}</figcaption>
</figure>
);
}
/** One pill per player slot: number = alive, ★ = special held, ✗ = splatted. */
function StatusSlots(props: { data: PlayerStatusData }) {
return (
<span className={styles.statusSlots}>
{([0, 1] as const).map((side) => (
<span key={side} className={styles.statusSide}>
{props.data.dead[side].map((dead, slot) => {
const special = !dead && props.data.special[side][slot];
return (
<span
key={slot}
className={clsx(styles.statusSlot, {
[styles.dead]: dead,
[styles.special]: special,
})}
>
{dead ? "✗" : special ? "★" : slot + 1}
</span>
);
})}
</span>
))}
</span>
);
}
/** Per-row parse ROIs, in the same order as the event's players array. */
interface RowRois {
weapon: Roi;
name: Roi;
paint: Roi;
stats: [Roi, Roi, Roi];
}
function scoreboardRows(): RowRois[] {
return sb.ROW_CENTERS.map((cy) => ({
weapon: sb.weaponRoi(cy),
name: sb.nameRoi(cy),
paint: sb.paintRoi(cy),
stats: [sb.statRoi(cy, 0), sb.statRoi(cy, 1), sb.statRoi(cy, 2)],
}));
}
/** winnerSide comes from the event debug: players are ordered winners-first. */
function battleLogRows(winnerSide: string): RowRois[] {
const panels = winnerSide === "bottom" ? [bl.PANEL_DY, 0] : [0, bl.PANEL_DY];
return panels.flatMap((dy) =>
bl.ROW_CENTERS.map((base) => {
const cy = base + dy;
return {
weapon: bl.weaponRoi(cy),
name: bl.nameRoi(cy),
paint: bl.paintRoi(cy),
stats: [bl.statRoi(cy, 0), bl.statRoi(cy, 1), bl.statRoi(cy, 2)] as [
Roi,
Roi,
Roi,
],
};
}),
);
}
/** winnerSide comes from the event debug: players are ordered winners-first. */
function replayRows(winnerSide: string): RowRois[] {
const panels =
winnerSide === "right" ? [replay.PANEL_DX, 0] : [0, replay.PANEL_DX];
return panels.flatMap((dx) =>
replay.ROW_CENTERS.map((cy) => ({
weapon: replay.weaponRoi(cy, dx),
name: replay.nameRoi(cy, dx),
paint: replay.paintRoi(cy, dx),
stats: [
replay.statRoi(cy, dx, 0),
replay.statRoi(cy, dx, 1),
replay.statRoi(cy, dx, 2),
] as [Roi, Roi, Roi],
})),
);
}
/** Same marker language as the objective status pills: ✗ dead, ★ special. */
function playerFlags(p: { dead: boolean; specialReady: boolean }) {
return `${p.dead ? " ✗" : ""}${p.specialReady ? " ★" : ""}`;
}
function formatTimer(time: number | null) {
if (time === null) return "?:??";
return `${Math.floor(time / 60)}:${String(time % 60).padStart(2, "0")}`;
}
/** One-line recap of what a fired detector read, for the gate list. */
function gateSummary(result: Result): string | null {
const event = result.events[0];
if (!event) return null;
const confidence = `${((event.confidence ?? 0) * 100).toFixed(1)}% conf`;
switch (result.detector) {
case "death": {
const data = event.data as DeathData;
return `${confidence} · splatted by ${weaponLabel(data.weaponType, data.weaponId) ?? "?"} (${data.name ?? "?"})`;
}
case "map-start": {
const data = event.data as MapStartData;
return `${confidence} · ${modeLabel(data.mode) ?? "?"} · ${stageLabel(data.stage) ?? "?"}`;
}
case "scoreboard-own": {
const data = event.data as ScoreboardOwnData;
return `${confidence} · ${[lobbyLabel(data.lobby), modeLabel(data.mode), stageLabel(data.stage)].map((v) => v ?? "?").join(" · ")} · ${mainWeaponLabel(data.weaponId) ?? "?"}`;
}
case "minimap": {
const data = event.data as MinimapData;
const players = data.teammates
.map((p) => `${p.name ?? "?"}${playerFlags(p)}`)
.join(", ");
return `${confidence} · ${data.stage ?? "?"} · ${players}`;
}
case "objective": {
const data = event.data as unknown as ObjectiveData;
return `${confidence} · ${formatTimer(data.time)} · score ${data.score[0] ?? "?"}–${data.score[1] ?? "?"}`;
}
default: {
const data = event.data as CardData;
return `${confidence} · scores ${JSON.stringify(data.matchScores)} · ${[lobbyLabel(data.lobby), modeLabel(data.mode), stageLabel(data.stage)].map((v) => v ?? "?").join(" · ")}`;
}
}
}
/** Band covering one side's player-status icon strip, for the crop view. */
function statusStripRoi(layout: PlayerStatusLayout, side: 0 | 1): Roi {
const centers =
layout === "pov"
? objective.STATUS_SLOT_CENTERS_POV[side]
: layout === "cast"
? objective.STATUS_SLOT_CENTERS_CAST[side]
: objective.STATUS_SLOT_CENTERS_CAST_MIRROR[side];
const first = centers[0]!;
const last = centers[centers.length - 1]!;
return { x: first - 55, y: 25, w: last - first + 110, h: 115 };
}
function drawOverlay(ctx: CanvasRenderingContext2D, detector: string) {
const rect = (roi: Roi, color: string) => {
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.strokeRect(roi.x, roi.y, roi.w, roi.h);
};
if (detector === "death") {
rect(death.SPLAT_LINE1_ROI, "#34d399");
rect(death.WEAPON_LINE_ROI, "#f87171");
for (let row = 0; row < death.ABILITY_ROWS; row++) {
rect(death.abilityMainRoi(row), "#60a5fa");
for (const slot of [0, 1, 2])
rect(death.abilitySubRoi(row, slot), "#e879f9");
}
rect(death.TAG_NAME_OUTER, "#4ade80");
for (const roi of [
...death.GATE_BURST_PROBES,
...death.GATE_PANEL_PROBES,
]) {
rect(roi, "#facc15");
}
return;
}
if (detector === "objective") {
rect(objective.TIMER_DIGIT_ROI, "#34d399");
for (const side of [0, 1] as const) {
rect(objective.SCORE_ROIS[side], "#f87171");
rect(objective.PENALTY_ROIS[side], "#fb923c");
rect(objective.PLATE_PROBE_ROIS[side], "#facc15");
}
for (const [centers, box, color] of [
[
objective.STATUS_SLOT_CENTERS_POV,
objective.STATUS_BODY_BOX_POV,
"#60a5fa",
],
[
objective.STATUS_SLOT_CENTERS_CAST,
objective.STATUS_BODY_BOX_CAST,
"#e879f9",
],
] as const) {
for (const cx of centers.flat()) {
rect({ x: cx + box.dx, y: box.y, w: box.w, h: box.h }, color);
}
}
return;
}
if (detector === "map-start") {
rect(mapStart.MODE_LABEL_ROI, "#34d399");
rect(mapStart.MODE_BLOCK_ROI, "#f87171");
rect(mapStart.STAGE_ROI, "#60a5fa");
rect(mapStart.GATE_INK_BAND, "#e879f9");
for (const roi of mapStart.GATE_DARK_PROBES) rect(roi, "#facc15");
return;
}
if (detector === "scoreboard-own") {
rect(own.WEAPON_TITLE_BAND, "#f87171");
for (let row = 0; row < own.GEAR_ROWS; row++) {
rect(own.gearMainRoi(row), "#60a5fa");
for (const slot of [0, 1, 2]) rect(own.gearSubRoi(row, slot), "#e879f9");
rect(own.gateStripProbe(row), "#facc15");
}
for (const roi of [
...own.GATE_PANEL_PROBES,
...own.GATE_TITLE_TEXT_PROBES,
]) {
rect(roi, "#facc15");
}
rect(sb.HEADER_LOBBY_BAND, "#34d399");
rect(sb.HEADER_LINE_BAND, "#34d399");
return;
}
if (detector === "minimap") {
for (const card of minimap.CARD_LAYOUTS) {
rect(card.name, "#4ade80");
rect(card.weapon, "#f87171");
rect(card.subTile, "#22d3ee");
for (const [cx, cy] of card.badges)
rect(minimap.badgeRoi(cx, cy), "#60a5fa");
rect(card.cross, "#e879f9");
}
for (const cy of minimap.ENEMY_ROW_CYS) {
rect(minimap.enemyWeaponRoi(cy), "#f87171");
rect(minimap.enemySubTileRoi(cy), "#22d3ee");
for (const cx of minimap.ENEMY_BADGE_XS)
rect(minimap.badgeRoi(cx, cy), "#60a5fa");
rect(minimap.enemyCrossRoi(cy), "#e879f9");
}
for (const roi of [
...minimap.GATE_CLOSE_X_BRIGHT,
minimap.GATE_SPAWN_BRIGHT,
...minimap.GATE_CLOSE_DARK_PROBES,
...minimap.GATE_CLOSE_X_DARK,
...minimap.GATE_SPAWN_DARK_PROBES,
]) {
rect(roi, "#facc15");
}
return;
}
if (detector === "scoreboard-battle-log") {
for (const dy of bl.PANEL_DYS) {
for (const base of bl.ROW_CENTERS) {
const cy = base + dy;
rect(bl.weaponRoi(cy), "#f87171");
rect(bl.nameRoi(cy), "#4ade80");
rect(bl.paintRoi(cy), "#60a5fa");
for (const i of [0, 1, 2] as const) rect(bl.statRoi(cy, i), "#e879f9");
rect(bl.gateDarkProbe(cy), "#facc15");
}
rect(bl.teamScoreRoi(dy), "#60a5fa");
rect(bl.resultTagRoi(dy), "#fb923c");
}
for (const roi of bl.MATCH_SCORE_ROIS) rect(roi, "#60a5fa");
for (const roi of bl.GATE_COLOR_PROBES) rect(roi, "#facc15");
rect(bl.HEADER_TOP_BAND, "#34d399");
rect(bl.HEADER_BOTTOM_BAND, "#34d399");
return;
}
if (detector === "scoreboard-battle-log-replay") {
for (const dx of replay.PANEL_XS) {
for (const cy of replay.ROW_CENTERS) {
rect(replay.weaponRoi(cy, dx), "#f87171");
rect(replay.nameRoi(cy, dx), "#4ade80");
rect(replay.paintRoi(cy, dx), "#60a5fa");
for (const i of [0, 1, 2] as const)
rect(replay.statRoi(cy, dx, i), "#e879f9");
rect(replay.gateFlatProbe(cy, dx), "#facc15");
}
rect(replay.teamScoreRoi(dx), "#60a5fa");
rect(replay.resultTagRoi(dx), "#fb923c");
}
for (const roi of replay.MATCH_SCORE_ROIS) rect(roi, "#60a5fa");
for (const roi of replay.GATE_GAP_PROBES) rect(roi, "#facc15");
rect(replay.HEADER_TOP_BAND, "#34d399");
rect(replay.HEADER_BOTTOM_BAND, "#34d399");
rect(replay.REPLAY_CODE_ROI, "#34d399");
return;
}
for (const cy of sb.ROW_CENTERS) {
rect(sb.weaponRoi(cy), "#f87171");
rect(sb.nameRoi(cy), "#4ade80");
rect(sb.paintRoi(cy), "#60a5fa");
for (const i of [0, 1, 2] as const) rect(sb.statRoi(cy, i), "#e879f9");
rect(sb.gateDarkProbe(cy), "#facc15");
}
for (const roi of sb.TEAM_SCORE_ROIS) rect(roi, "#60a5fa");
for (const roi of sb.MATCH_SCORE_ROIS) rect(roi, "#fb923c");
for (const roi of sb.GATE_PANEL_PROBES) rect(roi, "#facc15");
rect(sb.HEADER_LOBBY_BAND, "#34d399");
rect(sb.HEADER_LINE_BAND, "#34d399");
}
export function ScreenshotPage() {
const displayRef = useRef<HTMLCanvasElement>(null);
const clientRef = useRef<AnalyzerClient | null>(null);
const resultRef = useRef<(r: Result) => void>(() => {});
const doneRef = useRef<() => void>(() => {});
const [frame, setFrame] = useState<HTMLCanvasElement | null>(null);
const [results, setResults] = useState<Record<string, Result>>({});
const [busy, setBusy] = useState(false);
const busyRef = useRef(false);
const [over, setOver] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
clientRef.current = new AnalyzerClient(
(r) => resultRef.current(r),
(message) => {
setError(message);
setBusy(false);
},
() => doneRef.current(),
// one-shot analyses: re-running the same screenshot must always parse
{ suppressSteadyFrames: false },
);
return () => clientRef.current?.dispose();
}, []);
// the detector whose inspector/overlay is shown: the one that fired
const active = Object.values(results).find((r) => r.events.length > 0);
const activeDetector = active?.detector ?? "scoreboard";
useEffect(() => {
if (!frame) return;
const display = displayRef.current!;
display.width = CANONICAL_WIDTH;
display.height = CANONICAL_HEIGHT;
const ctx = display.getContext("2d")!;
ctx.drawImage(frame, 0, 0);
drawOverlay(ctx, activeDetector);
}, [frame, activeDetector]);
const analyze = useCallback(async (file: File | Blob) => {
// one file at a time: in-flight worker results would land in the next
// file's state through the shared resultRef otherwise
if (busyRef.current) return;
busyRef.current = true;
setError(null);
setBusy(true);
setResults({});
try {
const bitmap = await createImageBitmap(file);
// normalized frame for local crop display, same as the pipeline does
const norm = document.createElement("canvas");
norm.width = CANONICAL_WIDTH;
norm.height = CANONICAL_HEIGHT;
norm
.getContext("2d")!
.drawImage(bitmap, 0, 0, CANONICAL_WIDTH, CANONICAL_HEIGHT);
setFrame(norm);
resultRef.current = (r) => {
setResults((prev) => ({ ...prev, [r.detector]: r }));
};
doneRef.current = () => {
busyRef.current = false;
setBusy(false);
};
const client = clientRef.current!;
await client.whenReady();
if (!client.analyze(bitmap, 0)) {
busyRef.current = false;
setBusy(false);
}
} catch (e) {
setError(String(e));
busyRef.current = false;
setBusy(false);
}
}, []);
// frame handed off from an Inspect click in another browser tab; the
// handoff write races this tab's load, so the claim polls briefly
const [inspectKey, setInspectKey] = useSearchParam(
scannerSearchParams,
"inspect",
);
useEffect(() => {
if (!inspectKey) return;
setInspectKey(null);
claimInspectFrame(inspectKey).then(
(frame) => {
if (frame) void analyze(frame);
else
setError(
"Inspected frame did not arrive — go back to the other tab and press Inspect again",
);
},
(e) => setError(String(e)),
);
}, [inspectKey, setInspectKey, analyze]);
const event = active?.events[0] as DetectedEvent<CardData> | undefined;
const rows = (event?.debug?.rows ?? []) as ScoreboardRowDebug[];
const isReplay = activeDetector === "scoreboard-battle-log-replay";
const isScoreboardBattleLog = activeDetector === "scoreboard-battle-log";
const isDeath = activeDetector === "death";
const isMapStart = activeDetector === "map-start";
const isOwn = activeDetector === "scoreboard-own";
const isMinimap = activeDetector === "minimap";
const isObjective = activeDetector === "objective";
const winnerSide = String(event?.debug?.winnerSide ?? "left");
const rowRois = isReplay
? replayRows(winnerSide)
: isScoreboardBattleLog
? battleLogRows(winnerSide)
: scoreboardRows();
return (
<div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target; the file input inside is the accessible path */}
<div
className={clsx(scannerStyles.dropzone, {
[scannerStyles.over]: over,
})}
onDragOver={(e) => {
e.preventDefault();
setOver(true);
}}
onDragLeave={() => setOver(false)}
onDrop={(e) => {
e.preventDefault();
setOver(false);
const file = e.dataTransfer.files[0];
if (file) void analyze(file);
}}
>
Drop a frame (PNG/JPEG) here, or{" "}
<label>
pick a file
<input
type="file"
accept="image/png,image/jpeg"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = ""; // allow re-picking the same file
if (file) void analyze(file);
}}
/>
</label>
{busy ? " — analyzing…" : null}
</div>
{error ? <p className={scannerStyles.error}>{error}</p> : null}
<div
className={styles.frame}
style={{ display: frame ? "block" : "none" }}
>
<canvas ref={displayRef} />
</div>
{frame && !busy ? (
<p>
<button
type="button"
onClick={() =>
downloadExpectedJson(event?.data ?? null, event?.type)
}
>
Download expected.json
</button>{" "}
<button
type="button"
disabled={!event}
onClick={() =>
downloadEventsCsv(
"screenshot-events.csv",
Object.values(results).flatMap((r) => r.events),
)
}
>
Download CSV
</button>
</p>
) : null}
{Object.keys(results).length > 0 ? (
<div className={styles.gateList}>
{Object.values(results).map((result) => (
<div
key={result.detector}
className={clsx(styles.gateRow, {
[styles.fired]: result.gate.pass,
})}
>
<span className={styles.gateBadge}>
{result.gate.pass ? "fired" : "no fire"}
</span>
<span className={styles.gateName}>{result.detector}</span>
<span className={styles.gateScore}>
{result.gate.score.toFixed(3)}
</span>
<span className={styles.gateNote}>{gateSummary(result)}</span>
</div>
))}
</div>
) : null}
{frame && event && isReplay ? (
<div className={styles.detail}>
<div className={styles.detailStats}>
<Stat label="timestamp">{event.data.timestamp ?? "?"}</Stat>
<Stat label="code" raw={event.debug?.codeRaw}>
{event.data.replayCode ?? "?"}
</Stat>
<Stat label="match scores">
{JSON.stringify(event.data.matchScores)}
</Stat>
<Stat label="winner panel">{winnerSide}</Stat>
</div>
<div className={styles.detailCrops}>
<LabeledCrop
label="header"
frame={frame}
roi={replay.HEADER_TOP_BAND}
/>
<LabeledCrop
label="replay code"
frame={frame}
roi={replay.REPLAY_CODE_ROI}
/>
</div>
</div>
) : null}
{frame && event && isScoreboardBattleLog ? (
<div className={styles.detail}>
<div className={styles.detailStats}>
<Stat label="timestamp">{event.data.timestamp ?? "?"}</Stat>
<Stat label="match scores">
{JSON.stringify(event.data.matchScores)}
</Stat>
<Stat label="winner panel">{winnerSide}</Stat>
</div>
<div className={styles.detailCrops}>
<LabeledCrop
label="header top"
frame={frame}
roi={bl.HEADER_TOP_BAND}
/>
<LabeledCrop
label="header bottom"
frame={frame}
roi={bl.HEADER_BOTTOM_BAND}
/>
</div>
</div>
) : null}
{frame && event && isDeath ? (
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as DeathData;
return (
<>
<div className={styles.detailStats}>
<Stat label="weapon" raw={event.debug?.weaponRaw}>
{weaponLabel(data.weaponType, data.weaponId) ?? "?"}
</Stat>
<Stat label="name" raw={event.debug?.nameRaw}>
{data.name ?? "?"}
</Stat>
<Stat label="abilities">
{data.abilities.map((row) => row.join(" ")).join(" | ")}
</Stat>
</div>
<div className={styles.detailCrops}>
<LabeledCrop
label="weapon line"
frame={frame}
roi={death.WEAPON_LINE_ROI}
/>
<LabeledCrop
label="name tag"
frame={frame}
roi={death.TAG_NAME_OUTER}
/>
</div>
</>
);
})()}
</div>
) : null}
{frame && event && isMapStart ? (
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as MapStartData;
return (
<>
<div className={styles.detailStats}>
<Stat label="mode" raw={event.debug?.modeReading}>
{modeLabel(data.mode) ?? "?"}
</Stat>
<Stat label="stage" raw={event.debug?.stageReading}>
{stageLabel(data.stage) ?? "?"}
</Stat>
</div>
<div className={styles.detailCrops}>
<LabeledCrop
label="mode block"
frame={frame}
roi={mapStart.MODE_BLOCK_ROI}
scale={0.75}
/>
<LabeledCrop
label="stage"
frame={frame}
roi={mapStart.STAGE_ROI}
/>
</div>
</>
);
})()}
</div>
) : null}
{frame && event && isOwn ? (
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as ScoreboardOwnData;
return (
<>
<div className={styles.detailStats}>
<Stat label="weapon" raw={event.debug?.weaponReading}>
{mainWeaponLabel(data.weaponId) ?? "?"}
</Stat>
<Stat label="abilities">
{data.abilities.map((row) => row.join(" ")).join(" | ")}
</Stat>
</div>
<div className={styles.detailCrops}>
<LabeledCrop
label="title band"
frame={frame}
roi={own.WEAPON_TITLE_BAND}
/>
{[0, 1, 2].map((row) => (
<LabeledCrop
key={row}
label={`gear ${row + 1}`}
frame={frame}
roi={{
x: own.GEAR_MAIN_CXS[row]! - 36,
y: own.GEAR_BADGE_CY - 32,
w: 200,
h: 64,
}}
/>
))}
</div>
</>
);
})()}
</div>
) : null}
{frame && event && isMinimap ? (
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as MinimapData;
return (
<>
<div className={styles.detailStats}>
<Stat label="stage">{stageLabel(data.stage) ?? "?"}</Stat>
<Stat label="view">
{data.spectator ? "spectator map" : "POV overlay"}
</Stat>
<Stat label="team">
{data.teammates
.map(
(p) =>
`${p.slot}: ${p.name ?? "?"} (${mainWeaponLabel(p.weaponId) ?? "?"})${playerFlags(p)}`,
)
.join(", ") || "—"}
</Stat>
{data.enemies.length > 0 ? (
<Stat label="enemies">
{data.enemies
.map(
(p) =>
`${p.name ?? "?"} (${mainWeaponLabel(p.weaponId) ?? "?"})${playerFlags(p)}`,
)
.join(", ")}
</Stat>
) : null}
</div>
{!data.spectator ? (
<div className={styles.detailCrops}>
{minimap.CARD_LAYOUTS.map((card) => (
<LabeledCrop
key={card.slot}
label={`slot ${card.slot}`}
frame={frame}
roi={card.name}
/>
))}
</div>
) : null}
</>
);
})()}
</div>
) : null}
{frame && event && isObjective ? (
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as ObjectiveData;
const status = active?.events.find(
(e) => e.type === PLAYER_STATUS_EVENT_TYPE,
) as DetectedEvent<PlayerStatusData> | undefined;
return (
<>
<div className={styles.detailStats}>
<Stat label="timer">{formatTimer(data.time)}</Stat>
<Stat label="score">
{data.score[0] ?? "?"}–{data.score[1] ?? "?"}
</Stat>
<Stat label="penalty">
{data.penalty[0] ?? "—"} / {data.penalty[1] ?? "—"}
</Stat>
<Stat label="control">
{data.control[0]
? "left"
: data.control[1]
? "right"
: "none"}
</Stat>
{status ? (
<>
<Stat label="layout">{status.data.layout}</Stat>
<Stat label="players">
<StatusSlots data={status.data} />
</Stat>
</>
) : null}
</div>
<div className={styles.detailCrops}>
<LabeledCrop
label="left count"
frame={frame}
roi={objective.SCORE_ROIS[0]}
/>
<LabeledCrop
label="timer"
frame={frame}
roi={objective.TIMER_DIGIT_ROI}
/>
<LabeledCrop
label="right count"
frame={frame}
roi={objective.SCORE_ROIS[1]}
/>
{status
? ([0, 1] as const).map((side) => (
<LabeledCrop
key={side}
label={side === 0 ? "left team" : "right team"}
frame={frame}
roi={statusStripRoi(status.data.layout, side)}
scale={0.75}
/>
))
: null}
</div>
</>
);
})()}
</div>
) : null}
{frame &&
event &&
!isDeath &&
!isMapStart &&
!isOwn &&
!isMinimap &&
!isObjective ? (
<table className={styles.inspector}>
<thead>
<tr>
<th>row</th>
<th>weapon crop</th>
<th>top candidates</th>
<th>name</th>
<th>paint</th>
<th>stats (splats / deaths / specials)</th>
</tr>
</thead>
<tbody>
{rowRois.map((roi, i) => {
const player = event.data.players[i];
const dbg = rows[i];
return (
<tr key={i}>
<td>{i}</td>
<td>
<RoiCrop frame={frame} roi={roi.weapon} />
</td>
<td>
<div
className={clsx(
styles.candidates,
styles.weaponCandidates,
)}
>
{dbg?.weapon?.top.map((c) => (
<span className={styles.candidate} key={c.id}>
<img
className={styles.weaponIcon}
src={`${mainWeaponImageUrl(Number(c.id) as MainWeaponId)}.avif`}
alt={c.id}
/>
{c.id}
<span className={scannerStyles.score}>
{c.score.toFixed(3)}
</span>
</span>
))}
</div>
</td>
<td>
<RoiCrop frame={frame} roi={roi.name} />
<b>{player?.name || "—"}</b>{" "}
<span className={scannerStyles.score}>
{dbg?.nameScore.toFixed(3)}
</span>
</td>
<td>
<RoiCrop frame={frame} roi={roi.paint} />
<b>{player?.paint ?? "—"}</b>{" "}
<span className={scannerStyles.score}>
{dbg?.paintScore.toFixed(3)}
</span>
</td>
<td>
<div className={styles.candidates}>
{([0, 1, 2] as const).map((s) => (
<span className={styles.candidate} key={s}>
<RoiCrop frame={frame} roi={roi.stats[s]} scale={2} />
<b>{[player?.ka, player?.d, player?.s][s] ?? "—"}</b>
<span className={scannerStyles.score}>
{dbg?.statScores[s].toFixed(2)}
</span>
</span>
))}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
) : null}
</div>
);
}

View File

@@ -0,0 +1,55 @@
import {
STRIP_WEAPONS_EVENT_TYPE,
type StripWeaponsData,
} from "../core/detectors/objective/strip-weapons";
import styles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { formatClock, useEventTimeFormatter } from "./format";
import { mainWeaponLabel } from "./labels";
import { MetaPills } from "./MetaChips";
export function StripWeaponsCard(props: {
t: number;
confidence: number;
data: StripWeaponsData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
onInspect?: () => void;
}) {
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
props;
const side = (index: 0 | 1) =>
data.slots[index]
.map((candidates) =>
candidates === null
? "✕"
: (mainWeaponLabel(candidates[0]?.weaponId ?? null) ?? "?"),
)
.join(" | ");
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={styles.card}>
<div className={styles.meta}>
<MetaPills
t={t}
confidence={confidence}
type={STRIP_WEAPONS_EVENT_TYPE}
label={`strip weapons (${data.layout})`}
/>
<span>
{data.time !== null ? `${formatClock(data.time)} · ` : null}
<b>{side(0)}</b> vs <b>{side(1)}</b>
</span>
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: STRIP_WEAPONS_EVENT_TYPE }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,99 @@
/* internal navigation styled like the sibling action buttons */
.linkButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--s-1-5);
padding: 0 var(--field-padding);
height: var(--field-size-sm);
border: var(--border-style-accent);
border-radius: var(--radius-field);
background: var(--color-text-accent);
color: var(--color-text-inverse);
font-size: var(--font-xs);
font-weight: var(--weight-bold);
white-space: nowrap;
text-decoration: none;
& > svg {
width: 16px;
height: 16px;
}
&:active {
transform: translateY(1px);
}
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
}
.vodList {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.vodItem {
display: flex;
align-items: center;
gap: var(--s-3);
background: var(--color-bg-high);
border-radius: var(--radius-box);
padding: var(--s-3) var(--s-4);
}
.vodName {
font-size: var(--font-sm);
font-weight: var(--weight-bold);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vodMeta {
flex: 1;
}
/* the shared destructive variant loses to the scanner's own button look */
button.vodDelete {
width: var(--field-size-sm);
padding: 0;
border-color: var(--color-error);
background: var(--color-error);
outline-color: var(--color-error);
}
.telemetry {
margin: var(--s-2) 0;
font-size: var(--font-2xs);
color: var(--color-text-high);
& summary {
cursor: pointer;
}
& table {
margin-top: var(--s-1-5);
border-collapse: collapse;
& th,
& td {
padding: var(--s-0-5) var(--s-2-5) var(--s-0-5) 0;
text-align: right;
font-variant-numeric: tabular-nums;
&:first-child {
text-align: left;
}
}
}
}
@container scanner (width < 700px) {
.vodItem {
flex-wrap: wrap;
}
}

View File

@@ -0,0 +1,939 @@
/**
* VoD tab: load a video file and scan it for scoreboard matches as fast as
* decoding allows — no real-time playback. On the primary (WebCodecs) path
* the file's duration is split into one contiguous slice per analyzer
* worker and each worker demuxes, decodes, schedules and analyzes its slice
* by itself (worker/analyzer.worker.ts): no frames cross the main thread,
* scheduling state is exact per slice, and calm stretches are skimmed by
* keyframe hops instead of decoded frame-by-frame. The seek fallback drives
* a <video> element through a single worker, widening its stride over calm
* footage. Each match can be opened in the screenshot page with the exact
* frame that was analyzed.
*
* Completed scans are persisted to IndexedDB keyed by file name
* (src/store/vods.ts); the default view lists them for reinspection.
*/
import clsx from "clsx";
import { Download, FileText, Send, Trash2, Video } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import * as R from "remeda";
import { SendouButton } from "~/components/elements/Button";
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { GameTimeline } from "~/components/GameTimeline";
import { useSearchParam } from "~/modules/search-params/hooks";
import { openSeekScan, probeWebCodecs } from "../capture/vod-frames";
import { connectAbilities } from "../core/ability-harvest";
import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index";
import { PLAYER_STATUS_EVENT_TYPE } from "../core/detectors/objective/player-status";
import { STRIP_WEAPONS_EVENT_TYPE } from "../core/detectors/objective/strip-weapons";
import {
mergeScanTelemetry,
type ScanTelemetry,
} from "../core/detectors/telemetry";
import type { DetectedEvent } from "../core/detectors/types";
import {
buildScannerMatches,
ingestSkipReasons,
invalidObjectiveEvents,
} from "../core/match-builder";
import { TimelineBuilder } from "../core/timeline/index";
import scannerStyles from "../scanner.module.css";
import { scannerSearchParams } from "../scanner-search-params";
import type { SendStatus } from "../store/events";
import {
deleteVod,
listVods,
loadVodEventFrame,
loadVodEvents,
saveVod,
saveVodResultsSend,
type VodResultsSend,
type VodSummary,
} from "../store/vods";
import {
AnalyzerClient,
type DoneInfo,
defaultScanWorkerCount,
} from "../worker/client";
import { withoutRepeatEvents } from "./dedupe-events";
import { EventCard, type GetFrame } from "./EventCard";
import { EventsSummary } from "./EventsSummary";
import { downloadEventsCsv } from "./events-csv";
import type { FixtureData } from "./fixture-export";
import { formatTime, useEventDateTimeFormatter } from "./format";
import { MatchCard } from "./MatchCard";
import { MatchLobbyTabs } from "./MatchLobbyTabs";
import { playerStatusTeams } from "./player-status-view";
import {
countIngestableMatches,
type SendouUser,
sendVodResults,
} from "./sendou-ingest";
import { sendouUpload } from "./sendou-upload";
import { thumbnailFromBlob } from "./thumbnail";
import styles from "./VodPage.module.css";
/** The scan knows the on-screen sides only, not who is playing. */
const SCANNER_TEAM_LABELS = ["Alpha", "Bravo"] as const;
/** seek-fallback stride while the worker reports activity */
const SEEK_ACTIVE_STRIDE_S = 0.25;
/**
* seek-fallback stride over calm footage (nothing detected for a while, no
* match open) — small enough that the screens that can start activity from
* dead air (results ~10s, match intro ~7s) still get sampled
*/
const SEEK_CALM_STRIDE_S = 2.5;
type Status = "idle" | "scanning" | "done" | "error";
interface VodMatch {
event: DetectedEvent<FixtureData>;
/**
* render identity — inherited when a better read replaces the event, so
* match cards keyed on it don't remount (replaying the enter animation)
*/
key: number;
thumbnail?: string;
/** lossless PNG of the exact frame the detector analyzed (live scan) */
frame?: Blob;
/** stored VoD: id to load the frame from the vod-frames store */
frameId?: number;
}
interface Progress {
t: number;
duration: number;
/** scan speed as a multiple of realtime */
rate: number;
}
/** "Send results" progress/outcome shown next to the button. */
type ResultsSend =
| { state: "sending"; sent: number; total: number }
| ({ state: "done" } & VodResultsSend);
export function VodPage({
sendouUser,
}: {
/** sendou.ink login shown in the header; undefined = probing, null = not logged in */
sendouUser: SendouUser | null | undefined;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const previewRef = useRef<HTMLCanvasElement>(null);
const clientsRef = useRef<AnalyzerClient[]>([]);
// cancels the in-flight chunk scans of the previous scan, if any
const abortScanRef = useRef<(() => void) | null>(null);
// seek fallback: latest per-frame done info + the waiter for the next one
const doneInfoRef = useRef<DoneInfo | null>(null);
const frameDoneRef = useRef<(() => void) | null>(null);
const timelineRef = useRef(new TimelineBuilder());
// latest gate score from any worker; flushed to state on the UI throttle
const gateScoreRef = useRef<number | null>(null);
const abortRef = useRef({ aborted: false });
const urlRef = useRef<string | null>(null);
// source of truth for matches (state mirrors it) so the scan loop can
// persist the final list without waiting on React
const matchesRef = useRef<VodMatch[]>([]);
// in-flight thumbnail work; awaited before persisting a finished scan
const sideWorkRef = useRef<Promise<void>[]>([]);
const nextMatchKeyRef = useRef(0);
// telemetry collection is baked into the workers at init, so a change of
// the search param needs a fresh pool
const clientsCollectTelemetryRef = useRef(false);
const [fileName, setFileName] = useState<string | null>(null);
/** live scan vs. reopened saved VoD (no video element for the latter) */
const [source, setSource] = useState<"scan" | "stored">("scan");
const [status, setStatus] = useState<Status>("idle");
const [method, setMethod] = useState<string | null>(null);
const [gateScore, setGateScore] = useState<number | null>(null);
const [progress, setProgress] = useState<Progress | null>(null);
const [matches, setMatches] = useState<VodMatch[]>([]);
const [vods, setVods] = useState<VodSummary[]>([]);
const [error, setError] = useState<string | null>(null);
const [telemetry, setTelemetry] = useState<ScanTelemetry | null>(null);
const [over, setOver] = useState(false);
const [eventsOpen, setEventsOpen] = useState(false);
const [resultsSend, setResultsSend] = useState<ResultsSend | null>(null);
// opt-in via ?telemetry=true only; nothing in the UI links to it
const [collectTelemetry] = useSearchParam(scannerSearchParams, "telemetry");
const formatSavedAt = useEventDateTimeFormatter();
const abilityMap = connectAbilities(matches.map((m) => m.event));
const builtMatches = buildScannerMatches(matches.map((m) => m.event));
const skipReasons = ingestSkipReasons(builtMatches);
// mirrors sendVodResults' ingestable-match order, which the send outcome's
// links are keyed by
const ingestableBuilt = builtMatches.filter((b) => !skipReasons.has(b));
const linkByIngestableIndex = new Map(
(resultsSend?.state === "done" ? (resultsSend.links ?? []) : []).map(
(linked) => [linked.matchIndex, linked.link] as const,
),
);
const vodMatchByEvent = new Map(matches.map((m) => [m.event, m] as const));
const groupedEvents = new Set(builtMatches.flatMap((b) => b.sources));
// strip weapon evidence is assignment input, not a detection worth a card
const ungroupedMatches = matches.filter(
(m) =>
!groupedEvents.has(m.event) && m.event.type !== STRIP_WEAPONS_EVENT_TYPE,
);
// "Send results" sends the whole scan in one go, so its outcome maps
// onto every ingestable card; a partial failure (some chunks sent, some
// not) can't be attributed per match — the bulk status text covers it
const bulkSend: SendStatus | undefined =
resultsSend?.state === "sending"
? { state: "sending", at: 0 }
: resultsSend?.state === "done" && resultsSend.error === null
? { state: "sent", at: resultsSend.at }
: resultsSend?.state === "done" && resultsSend.sent === 0
? { state: "failed", at: resultsSend.at }
: undefined;
// only offered once the whole VoD has been processed (a stored VoD is a
// completed scan by construction)
const upload =
status === "done" ? sendouUpload(matches.map((m) => m.event)) : null;
// "Send results" — the /ingest counterpart of live sending: the
// scan's ingestable ScannerMatches POSTed in one go
const resultsMatchCount =
status === "done" ? countIngestableMatches(matches.map((m) => m.event)) : 0;
const refreshVods = useCallback(async () => {
try {
setVods(await listVods());
} catch {
// listing failures are non-fatal; the scan UI still works
}
}, []);
const uploadResults = async () => {
const events = matchesRef.current.map((m) => m.event);
setResultsSend({
state: "sending",
sent: 0,
total: countIngestableMatches(events),
});
const report = await sendVodResults(events, (sent, total) =>
setResultsSend({ state: "sending", sent, total }),
);
const outcome: VodResultsSend = {
sent: report.sentMatches,
total: report.totalMatches,
error: report.error,
at: Date.now(),
links: report.links,
};
setResultsSend({ state: "done", ...outcome });
// the scan is saved under its file name, so its send outcome can be
// restored when the VoD is reopened
if (fileName) {
await saveVodResultsSend(fileName, outcome);
await refreshVods();
}
};
useEffect(() => {
void refreshVods();
return () => {
abortRef.current.aborted = true;
abortScanRef.current?.();
for (const client of clientsRef.current) client.dispose();
clientsRef.current = [];
if (urlRef.current) URL.revokeObjectURL(urlRef.current);
};
}, [refreshVods]);
const scan = async (file: File) => {
abortRef.current.aborted = true;
abortScanRef.current?.();
const abort = { aborted: false };
abortRef.current = abort;
setError(null);
setTelemetry(null);
setMatches([]);
setResultsSend(null);
setEventsOpen(false);
setProgress(null);
setGateScore(null);
setMethod(null);
setFileName(file.name);
setSource("scan");
setStatus("scanning");
try {
// the element is used by the seek fallback and for post-scan review
const video = videoRef.current!;
if (urlRef.current) URL.revokeObjectURL(urlRef.current);
urlRef.current = URL.createObjectURL(file);
video.src = urlRef.current;
if (clientsCollectTelemetryRef.current !== collectTelemetry) {
for (const client of clientsRef.current) client.dispose();
clientsRef.current = [];
clientsCollectTelemetryRef.current = collectTelemetry;
}
if (clientsRef.current.length === 0) {
clientsRef.current = Array.from(
{ length: defaultScanWorkerCount() },
() =>
new AnalyzerClient(
(result) => {
gateScoreRef.current = result.gate.score;
if (!result.gate.pass) return;
for (const event of result.events as DetectedEvent<FixtureData>[]) {
const action = timelineRef.current.push(event);
if (action.action !== "added" && action.action !== "replaced")
continue;
const frame = result.frame;
sideWorkRef.current.push(
(async () => {
const thumbnail = frame
? await thumbnailFromBlob(frame)
: undefined;
const replaced =
action.action === "replaced"
? matchesRef.current.find(
(m) => m.event === action.replaced,
)
: undefined;
const next = matchesRef.current.filter(
(m) => m !== replaced,
);
next.push({
event,
key: replaced?.key ?? nextMatchKeyRef.current++,
thumbnail,
frame,
});
next.sort((a, b) => a.event.t - b.event.t);
matchesRef.current = next;
setMatches(next);
})().catch(() => {}),
);
}
},
(message) => {
frameDoneRef.current?.();
frameDoneRef.current = null;
setError(message);
setStatus("error");
},
(_t, info: DoneInfo) => {
doneInfoRef.current = info;
frameDoneRef.current?.();
frameDoneRef.current = null;
},
{ collectTelemetry },
),
);
}
const clients = clientsRef.current;
await Promise.all(clients.map((c) => c.whenReady()));
// let work still in flight from an aborted scan finish before the
// timeline resets, so its results can't bleed into this scan
await Promise.all(clients.map((c) => c.whenIdle()));
if (abort.aborted) return;
matchesRef.current = [];
sideWorkRef.current = [];
timelineRef.current = new TimelineBuilder();
const started = performance.now();
const finalize = async (duration: number) => {
await Promise.all(sideWorkRef.current);
if (abort.aborted) return;
matchesRef.current = withoutInvalidObjectives(matchesRef.current);
setMatches(matchesRef.current);
setProgress((p) => (p ? { ...p, t: duration } : p));
setStatus("done");
await saveVod(
{ name: file.name, savedAt: Date.now(), duration },
matchesRef.current.map((m) => ({
type: m.event.type,
t: m.event.t,
confidence: m.event.confidence,
data: m.event.data,
thumbnail: m.thumbnail,
frame: m.frame,
})),
);
await refreshVods();
};
const probe = await probeWebCodecs(file);
if (abort.aborted) return;
if (probe) {
// each worker demuxes, decodes and analyzes its own slice of
// the file; the main thread only aggregates progress
setMethod("webcodecs");
const { duration } = probe;
const chunkSpan = duration / clients.length;
const chunks = clients.map((client, i) => ({
client,
tStart: i * chunkSpan,
tEnd: i === clients.length - 1 ? duration : (i + 1) * chunkSpan,
t: i * chunkSpan,
done: false,
telemetry: null as ScanTelemetry | null,
}));
abortScanRef.current = () => {
for (const client of clients) client.abortChunk();
};
const mergedTelemetry = () => {
const parts = chunks.flatMap((c) =>
c.telemetry ? [c.telemetry] : [],
);
return parts.length > 0 ? mergeScanTelemetry(parts) : null;
};
let lastUiUpdate = Number.NEGATIVE_INFINITY;
const pushUiUpdate = () => {
const now = performance.now();
if (now - lastUiUpdate < 250) return;
lastUiUpdate = now;
const covered = R.sumBy(
chunks,
(c) => Math.min(c.t, c.tEnd) - c.tStart,
);
const elapsed = (now - started) / 1000;
setGateScore(gateScoreRef.current);
setProgress({
t: covered,
duration,
rate: elapsed > 0 ? covered / elapsed : 0,
});
setTelemetry(mergedTelemetry());
};
await Promise.all(
chunks.map((chunk, chunkIndex) =>
chunk.client
.scanChunk(
{ file, chunkIndex, tStart: chunk.tStart, tEnd: chunk.tEnd },
(progress) => {
chunk.t = progress.t;
chunk.telemetry = progress.telemetry;
if (progress.preview) {
// show one chunk at a time: the earliest still running
if (chunks.find((c) => !c.done) === chunk) {
drawPreview(previewRef.current, progress.preview);
}
progress.preview.close();
}
pushUiUpdate();
},
)
.then((chunkTelemetry) => {
chunk.done = true;
chunk.t = chunk.tEnd;
chunk.telemetry = chunkTelemetry;
}),
),
);
if (abort.aborted) return;
setTelemetry(mergedTelemetry());
await finalize(duration);
return;
}
// seek fallback: one worker, one frame in flight; the worker's calm
// signal widens the stride over dead air
setMethod("seek");
const strideRef = { current: SEEK_ACTIVE_STRIDE_S };
const vod = await openSeekScan(video, () => strideRef.current);
if (abort.aborted) return;
const client = clients[0]!;
let lastUiUpdate = Number.NEGATIVE_INFINITY;
for await (const { frame, t } of vod.frames) {
if (abort.aborted) {
frame.close();
break;
}
const now = performance.now();
if (now - lastUiUpdate >= 250) {
lastUiUpdate = now;
// the preview draw must precede analyze — transferring the
// frame to the worker detaches it
drawPreview(previewRef.current, frame);
setGateScore(gateScoreRef.current);
const elapsed = (now - started) / 1000;
setProgress({
t,
duration: vod.duration,
rate: elapsed > 0 ? t / elapsed : 0,
});
if (doneInfoRef.current) setTelemetry(doneInfoRef.current.telemetry);
}
await new Promise<void>((resolve) => {
frameDoneRef.current = resolve;
if (!client.analyze(frame, t)) resolve();
});
strideRef.current = doneInfoRef.current?.calm
? SEEK_CALM_STRIDE_S
: SEEK_ACTIVE_STRIDE_S;
}
if (doneInfoRef.current) setTelemetry(doneInfoRef.current.telemetry);
await finalize(vod.duration);
} catch (e) {
if (!abort.aborted) {
abortScanRef.current?.();
setError(String(e));
setStatus("error");
}
}
};
const openStored = async (vod: VodSummary) => {
const name = vod.name;
abortRef.current.aborted = true;
abortScanRef.current?.();
setTelemetry(null);
try {
const events = await loadVodEvents(name);
// VoDs saved before objective reads were mode-gated may carry them
const loaded = withoutInvalidObjectives(
events.map((e) => ({
event: {
type: e.type,
t: e.t,
confidence: e.confidence,
data: e.data as FixtureData,
},
key: nextMatchKeyRef.current++,
thumbnail: e.thumbnail,
frameId: e.hasFrame ? e.id : undefined,
})),
);
matchesRef.current = loaded;
setMatches(loaded);
setResultsSend(
vod.resultsSend ? { state: "done", ...vod.resultsSend } : null,
);
setEventsOpen(false);
setFileName(name);
setSource("stored");
setStatus("done");
setError(null);
setProgress(null);
setGateScore(null);
setMethod(null);
} catch (e) {
setError(String(e));
}
};
const removeVod = async (name: string) => {
await deleteVod(name);
await refreshVods();
};
const backToList = () => {
abortRef.current.aborted = true;
abortScanRef.current?.();
setTelemetry(null);
matchesRef.current = [];
setMatches([]);
setResultsSend(null);
setEventsOpen(false);
setFileName(null);
setStatus("idle");
setError(null);
setProgress(null);
setGateScore(null);
setMethod(null);
void refreshVods();
};
const showVodView = fileName !== null;
return (
<div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target; the file input inside is the accessible path */}
<div
className={clsx(scannerStyles.dropzone, {
[scannerStyles.over]: over,
})}
onDragOver={(e) => {
e.preventDefault();
setOver(true);
}}
onDragLeave={() => setOver(false)}
onDrop={(e) => {
e.preventDefault();
setOver(false);
const file = e.dataTransfer.files[0];
if (file) void scan(file);
}}
>
Drop a VoD (video file) here, or{" "}
<label>
pick a file
<input
type="file"
accept="video/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = ""; // allow re-picking the same file
if (file) void scan(file);
}}
/>
</label>
</div>
<div className={scannerStyles.controls}>
{showVodView ? (
<>
<button type="button" onClick={backToList}>
← All VoDs
</button>
<span
className={clsx(scannerStyles.status, {
[scannerStyles.watching]: status === "scanning",
[scannerStyles.detected]: status === "done",
[scannerStyles.idle]:
status !== "scanning" && status !== "done",
})}
>
{source === "stored" ? "saved" : status}
{fileName ? ` · ${fileName}` : null}
{method ? ` · ${method}` : null}
{gateScore !== null && status === "scanning"
? ` · gate ${gateScore.toFixed(2)}`
: null}
</span>
{progress ? (
<span className={scannerStyles.score}>
{formatTime(progress.t)} / {formatTime(progress.duration)}
{progress.duration > 0
? ` (${Math.round((progress.t / progress.duration) * 100)}%)`
: null}
{progress.rate > 0
? ` · ${progress.rate.toFixed(0)}× realtime`
: null}
</span>
) : null}
{upload?.url ? (
<Link to={upload.url} className={styles.linkButton}>
<Video aria-hidden />
Add VoD
</Link>
) : null}
{upload?.problem ? (
<span className={scannerStyles.score}>
upload unavailable: {upload.problem}
</span>
) : null}
{resultsMatchCount > 0 ? (
<button
type="button"
disabled={!sendouUser || resultsSend?.state === "sending"}
title={sendouUser ? undefined : "Log in on sendou.ink first"}
onClick={() => void uploadResults()}
>
<Send aria-hidden />
Send results
</button>
) : null}
{resultsSend ? (
<span
className={clsx(scannerStyles.score, {
[scannerStyles.error]:
resultsSend.state === "done" && Boolean(resultsSend.error),
})}
>
{resultsSend.state === "sending"
? `sending match ${Math.min(resultsSend.sent + 1, resultsSend.total)}/${resultsSend.total}…`
: resultsSend.error
? `sent ${resultsSend.sent}/${resultsSend.total} matches — ${resultsSend.error}`
: `sent ${resultsSend.sent}/${resultsSend.total} matches to sendou.ink`}
</span>
) : null}
{matches.length > 0 ? (
<ExportMenu
fileName={fileName}
events={matches.map((m) => m.event)}
/>
) : null}
</>
) : null}
</div>
{error ? <p className={scannerStyles.error}>{error}</p> : null}
{showVodView && telemetry ? (
<TelemetryPanel telemetry={telemetry} />
) : null}
{!showVodView ? (
<div className={styles.vodList}>
{vods.length === 0 ? (
<p className={scannerStyles.score}>
No saved VoDs yet — scan one and it will show up here.
</p>
) : null}
{vods.map((vod) => (
<div key={vod.name} className={styles.vodItem}>
<span className={styles.vodName}>{vod.name}</span>
<span className={clsx(scannerStyles.score, styles.vodMeta)}>
{vod.eventCount} event{vod.eventCount === 1 ? "" : "s"} ·{" "}
{formatTime(vod.duration)} · {formatSavedAt(vod.savedAt)}
</span>
<button type="button" onClick={() => void openStored(vod)}>
Open
</button>
<FormWithConfirm
dialogHeading={`Delete saved analysis of "${vod.name}"?`}
onConfirm={() => void removeVod(vod.name)}
>
<SendouButton
variant="destructive"
size="small"
shape="square"
className={styles.vodDelete}
icon={<Trash2 />}
aria-label="Delete"
/>
</FormWithConfirm>
</div>
))}
</div>
) : null}
<div
className={scannerStyles.liveLayout}
style={{
display: showVodView ? undefined : "none",
// a reopened saved VoD has no video to review — give the feed the full width
gridTemplateColumns: source === "stored" ? "1fr" : undefined,
}}
>
<div style={{ display: source === "scan" ? undefined : "none" }}>
<canvas
ref={previewRef}
className={scannerStyles.preview}
style={{ display: status === "scanning" ? "block" : "none" }}
/>
<video
ref={videoRef}
className={scannerStyles.preview}
muted
playsInline
controls
style={{
display: fileName && status !== "scanning" ? "block" : "none",
}}
/>
</div>
<div className={scannerStyles.feed}>
{matches.length === 0 ? (
<p className={scannerStyles.score}>
{status === "scanning"
? "Scanning — matches appear here as scoreboards are detected."
: "No matches found in this VoD."}
</p>
) : null}
<MatchLobbyTabs
matches={builtMatches}
keyOf={(built) => vodMatchByEvent.get(built.sources[0]!)!.key}
renderMatch={(built, justFormed) => {
const skipReason = skipReasons.get(built);
const link = linkByIngestableIndex.get(
ingestableBuilt.indexOf(built),
);
const send = skipReason ? undefined : bulkSend;
// counter reads render as one timeline chart, not a card each --
// from the builder's samples, whose sides are team-stable (raw
// reads follow the specced player on casts); a non-SZ match's
// reads (objective null) are never shown
const objectiveEvents = (
built.match.objective?.samples ?? []
).map((sample) => ({ t: sample.t, data: sample }));
const statusSamples = built.match.playerStatus?.samples ?? [];
const cardEvents = withoutRepeatEvents(built.sources).filter(
(e) =>
e.type !== OBJECTIVE_EVENT_TYPE &&
e.type !== PLAYER_STATUS_EVENT_TYPE &&
e.type !== STRIP_WEAPONS_EVENT_TYPE,
);
return (
<MatchCard
match={built.match}
inProgress={
status === "scanning" &&
built === builtMatches.at(-1) &&
built.match.winner === null
}
skipReason={skipReason}
justFormed={justFormed}
send={
send?.state === "sent" && link ? { ...send, link } : send
}
>
<GameTimeline
objectiveEvents={objectiveEvents}
playerStatusSamples={statusSamples}
teams={playerStatusTeams(built.match, SCANNER_TEAM_LABELS)}
/>
{cardEvents.map((e) => {
const vodMatch = vodMatchByEvent.get(e);
return (
<EventCard
key={vodMatch?.key ?? `${e.type}-${e.t}`}
type={e.type}
t={e.t}
confidence={e.confidence}
data={e.data}
abilities={abilityMap.get(e)}
thumbnail={vodMatch?.thumbnail}
getFrame={vodMatch ? frameLoader(vodMatch) : undefined}
/>
);
})}
</MatchCard>
);
}}
/>
{ungroupedMatches.length > 0 ? (
<EventsSummary
events={ungroupedMatches.map((m) => m.event)}
open={eventsOpen}
onToggle={() => setEventsOpen(!eventsOpen)}
/>
) : null}
{/* newest detection on top; storage keeps ascending video-time order */}
{eventsOpen
? ungroupedMatches
.toReversed()
.map((m) => (
<EventCard
key={m.key}
type={m.event.type}
t={m.event.t}
confidence={m.event.confidence}
data={m.event.data}
abilities={abilityMap.get(m.event)}
thumbnail={m.thumbnail}
getFrame={frameLoader(m)}
/>
))
: null}
</div>
</div>
</div>
);
}
/** Export formats of the scanned events, behind one icon-only menu. */
function ExportMenu({
fileName,
events,
}: {
fileName: string;
events: DetectedEvent<FixtureData>[];
}) {
return (
<SendouMenu
trigger={
<SendouButton
icon={<Download />}
className={scannerStyles.iconMenu}
aria-label="Export"
/>
}
>
<SendouMenuItem
icon={<FileText />}
onAction={() =>
downloadEventsCsv(
`${fileName.replace(/\.[^.]+$/, "")}-events.csv`,
events,
)
}
>
CSV
</SendouMenuItem>
</SendouMenu>
);
}
/** Drops objective reads that grouped into a known non-SZ match (misreads). */
function withoutInvalidObjectives(matches: VodMatch[]): VodMatch[] {
const invalid = new Set(
invalidObjectiveEvents(buildScannerMatches(matches.map((m) => m.event))),
);
return invalid.size > 0
? matches.filter((m) => !invalid.has(m.event))
: matches;
}
function frameLoader(m: VodMatch): GetFrame | undefined {
return m.frame
? () => Promise.resolve(m.frame)
: m.frameId !== undefined
? () => loadVodEventFrame(m.frameId!)
: undefined;
}
function TelemetryPanel({ telemetry }: { telemetry: ScanTelemetry }) {
const detectors = Object.entries(telemetry.detectors).sort(([a], [b]) =>
a.localeCompare(b),
);
const coveredS = telemetry.activeVideoS + telemetry.skimVideoS;
return (
<details className={styles.telemetry}>
<summary>
telemetry · analyzed {telemetry.analyzedFrames}/
{telemetry.decodedFrames} decoded frames
{coveredS > 0
? ` · skimmed ${formatTime(telemetry.skimVideoS)} of ${formatTime(coveredS)}`
: null}
{telemetry.wallMs > 0
? ` · ${formatTime(telemetry.wallMs / 1000)} cpu`
: null}
</summary>
<table>
<thead>
<tr>
<th>detector</th>
<th>checks</th>
<th>gate pass</th>
<th>gate ms</th>
<th>parses</th>
<th>parse ms</th>
<th>suppressed</th>
</tr>
</thead>
<tbody>
{detectors.map(([id, d]) => (
<tr key={id}>
<td>{id}</td>
<td>{d.checks}</td>
<td>{d.gatePasses}</td>
<td>{Math.round(d.gateMs)}</td>
<td>{d.parses}</td>
<td>{Math.round(d.parseMs)}</td>
<td>{d.suppressedParses}</td>
</tr>
))}
</tbody>
</table>
</details>
);
}
function drawPreview(
canvas: HTMLCanvasElement | null,
frame: ImageBitmap | VideoFrame,
): void {
if (!canvas) return;
const width = "displayWidth" in frame ? frame.displayWidth : frame.width;
const height = "displayHeight" in frame ? frame.displayHeight : frame.height;
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
canvas.getContext("2d")!.drawImage(frame, 0, 0);
}

View File

@@ -0,0 +1,42 @@
/**
* Display-side cleanup for a match card's source event list: a long map-open
* can fragment into several minimap events (the timeline's merge window
* tracks a drifting `t`, and VoD results arrive out of order), so a minimap
* that shows nothing new versus the previous one is pure noise. Names are
* ignored in the comparison — OCR wobbles on them while the actual content
* (stage, weapons, ability reads) is unchanged. Stored events are untouched;
* this only filters what gets rendered.
*/
import { isDeepEqual, omit } from "remeda";
import {
MINIMAP_EVENT_TYPE,
type MinimapData,
} from "../core/detectors/minimap/index";
export function withoutRepeatEvents<T extends { type: string; data: unknown }>(
events: readonly T[],
): T[] {
const result: T[] = [];
let previousMinimap: MinimapData | null = null;
for (const event of events) {
if (event.type === MINIMAP_EVENT_TYPE) {
const data = event.data as MinimapData;
const repeat =
previousMinimap !== null &&
isDeepEqual(comparable(previousMinimap), comparable(data));
previousMinimap = data;
if (repeat) continue;
}
result.push(event);
}
return result;
}
function comparable(data: MinimapData) {
return {
...data,
teammates: data.teammates.map((player) => omit(player, ["name"])),
enemies: data.enemies.map((player) => omit(player, ["name"])),
};
}

View File

@@ -0,0 +1,336 @@
/**
* Flatten detected events into a single CSV for download. One row per event;
* the event types share columns where they overlap (lobby/mode/stage,
* weapon/name/abilities) and a scoreboard's eight player rows — or the
* minimap's teammates+enemies, or the objective HUD's icon strip — are packed
* into one cell, matching the compact per-event view of the live feed.
*/
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import {
DEATH_EVENT_TYPE,
type DeathData,
} from "../core/detectors/death/index";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "../core/detectors/map-start/index";
import {
MINIMAP_EVENT_TYPE,
type MinimapData,
} from "../core/detectors/minimap/index";
import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
} from "../core/detectors/objective/player-status";
import {
STRIP_WEAPONS_EVENT_TYPE,
type StripWeaponsData,
} from "../core/detectors/objective/strip-weapons";
import {
SCOREBOARD_EVENT_TYPE,
type ScoreboardData,
} from "../core/detectors/scoreboard/index";
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log/index";
import {
SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE,
type ScoreboardBattleLogReplayData,
} from "../core/detectors/scoreboard-battle-log-replay/index";
import {
SCOREBOARD_OWN_EVENT_TYPE,
type ScoreboardOwnData,
} from "../core/detectors/scoreboard-own/index";
import { formatClock, formatTime } from "./format";
import {
lobbyLabel,
mainWeaponLabel,
modeLabel,
stageLabel,
weaponLabel,
} from "./labels";
export interface CsvEvent {
type: string;
/** video/stream time in seconds */
t: number;
/** wall-clock time of detection (live capture only) */
detectedAt?: number;
confidence: number;
data: unknown;
}
const HEADER = [
"type",
"time",
"t_seconds",
"detected_at",
"confidence",
"lobby",
"mode",
"stage",
"winner_score",
"loser_score",
"pov",
"weapon",
"name",
"abilities",
"players",
"replay_code",
"replay_timestamp",
];
type Cell = string | number | null | undefined;
function csvCell(value: Cell): string {
if (value === null || value === undefined) return "";
const s = String(value);
return /[",\n\r]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
}
/** [head, clothes, shoes] rows of [main, sub, sub, sub] ability ids */
function formatAbilities(rows: string[][]): string {
return rows.map((row) => row.join("+")).join(" | ");
}
/** the minimap's flat [head, clothes, shoes] main-ability row */
function formatMinimapAbilities(abilities: (string | null)[]): string {
return abilities.map((a) => a ?? "?").join("+");
}
function formatMinimapPlayers(data: MinimapData): string {
const fmt = (
label: string,
p: {
name: string | null;
weaponId: number | null;
abilities: (string | null)[];
dead: boolean;
specialReady: boolean;
},
) =>
`${label} ${p.name ?? "?"} · ${mainWeaponLabel(p.weaponId as MainWeaponId | null) ?? "?"} · ${formatMinimapAbilities(p.abilities)}` +
`${p.dead ? " · splatted" : ""}${p.specialReady ? " · special" : ""}`;
return [
...data.teammates.map((p) => fmt(p.slot, p)),
...data.enemies.map((p, i) => fmt(`enemy${i + 1}`, p)),
].join("; ");
}
/** one team's four slots as top-candidate weapon names, ✕ = splatted */
function formatStripWeaponsSide(data: StripWeaponsData, side: 0 | 1): string {
return data.slots[side]
.map((candidates) =>
candidates === null
? "✕"
: (mainWeaponLabel(candidates[0]?.weaponId ?? null) ?? "?"),
)
.join(" | ");
}
/** one team's four icons as ✕ splatted / ★ special ready / · alive */
function formatPlayerStatusSide(data: PlayerStatusData, side: 0 | 1): string {
return data.dead[side]
.map((dead, slot) => (dead ? "✕" : data.special[side][slot] ? "★" : "·"))
.join("");
}
function formatPlayers(data: ScoreboardData): string {
return data.players
.map(
(p, i) =>
`${i < 4 ? "W" : "L"} ${p.name} · ${p.weaponId ?? "?"} · ${p.paint ?? "?"}p ` +
`${p.ka ?? "?"}/${p.d ?? "?"}/${p.s ?? "?"}`,
)
.join("; ");
}
function eventCells(event: CsvEvent): Cell[] {
const base: Cell[] = [
event.type,
formatTime(event.t),
Math.round(event.t * 1000) / 1000,
event.detectedAt === undefined
? ""
: new Date(event.detectedAt).toISOString(),
Math.round(event.confidence * 1000) / 1000,
];
switch (event.type) {
case DEATH_EVENT_TYPE: {
const d = event.data as DeathData;
return [
...base,
"",
"",
"",
"",
"",
"",
weaponLabel(d.weaponType, d.weaponId),
d.name,
formatAbilities(d.abilities),
"",
"",
"",
];
}
case MAP_START_EVENT_TYPE: {
const d = event.data as MapStartData;
return [
...base,
"",
modeLabel(d.mode),
stageLabel(d.stage),
"",
"",
"",
"",
"",
"",
"",
"",
"",
];
}
case SCOREBOARD_OWN_EVENT_TYPE: {
const d = event.data as ScoreboardOwnData;
return [
...base,
lobbyLabel(d.lobby),
modeLabel(d.mode),
stageLabel(d.stage),
"",
"",
"",
mainWeaponLabel(d.weaponId),
"",
formatAbilities(d.abilities),
"",
"",
"",
];
}
case OBJECTIVE_EVENT_TYPE: {
const d = event.data as ObjectiveData;
const sideText = (side: 0 | 1) =>
`${d.score[side] ?? "?"}${d.penalty[side] !== null ? ` (+${d.penalty[side]})` : ""}${d.control[side] ? " ctrl" : ""}`;
const clock = d.time === null ? "" : `${formatClock(d.time)} · `;
return [
...base,
"",
modeLabel(d.mode),
"",
"",
"",
"",
"",
"",
"",
`${clock}${sideText(0)} vs ${sideText(1)}`,
"",
"",
];
}
case MINIMAP_EVENT_TYPE: {
const d = event.data as MinimapData;
const self = d.teammates.find((p) => p.slot === "self");
return [
...base,
"", // lobby
"", // mode (not readable from the minimap)
stageLabel(d.stage), // stage (planner-signature match)
"", // winner_score
"", // loser_score
self?.name,
self ? mainWeaponLabel(self.weaponId) : "",
"",
self ? formatMinimapAbilities(self.abilities) : "",
formatMinimapPlayers(d),
"",
"",
];
}
case PLAYER_STATUS_EVENT_TYPE: {
const d = event.data as PlayerStatusData;
const clock = d.time === null ? "" : `${formatClock(d.time)} · `;
return [
...base,
"",
"",
"",
"",
"",
"",
"",
"",
"",
`${clock}${formatPlayerStatusSide(d, 0)} vs ${formatPlayerStatusSide(d, 1)} (${d.layout})`,
"",
"",
];
}
case STRIP_WEAPONS_EVENT_TYPE: {
const d = event.data as StripWeaponsData;
const clock = d.time === null ? "" : `${formatClock(d.time)} · `;
return [
...base,
"",
"",
"",
"",
"",
"",
"",
"",
"",
`${clock}${formatStripWeaponsSide(d, 0)} vs ${formatStripWeaponsSide(d, 1)} (${d.layout})`,
"",
"",
];
}
case SCOREBOARD_EVENT_TYPE:
case SCOREBOARD_BATTLE_LOG_EVENT_TYPE:
case SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE: {
// the three scoreboard reads share the base shape
const d = event.data as ScoreboardData &
Partial<ScoreboardBattleLogReplayData>;
return [
...base,
lobbyLabel(d.lobby),
modeLabel(d.mode),
stageLabel(d.stage),
d.matchScores[0],
d.matchScores[1],
d.povIndex === null ? "" : d.players[d.povIndex]?.name,
"",
"",
"",
formatPlayers(d),
d.replayCode ?? "",
d.timestamp ?? "",
];
}
default:
return [...base, ...Array(HEADER.length - base.length).fill("")];
}
}
export function eventsToCsv(events: CsvEvent[]): string {
const lines = [HEADER.join(",")];
for (const event of events)
lines.push(eventCells(event).map(csvCell).join(","));
return `${lines.join("\r\n")}\r\n`;
}
export function downloadEventsCsv(filename: string, events: CsvEvent[]): void {
const blob = new Blob([eventsToCsv(events)], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,305 @@
/**
* "Save as fixture": download the raw captured frame as PNG plus an
* expected.json prefilled from the detector's own output, so labeling a new
* fixture is review-and-correct instead of data entry.
*/
import {
DEATH_EVENT_TYPE,
type DeathData,
} from "../core/detectors/death/index";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "../core/detectors/map-start/index";
import {
MINIMAP_EVENT_TYPE,
type MinimapData,
} from "../core/detectors/minimap/index";
import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
} from "../core/detectors/objective/player-status";
import {
STRIP_WEAPONS_EVENT_TYPE,
type StripWeaponsData,
} from "../core/detectors/objective/strip-weapons";
import type { ScoreboardData } from "../core/detectors/scoreboard/index";
import type { ScoreboardBattleLogReplayData } from "../core/detectors/scoreboard-battle-log-replay/index";
import {
SCOREBOARD_OWN_EVENT_TYPE,
type ScoreboardOwnData,
} from "../core/detectors/scoreboard-own/index";
import { mainWeaponLabel, stageLabel, weaponLabel } from "./labels";
/** Scoreboard data with the replay extras present when the event has them. */
export type CardData = ScoreboardData &
Partial<Pick<ScoreboardBattleLogReplayData, "timestamp" | "replayCode">>;
/** Any detector's event payload that can prefill a fixture. */
export type FixtureData =
| CardData
| DeathData
| MapStartData
| ScoreboardOwnData
| MinimapData
| ObjectiveData
| PlayerStatusData
| StripWeaponsData;
function isDeath(_data: FixtureData, eventType: string): _data is DeathData {
return eventType === DEATH_EVENT_TYPE;
}
function download(name: string, blob: Blob): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
}
function buildExpectedJson(
data: FixtureData | null,
eventType = "Scoreboard",
): string {
if (!data) {
return `${JSON.stringify({ event: "none" }, null, 2)}\n`;
}
if (isDeath(data, eventType)) {
const label = weaponLabel(data.weaponType, data.weaponId);
return `${JSON.stringify(
{
event: eventType,
data: {
...(label !== null && { weaponLabel: label }),
...(data.weaponId !== null && { weaponId: data.weaponId }),
...(data.weaponType !== null && { weaponType: data.weaponType }),
abilities: data.abilities,
...(data.name !== null && { name: data.name }),
},
},
null,
2,
)}\n`;
}
if (eventType === SCOREBOARD_OWN_EVENT_TYPE) {
const own = data as ScoreboardOwnData;
return `${JSON.stringify(
{
event: eventType,
data: {
...(own.lobby !== null && { lobby: own.lobby }),
...(own.mode !== null && { mode: own.mode }),
...(own.stage !== null && {
stage: own.stage,
stageLabel: stageLabel(own.stage),
}),
...(own.weaponId !== null && {
weaponLabel: mainWeaponLabel(own.weaponId),
weaponId: own.weaponId,
}),
abilities: own.abilities,
},
},
null,
2,
)}\n`;
}
if (eventType === MINIMAP_EVENT_TYPE) {
const minimap = data as MinimapData;
return `${JSON.stringify(
{
event: eventType,
data: {
...(minimap.stage !== null && {
stage: minimap.stage,
stageLabel: stageLabel(minimap.stage),
}),
...(minimap.spectator && { spectator: true }),
teammates: minimap.teammates.map((p) => ({
slot: p.slot,
name: p.name,
weaponLabel: mainWeaponLabel(p.weaponId),
weaponId: p.weaponId,
abilities: p.abilities,
dead: p.dead,
specialReady: p.specialReady,
})),
enemies: minimap.enemies.map((p) => ({
...(minimap.spectator && { name: p.name }),
weaponLabel: mainWeaponLabel(p.weaponId),
weaponId: p.weaponId,
abilities: p.abilities,
dead: p.dead,
specialReady: p.specialReady,
})),
},
},
null,
2,
)}\n`;
}
if (eventType === OBJECTIVE_EVENT_TYPE) {
const objective = data as ObjectiveData;
return `${JSON.stringify(
{
event: eventType,
data: {
mode: objective.mode,
time: objective.time,
score: objective.score,
penalty: objective.penalty,
control: objective.control,
},
},
null,
2,
)}\n`;
}
if (eventType === PLAYER_STATUS_EVENT_TYPE) {
const status = data as PlayerStatusData;
return `${JSON.stringify(
{
event: eventType,
data: {
layout: status.layout,
time: status.time,
special: status.special,
dead: status.dead,
},
},
null,
2,
)}\n`;
}
if (eventType === STRIP_WEAPONS_EVENT_TYPE) {
const strip = data as StripWeaponsData;
return `${JSON.stringify(
{
event: eventType,
data: {
layout: strip.layout,
time: strip.time,
// the top candidate per slot; hand-correct to the true weapons
weapons: strip.slots.map((side) =>
side.map((candidates) => candidates?.[0]?.weaponId ?? null),
),
weaponLabels: strip.slots.map((side) =>
side.map((candidates) =>
mainWeaponLabel(candidates?.[0]?.weaponId ?? null),
),
),
},
},
null,
2,
)}\n`;
}
// NB: not a type-predicate helper — CardData is structurally assignable to
// MapStartData, so a predicate would narrow the fall-through case to never
if (eventType === MAP_START_EVENT_TYPE) {
const mapStart = data as MapStartData;
return `${JSON.stringify(
{
event: eventType,
data: {
...(mapStart.mode !== null && { mode: mapStart.mode }),
...(mapStart.stage !== null && {
stage: mapStart.stage,
stageLabel: stageLabel(mapStart.stage),
}),
},
},
null,
2,
)}\n`;
}
const card = data as CardData;
return `${JSON.stringify(
{
event: eventType,
data: {
...(card.lobby !== null && { lobby: card.lobby }),
...(card.mode !== null && { mode: card.mode }),
...(card.stage !== null && {
stage: card.stage,
stageLabel: stageLabel(card.stage),
}),
...(card.timestamp != null && { timestamp: card.timestamp }),
...(card.replayCode != null && { replayCode: card.replayCode }),
matchScores: card.matchScores,
players: card.players.map((p) => ({
name: p.name,
weaponId: p.weaponId,
paint: p.paint,
ka: p.ka,
d: p.d,
s: p.s,
})),
},
},
null,
2,
)}\n`;
}
/**
* expected.json alone, for a frame the user already has on disk (Screenshot
* page). Null data produces the negative-fixture form.
*/
export function downloadExpectedJson(
data: FixtureData | null,
eventType?: string,
): void {
download(
"expected.json",
new Blob([buildExpectedJson(data, eventType)], {
type: "application/json",
}),
);
}
/**
* Fixture export for a live detection: the stored PNG is the byte-exact
* frame the detector analyzed, paired with that event's own parse output.
*/
export function saveFixtureFromEvent(
frame: Blob,
data: FixtureData,
eventType: string,
): void {
download("frame.png", frame);
download(
"expected.json",
new Blob([buildExpectedJson(data, eventType)], {
type: "application/json",
}),
);
}
export async function saveFixture(
video: HTMLVideoElement,
latest: { type: string; data: FixtureData } | null,
): Promise<void> {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d")!.drawImage(video, 0, 0);
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/png"),
);
if (!blob) throw new Error("could not encode frame");
download("frame.png", blob);
download(
"expected.json",
new Blob([buildExpectedJson(latest?.data ?? null, latest?.type)], {
type: "application/json",
}),
);
}

View File

@@ -0,0 +1,37 @@
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
/** hh:mm:ss for feed rows, CSV cells, and scan progress. */
export function formatTime(t: number): string {
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const s = Math.floor(t % 60);
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
/** the match timer's M:SS (215 → "3:35") */
export function formatClock(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, "0")}`;
}
/**
* Locale-aware time-of-day formatter for detection timestamps (epoch ms)
* shown in scanner cards, in place of a raw `toLocaleTimeString()` call.
*/
export function useEventTimeFormatter(): (ms: number) => string {
const { formatter } = useDateTimeFormat({ timeStyle: "medium" });
return (ms: number) => formatter.format(new Date(ms));
}
/**
* Locale-aware date+time formatter for absolute timestamps (epoch ms), e.g.
* a saved VoD's scan time, in place of a raw `toLocaleString()` call.
*/
export function useEventDateTimeFormatter(): (ms: number) => string {
const { formatter } = useDateTimeFormat({
dateStyle: "medium",
timeStyle: "medium",
});
return (ms: number) => formatter.format(new Date(ms));
}

View File

@@ -0,0 +1,59 @@
/**
* 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.
*/
import type {
MainWeaponId,
ModeShort,
SpecialWeaponId,
StageId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import gameMisc from "../../../../locales/en/game-misc.json";
import {
ALL_WEAPON_ENTRIES,
type WeaponType,
} from "../core/detectors/death/weapon-names";
import type { ScannerLobby } from "../scanner-types";
const MISC = gameMisc as Record<string, string>;
const WEAPON_NAME_BY_KIND_AND_ID = new Map(
ALL_WEAPON_ENTRIES.map((e) => [`${e.type}:${e.id}`, e.name]),
);
export function weaponLabel(
type: WeaponType | null,
id: MainWeaponId | SubWeaponId | SpecialWeaponId | null,
): string | null {
if (type === null || id === null) return null;
return WEAPON_NAME_BY_KIND_AND_ID.get(`${type}:${id}`) ?? String(id);
}
export function mainWeaponLabel(id: MainWeaponId | null): string | null {
return id === null ? null : weaponLabel("MAIN", id);
}
export function stageLabel(stageId: StageId | null): string | null {
return stageId === null
? null
: (MISC[`STAGE_${stageId}`] ?? String(stageId));
}
export function modeLabel(mode: ModeShort | null): string | null {
return mode === null ? null : (MISC[`MODE_LONG_${mode}`] ?? mode);
}
const LOBBY_LABELS: Record<ScannerLobby, string> = {
X: "X Battle",
SERIES: "Anarchy Battle (Series)",
OPEN: "Anarchy Battle (Open)",
PRIVATE: "Private Battle",
};
export function lobbyLabel(lobby: ScannerLobby | null): string | null {
return lobby === null ? null : LOBBY_LABELS[lobby];
}

View File

@@ -0,0 +1,19 @@
/**
* Prop derivation for rendering a ScannerMatch's status samples with the
* shared <PlayerStatusTimeline />, used by the Live and VoD tabs.
*/
import type { PlayerStatusTimelineTeam } from "~/components/PlayerStatusTimeline";
import type { ScannerMatch } from "../core/scanner-match";
/** Row weapons per team from the match's known players, slots by index. */
export function playerStatusTeams(
match: ScannerMatch,
labels: readonly [string, string],
): [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam] {
return [0, 1].map((side) => ({
label: labels[side]!,
weapons: [0, 1, 2, 3].map(
(slot) => match.teams[side as 0 | 1].players[slot]?.weaponId ?? null,
),
})) as [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam];
}

View File

@@ -0,0 +1,301 @@
/**
* 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
* session user and resolves the tournament/match server-side.
*
* The unit of accounting is one ScannerMatch (core/match-builder.ts) —
* every source event's IndexedDB record tracks its outcome (the `send`
* status the feed cards display) — while the unit of transport is a request
* of up to `MAX_MATCHES_PER_REQUEST` of them. Resends are safe: sendou.ink
* dedupes matches by content hash, merges partials, and scoreboards
* first-ingest-wins.
*/
import * as R from "remeda";
import type {
IngestedMatchLink,
IngestResponse,
} from "~/features/scanner-ingest/scanner-ingest-schemas";
import type { DetectedEvent } from "../core/detectors/types";
import type { BuiltMatch } from "../core/match-builder";
import { buildScannerMatches, ingestSkipReasons } from "../core/match-builder";
import type { ScannerMatch } from "../core/scanner-match";
import {
type SendStatus,
type StoredEvent,
updateEventsSend,
} from "../store/events";
const INGEST_URL = "/ingest";
/** /ingest accepts at most 50 matches per request (mirrors the server cap) */
const MAX_MATCHES_PER_REQUEST = 50;
/**
* How long after each unlinked send to try again. A live send usually beats
* the players to reporting the game, so the first attempts find nothing to
* link to; these delays cover the reporting lag without hammering. Running
* out of them gives up — the capture ending still makes one last attempt.
*/
const UNLINKED_RETRY_DELAYS_MS = [30_000, 2 * 60_000, 5 * 60_000];
export interface SendouUser {
id: number;
username: string;
}
export interface SendResult {
sentMatches: number;
failedMatches: number;
}
/**
* Builds the stored events into matches, POSTs the ingestable ones `include`
* selects, and records the outcome on every source event's `send` status
* (calling `onStatus` after each request's store writes so the feed can
* refresh).
*
* Matches go out as few requests as the server cap allows rather than one
* apiece: sendou.ink resolves a whole request at once, so a request carrying
* several matches anchors on their mode+stage sequence instead of guessing a
* set from one match's timestamp. A live send is a single match either way;
* catching up on a session's backlog is where it counts. One request resolves
* to one context, so a backlog spanning two of them links the larger and
* leaves the rest "unlinked" — the retry then carries only those, and they
* resolve to their own context.
*/
export async function sendMatches({
events,
include,
onStatus,
}: {
events: readonly StoredEvent[];
include: (built: BuiltMatch<StoredEvent>) => boolean;
onStatus: () => void;
}): Promise<SendResult> {
const allBuilt = ingestableBuilt(
buildScannerMatches(events.filter((e) => e.id !== undefined)),
);
const selected = allBuilt.filter(include);
await clearOrphanedQueued(events, allBuilt);
const result: SendResult = { sentMatches: 0, failedMatches: 0 };
for (const request of R.chunk(selected, MAX_MATCHES_PER_REQUEST)) {
const idsPerMatch = request.map((built) => built.sources.map((e) => e.id!));
await updateEventsSend(idsPerMatch.flat(), {
state: "sending",
at: Date.now(),
});
onStatus();
try {
const response = await postIngestMatches(
request.map((built) => built.match),
);
for (const [matchIndex, built] of request.entries()) {
const link = response.linkedMatches?.find(
(linked) => linked.matchIndex === matchIndex,
)?.link;
// stored but not linked, and sendou.ink knows which tournament or
// SendouQ match this is: the game is just not reported yet, so a
// later resend can still land it. Without a context there is nothing
// to wait for and the match is as done as it will get.
const unlinked = !link && response.contextResolved;
await updateEventsSend(idsPerMatch[matchIndex]!, {
state: unlinked ? "unlinked" : "sent",
at: Date.now(),
...(link ? { link } : null),
...(unlinked
? {
attempts:
(aggregateSendStatus(built.sources)?.attempts ?? 0) + 1,
}
: null),
});
}
result.sentMatches += request.length;
} catch (err) {
await updateEventsSend(idsPerMatch.flat(), {
state: "failed",
at: Date.now(),
error: err instanceof Error ? err.message : String(err),
});
result.failedMatches += request.length;
}
onStatus();
}
return result;
}
export interface VodResultsSendReport {
sentMatches: number;
totalMatches: number;
/** last failure's message; null when every request went through */
error: string | null;
/** links /ingest reported, keyed by index into the scan's ingestable matches */
links: Array<{ matchIndex: number; link: IngestedMatchLink }>;
}
/**
* One-go sender for the VoD tab's "Send results": builds a completed
* scan's events into matches and POSTs as many per request as the server
* cap allows — usually the whole scan in one request, so sendou.ink's
* content-based tournament resolution sees the full match sequence (its
* mode+stage order plus roster sides is near-unique in the user's history).
* No per-event status bookkeeping — VoD events don't live in the live feed
* store. Resending is safe (server-side dedupe/merge), so a partial failure
* can simply be retried whole.
*/
export async function sendVodResults(
events: readonly DetectedEvent[],
onProgress?: (sentMatches: number, totalMatches: number) => void,
): Promise<VodResultsSendReport> {
const matches = ingestableMatches(events);
let sentMatches = 0;
let error: string | null = null;
const links: VodResultsSendReport["links"] = [];
const chunks = R.chunk(matches, MAX_MATCHES_PER_REQUEST);
for (const [chunkIndex, request] of chunks.entries()) {
const offset = chunkIndex * MAX_MATCHES_PER_REQUEST;
try {
const response = await postIngestMatches(request);
for (const linked of response.linkedMatches ?? []) {
links.push({
matchIndex: offset + linked.matchIndex,
link: linked.link,
});
}
sentMatches += request.length;
onProgress?.(sentMatches, matches.length);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
}
}
return { sentMatches, totalMatches: matches.length, error, links };
}
/** The number of matches a set of events would send to /ingest. */
export function countIngestableMatches(
events: readonly DetectedEvent[],
): number {
return ingestableMatches(events).length;
}
/** Match selector: the match built from the given stored event. */
export function matchContaining(
id: number,
): (built: BuiltMatch<StoredEvent>) => boolean {
return (built) => built.sources.some((e) => e.id === id);
}
/**
* The single send status a match displays, folded from its source events:
* an in-flight send wins, then a failure, then success, then queued. Within
* a state the most recent change is shown.
*/
export function aggregateSendStatus(
sources: readonly StoredEvent[],
): SendStatus | undefined {
const statuses = sources
.map((e) => e.send)
.filter((status) => status !== undefined);
for (const state of [
"sending",
"failed",
"unlinked",
"sent",
"queued",
] as const) {
const ofState = statuses.filter((status) => status.state === state);
if (ofState.length > 0) {
return ofState.reduce((a, b) => (a.at >= b.at ? a : b));
}
}
return undefined;
}
/** Match selector: matches not yet sent (nor currently sending). */
export function unsentMatches(built: BuiltMatch<StoredEvent>): boolean {
return !built.sources.some(
(e) => e.send?.state === "sent" || e.send?.state === "sending",
);
}
/**
* Match selector: matches sendou.ink stored without a game to link them to,
* whose next attempt is due. Exhausting the backoff stops the retries.
*/
export function retryableUnlinkedMatches(
built: BuiltMatch<StoredEvent>,
): boolean {
const status = aggregateSendStatus(built.sources);
if (status?.state !== "unlinked") return false;
const delay = UNLINKED_RETRY_DELAYS_MS[(status.attempts ?? 1) - 1];
return delay !== undefined && Date.now() - status.at >= delay;
}
function ingestableMatches(events: readonly DetectedEvent[]): ScannerMatch[] {
return ingestableBuilt(buildScannerMatches(events)).map(
(built) => built.match,
);
}
function ingestableBuilt<E extends DetectedEvent>(
built: BuiltMatch<E>[],
): BuiltMatch<E>[] {
const skipped = ingestSkipReasons(built);
return built.filter((match) => !skipped.has(match));
}
async function postIngestMatches(
matches: ScannerMatch[],
): Promise<IngestResponse> {
const res = await fetch(INGEST_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ matches }),
});
if (!res.ok) {
throw new Error(
res.status === 401 ? "not logged in to sendou.ink" : await errorText(res),
);
}
return res.json();
}
/**
* Live sending marks events "queued" as they arrive; ones the match builder
* later leaves out (non-private match, older than the fallback window) would
* sit "queued" forever. Once a match boundary has passed them they can
* never join a future match, so clear their status back to "not sent".
*/
async function clearOrphanedQueued(
events: readonly StoredEvent[],
allBuilt: BuiltMatch<StoredEvent>[],
): Promise<void> {
const lastBoundaryT = Math.max(
...allBuilt.map((built) => built.sources.at(-1)!.t),
Number.NEGATIVE_INFINITY,
);
const builtIds = new Set(
allBuilt.flatMap((built) => built.sources.map((e) => e.id)),
);
const orphaned = events
.filter(
(e) =>
e.send?.state === "queued" &&
e.id !== undefined &&
!builtIds.has(e.id) &&
e.t <= lastBoundaryT,
)
.map((e) => e.id!);
if (orphaned.length > 0) await updateEventsSend(orphaned, undefined);
}
async function errorText(res: Response): Promise<string> {
const text = await res.text().catch(() => "");
return `POST /ingest -> ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`;
}

View File

@@ -0,0 +1,89 @@
/**
* "Upload to sendou.ink" link for a fully processed VoD: the detected events
* are built into ScannerMatches (core/match-builder.ts), projected onto the
* slim per-match rows the `ingest` search param of sendou.ink's /vods/new
* form carries (an `SP.json` param the search-params module compresses),
* which prefills a new VoD from them (the user adds the YouTube
* URL/title/date and fixes any misreads before submitting).
*
* The VoD type is auto-detected: footage containing the casted 8-player
* spectator map screen is a CAST VoD; anything else leaves the form's default
* type untouched. A match without a mode read prefills the form's SZ default,
* flagged `modeAssumed` — the fabricated default lives here, not on
* ScannerMatch.
*/
import type {
IngestVodMatchInput,
IngestVodPrefill,
} from "~/features/scanner-ingest/scanner-ingest-vod-schemas";
import { vodsNewSearchParams } from "~/features/vods/vods-search-params";
import { newVodPage } from "~/features/vods/vods-urls";
import type { MainWeaponId, ModeShort } from "~/modules/in-game-lists/types";
import type { DetectedEvent } from "../core/detectors/types";
import { buildScannerMatches } from "../core/match-builder";
import type { ScannerMatch } from "../core/scanner-match";
/**
* GET query params ride the request line, and servers/proxies commonly cap
* that around 8-16 KB. A VoD long enough to blow past this needs a POST
* flow, which /vods/new doesn't offer yet — surface that instead of
* emitting a URL the server would reject with an opaque error.
*/
const MAX_URL_LENGTH = 8000;
/** The prefill default for a match whose mode no source read. */
const DEFAULT_VOD_MODE = "SZ" satisfies ModeShort;
export interface SendouUpload {
/** prefilled /vods/new path (same-origin); null when nothing usable to send */
url: string | null;
/** set when matches exist but no usable URL could be built */
problem: string | null;
}
/** Builds the prefilled /vods/new link for a completed scan's events. */
export function sendouUpload(events: readonly DetectedEvent[]): SendouUpload {
const matches = buildScannerMatches(events)
.map((built) => built.match)
.filter((match) => match.teams.some((team) => team.players.length > 0));
if (matches.length === 0) return { url: null, problem: null };
const isCast = matches.some((match) => match.cast);
const payload: IngestVodPrefill = {
...(isCast ? { type: "CAST" as const } : null),
matches: matches.map(toPrefillMatch),
};
const result = vodsNewSearchParams.href(newVodPage(), { ingest: payload });
if (result.length > MAX_URL_LENGTH) {
return {
url: null,
problem:
`prefill URL is ${result.length} chars (limit ~${MAX_URL_LENGTH}) — ` +
"too many matches for a GET query param.",
};
}
return { url: result, problem: null };
}
function toPrefillMatch(match: ScannerMatch): IngestVodMatchInput {
return {
startsAt: match.startsAt ?? 0,
mode: match.mode ?? DEFAULT_VOD_MODE,
modeAssumed: match.mode === null,
stage: match.stage,
// /vods/new splits this at a fixed 4 slots per team, so pad short rosters
weapons: match.teams.flatMap((team) =>
Array.from({ length: 4 }, (_, i) => team.players[i]?.weaponId ?? null),
),
povWeapon: povWeaponId(match),
};
}
function povWeaponId(match: ScannerMatch): MainWeaponId | undefined {
if (!match.pov) return undefined;
return (
match.teams[match.pov.team].players[match.pov.index]?.weaponId ?? undefined
);
}

View File

@@ -0,0 +1,10 @@
/** Small JPEG data-URL thumbnail of an analyzed frame, for feed cards. */
export async function thumbnailFromBlob(frame: Blob): Promise<string> {
const bitmap = await createImageBitmap(frame);
const canvas = document.createElement("canvas");
canvas.width = 320;
canvas.height = 180;
canvas.getContext("2d")!.drawImage(bitmap, 0, 0, 320, 180);
bitmap.close();
return canvas.toDataURL("image/jpeg", 0.7);
}