No uploads from view=vod for now

This commit is contained in:
Kalle
2026-09-27 09:50:58 +03:00
parent dd065bbb32
commit cc983c06e1
17 changed files with 146 additions and 223 deletions

View File

@@ -146,8 +146,8 @@ interface IngestContextCandidate {
* activity around play time is the strong signal: their match running then (for cast footage,
* the casted sets of tournaments they help run). Candidates are scored by how many matches would
* link, but kept even when nothing links yet (a live minimap-only match still gets its hint).
* Without activity the content decides: mode+stage sequence plus roster sides is near-unique in
* a user's history.
* Without activity (a set that outlasted the activity window) the user's history decides: the
* context whose games were reported around the matches' play times.
*/
async function resolveIngestContext({
matches,
@@ -300,7 +300,7 @@ function withoutDisprovenCast(match: ScannerMatch): ScannerMatch {
return { ...match, cast: false };
}
/** When the request's matches were probably played: the latest playedAt, else "now". */
/** When the request's matches were probably played: the latest playedAt, else "now" (live reads without a scoreboard). */
function anchorTime(matches: ScannerMatch[]): number {
const playedAts = matches
.map((match) => match.playedAt)

View File

@@ -53,7 +53,7 @@ function tournamentMatchIdOf(matched: Scoreboards.MatchedGame): number | null {
}
function testMatch({
t = 60,
playedAt = 1000,
mode = "SZ",
stage = 0,
lobby = "PRIVATE",
@@ -64,7 +64,8 @@ function testMatch({
objective = null,
playerStatus = null,
}: {
t?: number;
/** database timestamp (seconds) like the games' `playedAt`; null for a read without a wall clock (VoD) */
playedAt?: number | null;
mode?: ModeShort | null;
stage?: StageId | null;
lobby?: ScannerLobby | null;
@@ -87,9 +88,9 @@ function testMatch({
}),
);
return {
startsAt: t,
endsAt: t,
playedAt: null,
startsAt: 60,
endsAt: 360,
playedAt: playedAt === null ? null : playedAt * 1000,
lobby,
mode,
stage,
@@ -287,7 +288,7 @@ describe("matchedGames", () => {
test("matches matches to games by mode and stage", () => {
const matched = Scoreboards.matchedGames({
matches: [testMatch({ mode: "RM", stage: 1, t: 60 })],
matches: [testMatch({ mode: "RM", stage: 1 })],
games: [
testGame({ mapIndex: 0, mode: "SZ", stageId: 0 as StageId }),
testGame({ mapIndex: 1, mode: "RM", stageId: 1 as StageId }),
@@ -301,11 +302,11 @@ describe("matchedGames", () => {
const matched = Scoreboards.matchedGames({
matches: [
testMatch({
t: 60,
playedAt: 1000,
names: ["a", "b", "c", "d", "e", "f", "g", "h"],
}),
testMatch({
t: 5000,
playedAt: 2000,
names: ["i", "j", "k", "l", "m", "n", "o", "p"],
}),
],
@@ -323,7 +324,7 @@ describe("matchedGames", () => {
test("skips duplicate detections of the same game", () => {
const matched = Scoreboards.matchedGames({
matches: [testMatch({ t: 60 }), testMatch({ t: 65 })],
matches: [testMatch(), testMatch({ playedAt: 1005 })],
games: [
testGame({ tournamentMatchId: 1, playedAt: 1000 }),
testGame({ tournamentMatchId: 2, playedAt: 2000 }),
@@ -337,9 +338,9 @@ describe("matchedGames", () => {
test("skips a duplicate detection despite a couple of OCR-misread names", () => {
const matched = Scoreboards.matchedGames({
matches: [
testMatch({ t: 60 }),
testMatch(),
testMatch({
t: 65,
playedAt: 1005,
names: ["w1", "vv2", "w3", "w4", "l1", "l2", "l3", "I4"],
}),
],
@@ -374,9 +375,9 @@ describe("matchedGames", () => {
test("skips matches that have no matching game left", () => {
const matched = Scoreboards.matchedGames({
matches: [
testMatch({ t: 60 }),
testMatch(),
testMatch({
t: 5000,
playedAt: 1300,
names: ["i", "j", "k", "l", "m", "n", "o", "p"],
}),
],
@@ -483,8 +484,8 @@ describe("matchedGames", () => {
test("does not assign a game played before the previously assigned one", () => {
const matched = Scoreboards.matchedGames({
matches: [
testMatch({ t: 60, mode: "RM", stage: 1 }),
testMatch({ t: 1000, mode: "SZ", stage: 0 }),
testMatch({ playedAt: 2000, mode: "RM", stage: 1 }),
testMatch({ playedAt: 2100, mode: "SZ", stage: 0 }),
],
games: [
testGame({
@@ -504,6 +505,36 @@ describe("matchedGames", () => {
expect(matched.map(tournamentMatchIdOf)).toEqual([2]);
});
test("never links a read without a play time", () => {
const matched = Scoreboards.matchedGames({
matches: [testMatch({ playedAt: null })],
games: [testGame()],
});
expect(matched).toHaveLength(0);
});
test("links the play of a map reported nearest the read", () => {
const matched = Scoreboards.matchedGames({
matches: [testMatch({ playedAt: 3900 })],
games: [
testGame({ tournamentMatchId: 1, playedAt: 2600 }),
testGame({ tournamentMatchId: 2, playedAt: 4000 }),
],
});
expect(matched.map(tournamentMatchIdOf)).toEqual([2]);
});
test("leaves a read unlinked when no game was reported within 30 minutes of it", () => {
const matched = Scoreboards.matchedGames({
matches: [testMatch({ playedAt: 1000 + 31 * 60 })],
games: [testGame({ playedAt: 1000 })],
});
expect(matched).toHaveLength(0);
});
});
describe("deriveScoreboardData", () => {
@@ -786,7 +817,7 @@ describe("resolveContext", () => {
mapIndex: i,
mode,
stageId: stageId as StageId,
playedAt: 1000 + i,
playedAt: 1000 + i * 600,
...partial,
}),
context: { type: "tournament", tournamentId },
@@ -803,7 +834,7 @@ describe("resolveContext", () => {
mapIndex: i,
mode,
stageId: stageId as StageId,
playedAt: 1000 + i,
playedAt: 1000 + i * 600,
}),
target: {
type: "sendouq",
@@ -815,8 +846,8 @@ describe("resolveContext", () => {
}
const seenSequence = [
testMatch({ t: 60, mode: "SZ", stage: 0 }),
testMatch({ t: 600, mode: "TC", stage: 1 }),
testMatch({ playedAt: 1000, mode: "SZ", stage: 0 }),
testMatch({ playedAt: 1600, mode: "TC", stage: 1 }),
];
test("resolves the tournament whose games match the seen sequence", () => {
@@ -894,7 +925,7 @@ describe("resolveContext", () => {
const context = Scoreboards.resolveContext({
matches: [
seenSequence[0]!,
testMatch({ t: 300, stage: null }),
testMatch({ playedAt: 1300, stage: null }),
seenSequence[1]!,
],
games: [

View File

@@ -22,7 +22,7 @@ const MIN_LINKED_DUPLICATE_NAME_MATCHES = 6;
/** How many players on the winning (first) resp. losing side of a scoreboard. */
const PLAYERS_PER_TEAM = 4;
/** Matches that must align with one context's games before content resolution trusts it: one (mode, stage, sides) is common across a user's history, two carry order. */
/** Matches that must align with one context's games before content resolution trusts it. */
const MIN_RESOLVED_SCOREBOARDS = 2;
/**
@@ -127,11 +127,10 @@ export function resolveContext({
/**
* Decides which game result each ingested match links to.
*
* Only matches with a known winner and two full teams qualify (minimap-only reads never link).
* Matches and games are walked chronologically: each match takes an unassigned game of the same
* mode+stage whose sides agree with what is known. A match with a wall clock takes the game
* reported nearest it (none beyond `PLAYED_AT_TOLERANCE_MS`); a VoD read (video offsets only)
* takes the next in sequence.
* Only matches with a known winner, two full teams and a play time qualify (minimap-only and VoD
* reads never link). Matches and games are walked chronologically: each match takes the unassigned
* game of the same mode+stage reported nearest it whose sides agree with what is known, none
* beyond `PLAYED_AT_TOLERANCE_MS`.
*
* The sender is the POV player, so the roster they sit in pins the scan's sides — OCR'd names
* are too unreliable to overrule it. Only without a POV seat (cast footage) do in-game names
@@ -154,12 +153,12 @@ export function matchedGames({
const views = dedupeViews(
matches
.map((match, matchIndex) => {
const view = winnerFirstView(match, matchIndex);
const view = winnerFirstView(match);
return view ? { ...view, matchIndex } : null;
})
.filter((view): view is IndexedView => view !== null)
.filter((view): view is TimedView => view?.playedAt != null)
.filter((view) => isLinkableLobby(view.lobby))
.sort((a, b) => a.order - b.order),
.sort((a, b) => a.playedAt - b.playedAt),
);
const orderedGames = games.toSorted(
(a, b) => a.playedAt - b.playedAt || a.mapIndex - b.mapIndex,
@@ -231,7 +230,7 @@ export function deriveScoreboardData({
merged = Matches.mergeMatches(merged, other.data).merged;
}
const view = winnerFirstView(merged, 0);
const view = winnerFirstView(merged);
if (!view) return null;
const players = view.players.map(
@@ -260,7 +259,7 @@ export function deriveScoreboardData({
/** A match's players winner-first in row order (unread names as ""), or null without such a view — a game's `linkedPlayerNames`. */
export function winnerFirstPlayerNames(match: ScannerMatch): string[] | null {
const view = winnerFirstView(match, 0);
const view = winnerFirstView(match);
return view ? view.players.map((player) => player.name.trim()) : null;
}
@@ -279,12 +278,11 @@ interface WinnerFirstView {
povIndex: number | null;
/** wall-clock ms the game was played, when the read carried a clock at all */
playedAt: number | null;
/** chronological walk key: wall-clock, else video time, else input order */
order: number;
}
interface IndexedView extends WinnerFirstView {
interface TimedView extends WinnerFirstView {
matchIndex: number;
playedAt: number;
}
interface WinnerFirstPlayer {
@@ -297,10 +295,7 @@ interface WinnerFirstPlayer {
abilities?: AbilityWithUnknown[][];
}
function winnerFirstView(
match: ScannerMatch,
index: number,
): WinnerFirstView | null {
function winnerFirstView(match: ScannerMatch): WinnerFirstView | null {
if (match.winner === null) return null;
const winners = match.teams[match.winner];
const losers = match.teams[match.winner === 0 ? 1 : 0];
@@ -342,7 +337,6 @@ function winnerFirstView(
? match.pov.index
: PLAYERS_PER_TEAM + match.pov.index,
playedAt: match.playedAt,
order: match.playedAt ?? match.startsAt ?? index,
};
}
@@ -424,7 +418,7 @@ function attributePovUsers(
) {
for (const { data, povUserId } of linked) {
if (povUserId === null || data.pov === null) continue;
const view = winnerFirstView(data, 0);
const view = winnerFirstView(data);
if (!view || view.povIndex === null) continue;
if (players.some((player) => player.userId === povUserId)) continue;
@@ -458,8 +452,8 @@ function attributionIndex(
}
/** Drops re-detections of the same game within one request, with the same OCR-jitter tolerance as isLinkedDuplicate. */
function dedupeViews(sorted: IndexedView[]): IndexedView[] {
const result: IndexedView[] = [];
function dedupeViews(sorted: TimedView[]): TimedView[] {
const result: TimedView[] = [];
for (const view of sorted) {
const isDuplicate = result.some(
@@ -479,11 +473,11 @@ function dedupeViews(sorted: IndexedView[]): IndexedView[] {
/**
* Index of the game `view` links to, or null. Only games from `from` on are considered so two
* scans of one request never take the same game. A scan with a play time takes the candidate
* reported nearest it (keeps two plays of one map apart); one without takes the next in sequence.
* scans of one request never take the same game. The candidate reported nearest the scan's play
* time wins, which keeps two plays of one map apart.
*/
function pickGame(
view: IndexedView,
view: TimedView,
orderedGames: IngestableGame[],
from: number,
povUserId: number | null,
@@ -493,7 +487,6 @@ function pickGame(
for (let i = from; i < orderedGames.length; i++) {
const game = orderedGames[i]!;
if (!canLink(view, game, povUserId)) continue;
if (view.playedAt === null) return i;
const distance = Math.abs(
view.playedAt - databaseTimestampToJavascriptTimestamp(game.playedAt),

View File

@@ -442,35 +442,21 @@ describe("tournament flow", () => {
expect(laterPage.ingestedScoreboards).toHaveLength(0);
});
test("T2 VoD scan spanning two sets links each read into its own set", async () => {
test("T2 reads without a play time (VoD) are stored but never linked", async () => {
const w = await tournamentWorld();
const [set1, set2] = w.matchesOfTeam(w.championTeamId);
const games = [...(await w.games(set1!.id)), ...(await w.games(set2!.id))];
const [set1] = w.matchesOfTeam(w.championTeamId);
const games = await w.games(set1!.id);
const res = await ingest(
w.povUser,
games.map((game) => w.scanned(game, { playedAt: null })),
);
expect(res.linkedGamesCount).toBe(4);
expect(res.linkedMatches).toEqual(
[set1, set1, set2, set2].map((set, matchIndex) => ({
matchIndex,
link: {
type: "tournament",
tournamentId: w.tournamentId,
matchId: set!.id,
},
})),
);
const set1Page = await tournamentMatchPage(w.tournamentId, set1!.id);
expect(set1Page.ingestedScoreboards.map((sb) => sb.mapIndex)).toEqual([
0, 1,
]);
const set2Page = await tournamentMatchPage(w.tournamentId, set2!.id);
expect(set2Page.ingestedScoreboards.map((sb) => sb.mapIndex)).toEqual([
0, 1,
]);
expect(res.storedMatchesCount).toBe(games.length);
expect(res.linkedGamesCount).toBe(0);
expect(res.linkedMatches).toEqual([]);
const page = await tournamentMatchPage(w.tournamentId, set1!.id);
expect(page.ingestedScoreboards).toHaveLength(0);
});
test("T3 partial then fuller resend: the replay read merges into the stored partial and links", async () => {

View File

@@ -6,7 +6,7 @@ detects Splatoon 3 UI screens with OpenCV.js in a Web Worker, and parses them
into events speaking sendou.ink ids (`ModeShort`/`StageId`/weapon ids/`Ability`).
Events aggregate client-side into `ScannerMatch` objects
(`core/scanner-match.ts`) — one detected game per object, every field
nullable — which feed `/ingest` (features/scanner-ingest), the `/vods/new`
nullable — which feed `/ingest` (features/scanner-ingest, live captures only), the `/vods/new`
prefill, the match cards and the clip cutter. Imported from the emberz repo;
see `MIGRATION.md` there.
@@ -70,10 +70,11 @@ opens it, for anyone, through the same handoff Inspect uses.
- **Upload** is on by default when logged in (settings toggle, persisted).
Live: a scoreboard closes its match and sends it, a 15 s tick retries
unlinked matches on a backoff (`sendou-ingest.ts`) and flushes closed
matches whose send was skipped; Stop sends what is left. VoD: the whole
scan sends once saved. Both write per-event send statuses to their own
store (`updateEventsSend` takes the store), so the cards' upload status
button (`UploadStatus.tsx`) and its Retry/Upload are one code path.
matches whose send was skipped; Stop sends what is left. Per-event send
statuses back the cards' upload status button (`UploadStatus.tsx`) and its
Retry/Upload. VoD scans never upload: their reads carry no wall clock, and
without one a stranger's game can't be told apart from the uploader's
(linking needs a `playedAt` within 30 min of the game's report).
- **CSV** is a normal feature: `⇩ CSV` in every session header offers
`Matches` (`core/csv/matches.ts`, one row per game, the rows the cards
render) and `Raw detections` (`core/csv/events.ts`, one row per event).
@@ -192,8 +193,8 @@ sequenceDiagram
TL-->>UI: deduped timeline, status reads kept per run end (IndexedDB: events / vod-events)
UI->>MB: buildScannerMatches(events)
MB-->>UI: ScannerMatch[] + source events
UI->>ING: POST { matches } (live: on match close / stop, VoD: once saved)
ING->>ING: resolve context (current tournament/SendouQ activity, casts via staff roles, else content sequence ≥2)
UI->>ING: POST { matches } (live only: on match close / stop)
ING->>ING: resolve context (current tournament/SendouQ activity, casts via staff roles, else games reported around the play times ≥2)
ING->>DB: merge-store IngestedMatch (matchHash, isSameMatch + merge, context hints)
ING->>DB: link matches to game results → IngestedMatchLink (POV weapon → ReportedWeapon; scoreboards derived at read time)
Note over UI: VoD "Add to VoDs": ScannerMatch → slim prefill param → /vods/new
@@ -225,8 +226,8 @@ sequenceDiagram
scanner-ingest merges them server-side. Senders filter with
`ingestSkipReasons`: private/unread lobby only, and no games a disconnect
cut short (scoreless + counter left more time than the footage did, or
replayed right after on the same map — the latter is a VoD-scan filter in
practice since it only resolves after the fact).
replayed right after on the same map — the latter only resolves after the
fact, so a live scan may already have sent the game).
- The route (`routes/scanner.tsx`) is SSR-guarded: the client tree loads via
`React.lazy` after `useHydrated`; nothing from `core/worker/capture/store`
may be imported at route-module top level. There is no feature flag: the

View File

@@ -1,7 +1,7 @@
/**
* The landing: two entry cards (Live / File), the clip history strip and
* the sessions list. Anyone can capture, scan files and get clips locally;
* only uploading needs a login. Dropping a file anywhere here starts a scan;
* only uploading (live captures) needs a login. Dropping a file anywhere here starts a scan;
* an image opens the screenshot view instead, through the same handoff
* Inspect uses.
*/

View File

@@ -106,7 +106,8 @@ export function MatchCard({
justFormed: boolean;
/** false while the match is still being scanned: no expand button yet */
expandable: boolean;
upload: UploadState;
/** null when the session's matches never upload */
upload: UploadState | null;
/** the clips this game produced, best first */
clips: readonly ScannerClip[];
onPlayClip: (clip: ScannerClip) => void;
@@ -118,14 +119,13 @@ export function MatchCard({
const tabIdPrefix = useId();
// fixed at mount: re-rendering must not cut the animation short
const [enter] = useState(justFormed);
const [prevUploadKind, setPrevUploadKind] = useState(upload.kind);
const uploadKind = upload?.kind ?? null;
const [prevUploadKind, setPrevUploadKind] = useState(uploadKind);
const [flash, setFlash] = useState<"uploaded" | "failed" | null>(null);
if (prevUploadKind !== upload.kind) {
setPrevUploadKind(upload.kind);
if (prevUploadKind !== uploadKind) {
setPrevUploadKind(uploadKind);
setFlash(
upload.kind === "uploaded" || upload.kind === "failed"
? upload.kind
: null,
uploadKind === "uploaded" || uploadKind === "failed" ? uploadKind : null,
);
}
@@ -215,7 +215,9 @@ export function MatchCard({
) : null}
{expandable ? (
<span className={styles.footEnd}>
<UploadStatusButton state={upload} className={styles.circle} />
{upload ? (
<UploadStatusButton state={upload} className={styles.circle} />
) : null}
<SendouButton
variant="minimal"
size="small"

View File

@@ -17,12 +17,6 @@
font-variant-numeric: tabular-nums;
}
.headerEnd {
margin-inline-start: auto;
font-size: var(--font-2xs);
color: var(--color-text-high);
}
.progress {
width: 100%;
height: 8px;

View File

@@ -18,11 +18,9 @@ import {
export function ScanWorkers({
events,
headerEnd,
children,
}: {
events: ScanEvent[];
headerEnd: React.ReactNode;
children?: React.ReactNode;
}) {
const gameStarts = buildScannerMatches(events).flatMap(({ match }) =>
@@ -34,7 +32,6 @@ export function ScanWorkers({
<div className={styles.headerRow}>
<StatusPill>Scanning</StatusPill>
<ProgressText />
<div className={styles.headerEnd}>{headerEnd}</div>
</div>
<Lanes gameStarts={gameStarts} />
{children}

View File

@@ -90,8 +90,9 @@ export function SessionView({
header: (info: SessionInfo) => React.ReactNode;
getFrame: (event: ScanEvent) => GetFrame | undefined;
/** logged in: Retry/Upload buttons show */
canUpload: boolean;
onUpload: (built: BuiltMatch<ScanEvent>) => void;
canUpload?: boolean;
/** absent for sessions whose matches never upload (VoD scans): the cards show no upload state */
onUpload?: (built: BuiltMatch<ScanEvent>) => void;
emptyText: string;
/** rendered between the header and the clips (a scan's progress, an error) */
children?: React.ReactNode;
@@ -143,13 +144,17 @@ export function SessionView({
!newerGameStarted
)
}
upload={uploadStateOf({
send: aggregateSendStatus(b.sources),
skipReason: skipReasons.get(b),
lobby: b.match.lobby,
canUpload,
onUpload: () => onUpload(b),
})}
upload={
onUpload
? uploadStateOf({
send: aggregateSendStatus(b.sources),
skipReason: skipReasons.get(b),
lobby: b.match.lobby,
canUpload: canUpload ?? false,
onUpload: () => onUpload(b),
})
: null
}
clips={clipsByMatch[index]!}
onPlayClip={setPlaying}
getFrame={getFrame}

View File

@@ -33,17 +33,13 @@ import { ExportMenu } from "./ExportMenu";
import { NotFound } from "./NotFound";
import { ScanWorkers } from "./ScanWorkers";
import { SessionHeader } from "./SessionHeader";
import { type SessionInfo, SessionView } from "./SessionView";
import { matchContaining } from "./sendou-ingest";
import { SessionView } from "./SessionView";
import { sendouUpload } from "./sendou-upload";
import type { ScanEvent } from "./session-data";
import { useScannerSettings } from "./settings";
import { sendVod } from "./upload";
import { useDebug } from "./use-debug";
import styles from "./VodView.module.css";
import {
startVodScan,
uploadVodScan,
useVodScan,
useVodScanProgress,
vodScanFrame,
@@ -52,7 +48,7 @@ import { refreshVods } from "./vods-feed";
/**
* Builds keyed by the events array: views re-render for reasons other than new
* events (clips, upload state), and reusing the same `BuiltMatch` objects lets
* events (clips), and reusing the same `BuiltMatch` objects lets
* the unchanged cards skip rendering.
*/
const builtCache = new WeakMap<readonly ScanEvent[], BuiltMatch<ScanEvent>[]>();
@@ -68,8 +64,6 @@ export function VodView() {
/** The scan running (or finished) this visit. */
function ScanVodView({ name }: { name: string }) {
const scan = useVodScan();
const settings = useScannerSettings();
const user = useUser();
const [telemetryOn] = useSearchParam(scannerSearchParams, "telemetry");
const debug = useDebug();
const scanning = scan.status === "scanning";
@@ -80,10 +74,6 @@ function ScanVodView({ name }: { name: string }) {
events={scan.events}
running={scanning}
getFrame={vodScanFrame}
onUpload={(built) => {
const id = built.sources[0]?.id;
if (id !== undefined) void uploadVodScan(matchContaining(id));
}}
status={
scan.status === "error" ? (
<div className={styles.errorBox}>
@@ -107,10 +97,7 @@ function ScanVodView({ name }: { name: string }) {
</label>
</div>
) : scanning ? (
<ScanWorkers
events={scan.events}
headerEnd={`Upload ${user && settings.upload ? "on" : "off"}`}
>
<ScanWorkers events={scan.events}>
{scan.error ? (
<div className={styles.error}>{scan.error}</div>
) : null}
@@ -122,7 +109,6 @@ function ScanVodView({ name }: { name: string }) {
: scan.clipsWork?.state === "done" && scan.clipsWork.error
? `Clips: ${scan.clipsWork.error}`
: null}
{scan.uploading ? "Uploading…" : null}
{scan.error ? (
<span className={styles.error}>{scan.error}</span>
) : null}
@@ -146,21 +132,16 @@ function StoredVodView({ name }: { name: string }) {
if (stored.state === "missing") {
return <NotFound>This VoD is no longer saved.</NotFound>;
}
const { events, reload } = stored;
return (
<VodSessionView
name={name}
events={events}
events={stored.events}
running={false}
getFrame={(event) =>
event.hasFrame && event.id !== undefined
? () => loadVodEventFrame(event.id!)
: undefined
}
onUpload={(built) => {
const id = built.sources[0]?.id;
if (id !== undefined) void sendVod(name, matchContaining(id), reload);
}}
status={null}
telemetry={null}
/>
@@ -174,19 +155,17 @@ type StoredVod =
state: "ready";
summary: VodSummary;
events: ScanEvent[];
reload: () => void;
};
/** Loads a saved VoD's summary and events; `reload` re-reads them after a send. */
/** Loads a saved VoD's summary and events. */
function useStoredVod(name: string): StoredVod {
const [loaded, setLoaded] = useState<{
name: string;
summary: VodSummary | undefined;
events: ScanEvent[];
} | null>(null);
const [version, setVersion] = useState(0);
// the store is outside React: read it when the name (or version) changes
// the store is outside React: read it when the name changes
useEffect(() => {
let stale = false;
void Promise.all([loadVod(name), loadVodEvents(name)]).then(
@@ -197,7 +176,7 @@ function useStoredVod(name: string): StoredVod {
return () => {
stale = true;
};
}, [name, version]);
}, [name]);
if (!loaded || loaded.name !== name) return { state: "loading" };
if (!loaded.summary) return { state: "missing" };
@@ -205,7 +184,6 @@ function useStoredVod(name: string): StoredVod {
state: "ready",
summary: loaded.summary,
events: loaded.events,
reload: () => setVersion((v) => v + 1),
};
}
@@ -214,7 +192,6 @@ function VodSessionView({
events,
running,
getFrame,
onUpload,
status,
telemetry,
}: {
@@ -222,7 +199,6 @@ function VodSessionView({
events: ScanEvent[];
running: boolean;
getFrame: (event: ScanEvent) => (() => Promise<Blob | undefined>) | undefined;
onUpload: (built: SessionInfo["built"][number]) => void;
status: React.ReactNode;
telemetry: React.ReactNode;
}) {
@@ -250,8 +226,6 @@ function VodSessionView({
clips={vodClips}
clipsTitle="Clips"
running={running}
canUpload={Boolean(user)}
onUpload={onUpload}
getFrame={getFrame}
emptyText={
running

View File

@@ -3,8 +3,8 @@
* so requests are same-origin: the session cookie rides along and the
* logged-in user comes from the root loader (useUser); the server resolves
* the tournament/match. The unit of accounting is one ScannerMatch
* (core/match-builder.ts) — every source event's IndexedDB record, live or
* VoD, tracks its outcome (the `send` status the match cards display) — while
* (core/match-builder.ts) — every live source event's IndexedDB record tracks
* its outcome (the `send` status the match cards display) — while
* the unit of transport is a request of up to `MAX_MATCHES_PER_REQUEST`.
* Resends are safe: sendou.ink dedupes by content hash, merges partials, and
* scoreboards are first-ingest-wins.
@@ -43,11 +43,9 @@ export interface SendResult {
* through `writeSend` (calling `onStatus` after each request's store writes).
*
* Matches go out in as few requests as the server cap allows: sendou.ink
* resolves a whole request at once, so several matches anchor on their
* mode+stage sequence instead of one match's timestamp — this is what makes
* catching up on a session's backlog work. One request resolves to one
* context, so a backlog spanning two links the larger and leaves the rest
* "unlinked"; the retry carries only those, which then resolve on their own.
* resolves a whole request to one context, so a backlog spanning two links
* the larger and leaves the rest "unlinked"; the retry carries only those,
* which then resolve on their own.
*/
export async function sendMatches({
matches,

View File

@@ -1,7 +1,7 @@
/**
* The event shape every view renders, whichever store it came from: a live
* detection (`StoredEvent`) or a VoD's (`StoredVodEvent`) — both carry the
* detection, an optional thumbnail/frame and the /ingest send status.
* detection and an optional thumbnail/frame; live ones also their /ingest send status.
*/
import type { DetectedEvent } from "../core/detectors/types";
import type { SendStatus } from "../store/events";
@@ -14,6 +14,7 @@ export interface ScanEvent extends DetectedEvent {
thumbnail?: string;
/** whether a full-res frame can be loaded for the event */
hasFrame?: boolean;
/** live events only */
send?: SendStatus;
}

View File

@@ -1,6 +1,6 @@
/**
* Uploading to sendou.ink, shared by the live capture, VoD scans and the
* views' Retry/Upload buttons: one serialized sender per store, so sends
* Uploading to sendou.ink, shared by the live capture and the views'
* Retry/Upload buttons: one serialized sender per store, so sends
* never overlap (a send requested mid-flight runs right after), and one
* place that knows whether uploading is on at all (the setting, and a login).
*/
@@ -10,13 +10,8 @@ import {
listCompactedMatches,
updateCompactedMatchesSend,
} from "../store/compacted-matches";
import {
COMPACTED_MATCHES_STORE,
EVENTS_STORE,
VOD_EVENTS_STORE,
} from "../store/db";
import { COMPACTED_MATCHES_STORE, EVENTS_STORE } from "../store/db";
import { listEvents, type SendStatus, updateEventsSend } from "../store/events";
import { loadVodEvents } from "../store/vods";
import { refreshFeed } from "./events-feed";
import { type SendResult, sendMatches } from "./sendou-ingest";
import type { ScanEvent } from "./session-data";
@@ -26,7 +21,7 @@ export type MatchSelector = (built: BuiltMatch<ScanEvent>) => boolean;
interface SendRequest {
include: MatchSelector;
/** live: the session key built matches are loaded from; VoD: unused */
/** the session key built matches are loaded from */
since: number;
}
@@ -75,7 +70,11 @@ export function sendLive(
{ include, since },
{
load: async (from) => buildScannerMatches(await listEvents(from)),
writeSend: eventsSendWriter(EVENTS_STORE),
writeSend: (matches, sendStatus) =>
updateEventsSend(
matches.flatMap((built) => built.sources.map((event) => event.id!)),
sendStatus,
),
onStatus: refreshFeed,
},
);
@@ -102,23 +101,6 @@ export function sendCompacted(
);
}
/** Sends the VoD's matches `include` selects; `onStatus` runs after each status write. */
export function sendVod(
name: string,
include: MatchSelector,
onStatus: () => void,
): Promise<SendResult | null> {
return send(
`${VOD_EVENTS_STORE}:${name}`,
{ include, since: 0 },
{
load: async () => buildScannerMatches(await loadVodEvents(name)),
writeSend: eventsSendWriter(VOD_EVENTS_STORE),
onStatus,
},
);
}
async function send(
key: string,
request: SendRequest,
@@ -166,12 +148,3 @@ async function send(
}
return result;
}
function eventsSendWriter(store: string): SendTarget["writeSend"] {
return (matches, sendStatus) =>
updateEventsSend(
matches.flatMap((built) => built.sources.map((event) => event.id!)),
sendStatus,
store,
);
}

View File

@@ -6,8 +6,9 @@
* (worker/analyzer.worker.ts); the seek fallback drives a <video> element
* through a single worker. The finished scan persists under its file name
* (store/vods.ts), then the same path a live session takes runs: clips of
* the scored windows are cut off the file, and unsent matches upload. A
* scan is all or nothing: leaving the page cancels it and nothing is saved.
* the scored windows are cut off the file. Unlike a live session, a VoD's
* matches never upload: without a wall clock they can't be told apart from
* someone else's games. A scan is all or nothing: leaving the page cancels it and nothing is saved.
*/
import { useSyncExternalStore } from "react";
import * as R from "remeda";
@@ -41,11 +42,9 @@ import {
import { refreshClips } from "./clips-feed";
import { describeError } from "./errors";
import type { FixtureData } from "./fixture-export";
import { unsentMatches } from "./sendou-ingest";
import type { ScanEvent } from "./session-data";
import { readSettings } from "./settings";
import { thumbnailFromBlob } from "./thumbnail";
import { sendVod, uploadEnabled } from "./upload";
import { refreshVods } from "./vods-feed";
/** seek-fallback stride while the worker reports activity */
@@ -99,8 +98,6 @@ export interface VodScanSnapshot {
/** what the scan found, chronological; reloaded from the store once saved */
events: ScanEvent[];
clipsWork: ClipsWork | null;
/** matches uploading right after the scan */
uploading: boolean;
}
/** Kept apart from the snapshot: it ticks several times a second, which must not re-render the match cards. */
@@ -115,7 +112,6 @@ const IDLE: VodScanSnapshot = {
error: null,
events: [],
clipsWork: null,
uploading: false,
};
const IDLE_PROGRESS: VodScanProgressSnapshot = {
@@ -203,27 +199,6 @@ export function cancelVodScan(): void {
abortChunks?.();
}
/** Re-reads the saved events (send statuses) of the shown scan. */
async function reloadVodScanEvents(): Promise<void> {
if (!snapshot.name || snapshot.status === "scanning") return;
set({ events: await loadVodEvents(snapshot.name) });
}
/** Uploads the shown scan's unsent matches, or the ones `include` selects. */
export async function uploadVodScan(
include: (
built: Parameters<typeof unsentMatches>[0],
) => boolean = unsentMatches,
): Promise<void> {
if (!snapshot.name) return;
set({ uploading: true });
try {
await sendVod(snapshot.name, include, () => void reloadVodScanEvents());
} finally {
set({ uploading: false });
}
}
/**
* Scans `file` as fast as decoding allows; a finished scan replaces any saved
* one of the same name. `saveFrames` (debug mode) keeps each event's analyzed
@@ -507,14 +482,12 @@ export async function startVodScan(
data: event.data,
thumbnail: event.thumbnail,
frame: frames.get(event),
send: event.send,
})),
);
events = (await loadVodEvents(file.name)).map(toScanEvent);
update({ events, status: "done" });
void refreshVods();
await cutClips(file, events, update);
if (uploadEnabled()) await uploadVodScan();
}
}
@@ -600,7 +573,6 @@ function toScanEvent(event: StoredVodEvent): ScanEvent {
data: event.data,
thumbnail: event.thumbnail,
hasFrame: event.hasFrame,
send: event.send,
};
}

View File

@@ -171,14 +171,13 @@ function retainFrames(
};
}
/** Sets (or clears) the send status of the given events in one transaction; `storeName` picks the live or VoD store. */
/** Sets (or clears) the send status of the given events in one transaction. */
export async function updateEventsSend(
ids: number[],
send: SendStatus | undefined,
storeName: string = EVENTS_STORE,
): Promise<void> {
await readwrite([storeName], (transaction) => {
const events = transaction.objectStore(storeName);
await readwrite([EVENTS_STORE], (transaction) => {
const events = transaction.objectStore(EVENTS_STORE);
for (const id of ids) {
const get = events.get(id) as IDBRequest<StoredEvent | undefined>;
get.onsuccess = () => {

View File

@@ -1,8 +1,7 @@
/**
* Persistence for scanned VoDs, keyed by file name so a video can be reopened
* without re-decoding. The summary lives in `vods`, detections in
* `vod-events` under a `vod` index (each carrying its own /ingest send status,
* like a live event), full-res PNGs in `vod-frames` under the event id
* `vod-events` under a `vod` index, full-res PNGs in `vod-frames` under the event id
* (loadVodEventFrame), so listing stays cheap. Re-scanning the same file name
* overwrites the previous save.
*/
@@ -15,7 +14,6 @@ import {
VOD_FRAMES_STORE,
VODS_STORE,
} from "./db";
import type { SendStatus } from "./events";
export interface VodSummary {
/** VoD file name — primary key */
@@ -41,7 +39,6 @@ export interface StoredVodEvent {
thumbnail?: string;
/** whether a full-res frame exists in `vod-frames` under this id */
hasFrame?: boolean;
send?: SendStatus;
}
/** A vod-event to persist, with its (separately stored) frame attached. */