mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-29 22:58:18 -05:00
No uploads from view=vod for now
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user