mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 23:58:36 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
82
apps/web-react/app/features/scanner/components/DeathCard.tsx
Normal file
82
apps/web-react/app/features/scanner/components/DeathCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
175
apps/web-react/app/features/scanner/components/EventCard.tsx
Normal file
175
apps/web-react/app/features/scanner/components/EventCard.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
109
apps/web-react/app/features/scanner/components/FrameThumb.tsx
Normal file
109
apps/web-react/app/features/scanner/components/FrameThumb.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
581
apps/web-react/app/features/scanner/components/LivePage.tsx
Normal file
581
apps/web-react/app/features/scanner/components/LivePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
352
apps/web-react/app/features/scanner/components/MatchCard.tsx
Normal file
352
apps/web-react/app/features/scanner/components/MatchCard.tsx
Normal 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}`;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.matchList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
39
apps/web-react/app/features/scanner/components/MetaChips.tsx
Normal file
39
apps/web-react/app/features/scanner/components/MetaChips.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
159
apps/web-react/app/features/scanner/components/MinimapCard.tsx
Normal file
159
apps/web-react/app/features/scanner/components/MinimapCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
939
apps/web-react/app/features/scanner/components/VodPage.tsx
Normal file
939
apps/web-react/app/features/scanner/components/VodPage.tsx
Normal 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);
|
||||
}
|
||||
@@ -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"])),
|
||||
};
|
||||
}
|
||||
336
apps/web-react/app/features/scanner/components/events-csv.ts
Normal file
336
apps/web-react/app/features/scanner/components/events-csv.ts
Normal 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);
|
||||
}
|
||||
305
apps/web-react/app/features/scanner/components/fixture-export.ts
Normal file
305
apps/web-react/app/features/scanner/components/fixture-export.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
}
|
||||
37
apps/web-react/app/features/scanner/components/format.ts
Normal file
37
apps/web-react/app/features/scanner/components/format.ts
Normal 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));
|
||||
}
|
||||
59
apps/web-react/app/features/scanner/components/labels.ts
Normal file
59
apps/web-react/app/features/scanner/components/labels.ts
Normal 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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
301
apps/web-react/app/features/scanner/components/sendou-ingest.ts
Normal file
301
apps/web-react/app/features/scanner/components/sendou-ingest.ts
Normal 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)}` : ""}`;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
10
apps/web-react/app/features/scanner/components/thumbnail.ts
Normal file
10
apps/web-react/app/features/scanner/components/thumbnail.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user