From 1dd8dc9987868e972907bbf5e0cc0bc3d3903a4c Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:00:32 +0300 Subject: [PATCH] Tolerate K/S blips --- app/features/scanner/README.md | 7 +- .../scanner/components/events-feed.ts | 6 +- .../scanner/components/live-session.ts | 8 ++- app/features/scanner/components/vod-scan.ts | 11 +-- app/features/scanner/core/timeline/index.ts | 59 +++++++++++++++- app/features/scanner/tests/timeline.test.ts | 70 +++++++++++++++++++ 6 files changed, 146 insertions(+), 15 deletions(-) diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index 67a5d3fed..70309dfaf 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -168,7 +168,7 @@ sequenceDiagram Cap->>W: frame + t (live/screenshot/seek) — VoD: worker decodes its own slice W->>W: scheduler dueDetectors() → gate() → parse() W-->>TL: DetectedEvents - TL-->>UI: deduped timeline (IndexedDB: events / vod-events) + 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) @@ -323,8 +323,9 @@ sequenceDiagram keeps continuously-firing events from storing a frame PNG each, and the worker only encodes a frame at all when a shadow `TimelineBuilder` (same defaults as the page's) says an event would be listed rather than merged - into an earlier read — a 1080p PNG per repeat read cost more than the - parse once the kill feed re-read its stack twice a second. Frames no + into an earlier read (or extend a status run's trailing read) — a 1080p + PNG per repeat read cost more than the parse once the kill feed re-read + its stack twice a second. Frames no detector is due for skip canvas readback, and everything is counted in `core/detectors/telemetry.ts` — but only when a VoD is scanned with `?telemetry=true` in the URL (nothing links there) by a debug user; diff --git a/app/features/scanner/components/events-feed.ts b/app/features/scanner/components/events-feed.ts index 9b740f98e..e8b28d09c 100644 --- a/app/features/scanner/components/events-feed.ts +++ b/app/features/scanner/components/events-feed.ts @@ -150,15 +150,17 @@ async function toSessions(events: StoredEvent[]): Promise { return sessions; } -/** Changes when an event joins, leaves, or its send status moves. */ +/** Changes when an event joins, leaves, moves (a sampled run's trailing read) or its send status moves. */ function signatureOf(events: readonly StoredEvent[]): string { let sends = 0; let latest = 0; + let latestT = Number.NEGATIVE_INFINITY; for (const event of events) { + latestT = Math.max(latestT, event.t); if (event.send) { sends++; latest = Math.max(latest, event.send.at); } } - return `${events.length}:${events.at(-1)?.id ?? 0}:${sends}:${latest}`; + return `${events.length}:${events.at(-1)?.id ?? 0}:${latestT}:${sends}:${latest}`; } diff --git a/app/features/scanner/components/live-session.ts b/app/features/scanner/components/live-session.ts index f2c4ca22f..2aac95817 100644 --- a/app/features/scanner/components/live-session.ts +++ b/app/features/scanner/components/live-session.ts @@ -398,7 +398,7 @@ function onResult( continue; } const action = timeline.push(event); - if (action.action !== "added" && action.action !== "replaced") continue; + if (action.action === "merged" || action.action === "dropped") continue; if (event.type === MAP_START_EVENT_TYPE) { const mode = (event.data as MapStartData).mode; objectiveBlocked = mode !== null && mode !== "SZ"; @@ -406,8 +406,10 @@ function onResult( objectiveBlocked = false; } const stale = - action.action === "replaced" ? storedIds.get(action.replaced) : undefined; - void persist(event, result.frame, stale); + action.action === "added" ? undefined : storedIds.get(action.replaced); + // a run's trailing read only moves its time; the run's first read keeps the frame + const frame = action.action === "extended" ? undefined : result.frame; + void persist(event, frame, stale); } } diff --git a/app/features/scanner/components/vod-scan.ts b/app/features/scanner/components/vod-scan.ts index 75a0720ea..430bac2ff 100644 --- a/app/features/scanner/components/vod-scan.ts +++ b/app/features/scanner/components/vod-scan.ts @@ -218,18 +218,19 @@ export async function startVodScan( if (!result.gate.pass) return; for (const event of result.events as DetectedEvent[]) { const action = timeline.push(event); - if (action.action !== "added" && action.action !== "replaced") + if (action.action === "merged" || action.action === "dropped") continue; - const frame = result.frame; + const frame = + action.action === "extended" ? undefined : result.frame; thumbnailWork.push( (async () => { const thumbnail = frame ? await thumbnailFromBlob(frame) : undefined; const replaced = - action.action === "replaced" - ? events.find((e) => sameEvent(e, action.replaced)) - : undefined; + action.action === "added" + ? undefined + : events.find((e) => sameEvent(e, action.replaced)); const scanEvent: ScanEvent = { ...event, thumbnail, diff --git a/app/features/scanner/core/timeline/index.ts b/app/features/scanner/core/timeline/index.ts index 36e731303..f5d76cd61 100644 --- a/app/features/scanner/core/timeline/index.ts +++ b/app/features/scanner/core/timeline/index.ts @@ -1,6 +1,8 @@ /** * TimelineBuilder: same-type events within a merge window collapse into one * (highest confidence kept); events below a confidence floor are dropped. + * Sampled types (per-frame state reads) keep the ends of every same-state run + * instead, see `sampledTypes`. */ import { KILL_EVENT_TYPE, sameKillData } from "../detectors/kill/index"; @@ -38,6 +40,16 @@ export interface TimelineOptions { minConfidence: number; /** per-type floor overrides: evidence events scored on a different scale (raw NCC peaks) opt out of the shared floor */ minConfidenceByType: Record; + /** + * per-frame state samples, where every read carries state at its instant + * and a run of same-state reads is a series rather than one repeated + * screen. Instead of collapsing the run into its first read, the builder + * keeps that first read, the latest read (re-placed as the run grows — + * `extended`) and one read per merge window in between, so the run's ends + * are exact and a lone misread stays flanked by the reads either side of + * it for the match builder's smoothing. Confidence never moves a sample. + */ + sampledTypes: readonly string[]; } const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = { @@ -48,8 +60,9 @@ const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = { // frames merge only within one open (a mid-open dead/special flip stays its // own event via the content guard). Objective: reads repeat every second; the // content guard keeps every change while static stretches collapse. - // PlayerStatus: a state can recur no sooner than a respawn (~9s), so the - // window stays under that. StripWeapons: sampled every ~5s, each distinct evidence. + // PlayerStatus: sampled (see `sampledTypes`); the window is how often a + // standing state is re-confirmed, under the renderer's 15s unknown-gap rule. + // StripWeapons: sampled every ~5s, each distinct evidence. // Kill: the same stack re-read while it shows merges; a splatted player // can't re-enter the feed before respawning (~8.5s), so the window stays // under that and the content guard splits a growing stack. @@ -74,11 +87,14 @@ const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = { minConfidenceByType: { [STRIP_WEAPONS_EVENT_TYPE]: 0, }, + sampledTypes: [PLAYER_STATUS_EVENT_TYPE], }; export type TimelineAction = | { action: "added"; event: DetectedEvent } | { action: "replaced"; event: DetectedEvent; replaced: DetectedEvent } + /** a sampled run's trailing read moved forward: same stored slot, no new frame worth keeping */ + | { action: "extended"; event: DetectedEvent; replaced: DetectedEvent } | { action: "merged"; into: DetectedEvent } | { action: "dropped"; reason: "low-confidence" }; @@ -104,6 +120,9 @@ export class TimelineBuilder { const window = this.#options.mergeWindowByType[event.type] ?? this.#options.mergeWindow; const same = this.#options.sameEventDataByType[event.type]; + if (this.#options.sampledTypes.includes(event.type)) { + return this.#pushSample(event, window, same); + } const near = this.#events.find( (e) => e.type === event.type && @@ -122,4 +141,40 @@ export class TimelineBuilder { } return { action: "merged", into: near }; } + + /** + * Same-state neighbours (by `t`) decide a sample's fate: none within the + * window → added; already bracketed by two → merged (a late-arriving VoD + * read); following the run's trailing read while the kept read before it is + * still within the window → extends, replacing that trailing read; otherwise + * added as the run's new trailing read. + */ + #pushSample( + event: DetectedEvent, + window: number, + same: ((a: unknown, b: unknown) => boolean) | undefined, + ): TimelineAction { + const continues = (earlier: DetectedEvent, later: DetectedEvent) => + later.t - earlier.t <= window && + (same?.(earlier.data, later.data) ?? true); + const sameType = this.#events.filter((e) => e.type === event.type); + const nextIndex = sameType.findIndex((e) => e.t > event.t); + const before = nextIndex === -1 ? sameType : sameType.slice(0, nextIndex); + const next = nextIndex === -1 ? undefined : sameType[nextIndex]; + const prev = before.at(-1); + const kept = before.at(-2); + if (prev && continues(prev, event)) { + if (prev.t === event.t || (next && continues(event, next))) { + return { action: "merged", into: prev }; + } + if (kept && continues(kept, prev) && continues(kept, event)) { + this.#events[this.#events.indexOf(prev)] = event; + this.#events.sort((a, b) => a.t - b.t); + return { action: "extended", event, replaced: prev }; + } + } + this.#events.push(event); + this.#events.sort((a, b) => a.t - b.t); + return { action: "added", event }; + } } diff --git a/app/features/scanner/tests/timeline.test.ts b/app/features/scanner/tests/timeline.test.ts index 58bc98df3..b84b2df1f 100644 --- a/app/features/scanner/tests/timeline.test.ts +++ b/app/features/scanner/tests/timeline.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import type { PlayerStatusData } from "../core/detectors/objective/player-status"; import type { ScoreboardData } from "../core/detectors/scoreboard/index"; import type { DetectedEvent } from "../core/detectors/types"; import { TimelineBuilder } from "../core/timeline/index"; @@ -186,3 +187,72 @@ test("kill stacks merge while unchanged and split when a row enters", () => { assert.equal(kill(104, ["datkid", "24K"]).action, "merged"); assert.equal(tl.events.length, 2); }); + +const NO_FLAGS: PlayerStatusData["dead"] = [ + [false, false, false, false], + [false, false, false, false], +]; + +/** A PlayerStatus read; `dead` flags slot 0 of the left team. */ +function status(t: number, confidence = 0.8, dead = false): DetectedEvent { + const data: PlayerStatusData = { + time: null, + special: NO_FLAGS, + dead: [ + [dead, false, false, false], + [false, false, false, false], + ], + layout: "even", + cast: null, + }; + return { type: "PlayerStatus", t, confidence, data }; +} + +test("status reads keep a run's first read and trailing read, re-confirmed every window", () => { + const tl = new TimelineBuilder(); + assert.equal(tl.push(status(100)).action, "added"); + assert.equal(tl.push(status(101)).action, "added"); + const third = tl.push(status(102)); + assert.equal(third.action, "extended"); + assert.equal(third.action === "extended" ? third.replaced.t : null, 101); + for (let t = 103; t <= 112; t++) tl.push(status(t)); + assert.deepEqual( + tl.events.map((e) => e.t), + [100, 105, 110, 112], + ); +}); + +test("a one-read blip stays flanked by the reads either side of it", () => { + const tl = new TimelineBuilder(); + for (let t = 100; t <= 102; t++) tl.push(status(t)); + assert.equal(tl.push(status(103, 0.8, true)).action, "added"); + assert.equal(tl.push(status(104)).action, "added"); + assert.equal(tl.push(status(105)).action, "added"); + assert.equal(tl.push(status(106)).action, "extended"); + assert.deepEqual( + tl.events.map( + (e) => `${e.t}${(e.data as PlayerStatusData).dead[0][0] ? "x" : ""}`, + ), + ["100", "102", "103x", "104", "106"], + ); +}); + +test("a higher-confidence repeat never moves a sample", () => { + const tl = new TimelineBuilder(); + tl.push(status(100, 0.7)); + assert.equal(tl.push(status(101, 0.95)).action, "added"); + assert.equal(tl.events[0]!.t, 100); +}); + +test("a late read inside an existing run merges, as does a same-frame repeat", () => { + const tl = new TimelineBuilder(); + tl.push(status(100)); + tl.push(status(101)); + tl.push(status(104)); + assert.equal(tl.push(status(102)).action, "merged"); + assert.equal(tl.push(status(104)).action, "merged"); + assert.deepEqual( + tl.events.map((e) => e.t), + [100, 104], + ); +});