Tolerate K/S blips

This commit is contained in:
Kalle
2026-09-20 15:00:32 +03:00
parent d3bb24737d
commit 1dd8dc9987
6 changed files with 146 additions and 15 deletions

View File

@@ -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;

View File

@@ -150,15 +150,17 @@ async function toSessions(events: StoredEvent[]): Promise<LiveSession[]> {
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}`;
}

View File

@@ -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);
}
}

View File

@@ -218,18 +218,19 @@ export async function startVodScan(
if (!result.gate.pass) return;
for (const event of result.events as DetectedEvent<FixtureData>[]) {
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,

View File

@@ -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<string, number>;
/**
* 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 };
}
}

View File

@@ -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],
);
});