diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index c5158874c..db91cb3b4 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -116,8 +116,10 @@ sequenceDiagram the gate. `checkIntervalS` hard-caps both phases; `attachFrame: false` keeps continuously-firing events from storing a frame PNG each. Frames no detector is due for skip canvas readback, and everything is counted in - `core/detectors/telemetry.ts` (VoD tab's telemetry panel). A match's - objective reads render as one step-line timeline + `core/detectors/telemetry.ts` — but only when the VoD tab is opened with + `?telemetry=true` (nothing links there); otherwise the workers skip + collection and the panel stays hidden. A match's objective reads render + as one step-line timeline (`~/components/ObjectiveTimeline.tsx`, shared with the match page). - VoD scans (`components/VodPage.tsx`): on the WebCodecs path each worker demuxes + decodes its own contiguous slice (mediabunny in the worker — no diff --git a/app/features/scanner/components/VodPage.tsx b/app/features/scanner/components/VodPage.tsx index 76aebf6b4..95f975246 100644 --- a/app/features/scanner/components/VodPage.tsx +++ b/app/features/scanner/components/VodPage.tsx @@ -22,6 +22,7 @@ import { SendouButton } from "~/components/elements/Button"; import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu"; import { FormWithConfirm } from "~/components/FormWithConfirm"; import { ObjectiveTimeline } from "~/components/ObjectiveTimeline"; +import { useSearchParam } from "~/modules/search-params/hooks"; import { openSeekScan, probeWebCodecs } from "../capture/vod-frames"; import { connectAbilities } from "../core/ability-harvest"; import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index"; @@ -36,6 +37,7 @@ import { invalidObjectiveEvents, } from "../core/match-builder"; import { TimelineBuilder } from "../core/timeline/index"; +import { scannerSearchParams } from "../scanner-search-params"; import type { SendStatus } from "../store/events"; import { deleteVod, @@ -133,6 +135,9 @@ export function VodPage({ // in-flight thumbnail work; awaited before persisting a finished scan const sideWorkRef = useRef[]>([]); const nextMatchKeyRef = useRef(0); + // telemetry collection is baked into the workers at init, so a change of + // the search param needs a fresh pool + const clientsCollectTelemetryRef = useRef(false); const [fileName, setFileName] = useState(null); /** live scan vs. reopened saved VoD (no video element for the latter) */ @@ -149,6 +154,9 @@ export function VodPage({ const [eventsOpen, setEventsOpen] = useState(false); const [resultsSend, setResultsSend] = useState(null); + // opt-in via ?telemetry=true only; nothing in the UI links to it + const [collectTelemetry] = useSearchParam(scannerSearchParams, "telemetry"); + const formatSavedAt = useEventDateTimeFormatter(); const abilityMap = connectAbilities(matches.map((m) => m.event)); @@ -259,6 +267,11 @@ export function VodPage({ urlRef.current = URL.createObjectURL(file); video.src = urlRef.current; + if (clientsCollectTelemetryRef.current !== collectTelemetry) { + for (const client of clientsRef.current) client.dispose(); + clientsRef.current = []; + clientsCollectTelemetryRef.current = collectTelemetry; + } if (clientsRef.current.length === 0) { clientsRef.current = Array.from( { length: defaultScanWorkerCount() }, @@ -310,6 +323,7 @@ export function VodPage({ frameDoneRef.current?.(); frameDoneRef.current = null; }, + { collectTelemetry }, ), ); } @@ -365,10 +379,12 @@ export function VodPage({ abortScanRef.current = () => { for (const client of clients) client.abortChunk(); }; - const mergedTelemetry = () => - mergeScanTelemetry( - chunks.flatMap((c) => (c.telemetry ? [c.telemetry] : [])), + const mergedTelemetry = () => { + const parts = chunks.flatMap((c) => + c.telemetry ? [c.telemetry] : [], ); + return parts.length > 0 ? mergeScanTelemetry(parts) : null; + }; let lastUiUpdate = Number.NEGATIVE_INFINITY; const pushUiUpdate = () => { const now = performance.now(); diff --git a/app/features/scanner/scanner-search-params.test.ts b/app/features/scanner/scanner-search-params.test.ts index 56af67ec4..77b9f5456 100644 --- a/app/features/scanner/scanner-search-params.test.ts +++ b/app/features/scanner/scanner-search-params.test.ts @@ -10,6 +10,7 @@ describe("scannerSearchParams", () => { assertRoundTrips(scannerSearchParams, { tab: ["live", "screenshot", "vod"], inspect: ["1723456789012-abc123", null], + telemetry: [true, false], }); }); diff --git a/app/features/scanner/scanner-search-params.ts b/app/features/scanner/scanner-search-params.ts index fcb4a8b0e..1dcf0adc0 100644 --- a/app/features/scanner/scanner-search-params.ts +++ b/app/features/scanner/scanner-search-params.ts @@ -10,4 +10,9 @@ export const scannerSearchParams = SearchParams.define({ tab: SP.param(z.enum(SCANNER_TABS), { default: "live", loader: false }), /** Inspect handoff key: the screenshot tab claims this frame on load */ inspect: SP.param(z.string().max(100).nullable(), { loader: false }), + /** + * Opt-in scan telemetry: counters are accumulated and the panel is shown + * only when this is set by hand in the URL (no link points at it) + */ + telemetry: SP.param(z.boolean(), { default: false, loader: false }), }); diff --git a/app/features/scanner/worker/analyzer.worker.ts b/app/features/scanner/worker/analyzer.worker.ts index e0a1aaef7..02ffb56d7 100644 --- a/app/features/scanner/worker/analyzer.worker.ts +++ b/app/features/scanner/worker/analyzer.worker.ts @@ -28,6 +28,7 @@ import { DetectorScheduler } from "../core/detectors/scheduler"; import { createScanTelemetry, detectorTelemetry, + type ScanTelemetry, } from "../core/detectors/telemetry"; import type { Detector } from "../core/detectors/types"; import { normalizeFrame, toMat } from "../core/image"; @@ -53,7 +54,9 @@ const PREVIEW_HEIGHT = 270; let detectors: Detector[] = []; let scheduler: DetectorScheduler | null = null; -let telemetry = createScanTelemetry(); +/** null unless the init message asked for telemetry */ +let telemetry: ScanTelemetry | null = null; +let collectTelemetry = false; let chunkAborted = false; /** last per-frame t, to reset telemetry when a new session rewinds the clock */ let lastFrameT = Number.NEGATIVE_INFINITY; @@ -65,6 +68,7 @@ function post(message: WorkerResponse, transfer: Transferable[] = []): void { async function init({ assetsBaseUrl, suppressSteadyFrames = true, + collectTelemetry: collect = false, }: InitRequest): Promise { try { await loadOpenCV(); @@ -75,7 +79,8 @@ async function init({ matchOpeningTypes: [MAP_START_EVENT_TYPE], matchClosingTypes: SCOREBOARD_EVENT_TYPES, }); - telemetry = createScanTelemetry(); + collectTelemetry = collect; + telemetry = freshTelemetry(); post({ kind: "ready" }); } catch (error) { post({ kind: "error", message: `init failed: ${String(error)}` }); @@ -115,7 +120,7 @@ async function analyzeFrame( } finally { src.delete(); } - telemetry.analyzedFrames++; + if (telemetry) telemetry.analyzedFrames++; // On detection, ship back the exact analyzed pixels (lossless, at capture // resolution) so the UI never has to re-grab a later frame — encoded at @@ -127,21 +132,27 @@ async function analyzeFrame( try { for (const detector of detectors) { if (!due.includes(detector.id)) continue; - const counters = detectorTelemetry(telemetry, detector.id); - counters.checks++; - const gateStart = performance.now(); + const counters = telemetry + ? detectorTelemetry(telemetry, detector.id) + : null; + const gateStart = counters ? performance.now() : 0; const gate = detector.gate(frame); - counters.gateMs += performance.now() - gateStart; + if (counters) { + counters.checks++; + counters.gateMs += performance.now() - gateStart; + } scheduler!.recordGate(detector.id, t, gate.pass, gate.signature); - if (gate.pass) counters.gatePasses++; + if (counters && gate.pass) counters.gatePasses++; const runParse = gate.pass && scheduler!.shouldParse(detector.id, t); - if (gate.pass && !runParse) counters.suppressedParses++; + if (counters && gate.pass && !runParse) counters.suppressedParses++; let events: ReturnType = []; if (runParse) { - const parseStart = performance.now(); + const parseStart = counters ? performance.now() : 0; events = detector.parse(frame, t, gate); - counters.parses++; - counters.parseMs += performance.now() - parseStart; + if (counters) { + counters.parses++; + counters.parseMs += performance.now() - parseStart; + } scheduler!.recordParse(detector.id, t, events); } const blob = @@ -163,7 +174,7 @@ async function analyzeFrame( } async function analyze({ bitmap, t }: AnalyzeRequest): Promise { - if (t + 5 < lastFrameT) telemetry = createScanTelemetry(); + if (t + 5 < lastFrameT) telemetry = freshTelemetry(); lastFrameT = t; try { await analyzeFrame(bitmap, t); @@ -181,7 +192,7 @@ async function scanChunk({ }: ScanChunkRequest): Promise { chunkAborted = false; scheduler!.reset(tStart); - telemetry = createScanTelemetry(); + telemetry = freshTelemetry(); const wallStart = performance.now(); let lastProgressAt = 0; let lastPreviewAt = 0; @@ -201,11 +212,13 @@ async function scanChunk({ const packets = new EncodedPacketSink(track); const handleSample = async (sample: VideoSample): Promise => { - telemetry.decodedFrames++; const t = sample.timestamp; - const span = Math.max(0, t - cursor); - if (mode === "active") telemetry.activeVideoS += span; - else telemetry.skimVideoS += span; + if (telemetry) { + telemetry.decodedFrames++; + const span = Math.max(0, t - cursor); + if (mode === "active") telemetry.activeVideoS += span; + else telemetry.skimVideoS += span; + } cursor = Math.max(cursor, t); const frame = sample.toVideoFrame(); sample.close(); @@ -225,7 +238,7 @@ async function scanChunk({ } if (preview || now - lastProgressAt >= PROGRESS_POST_INTERVAL_MS) { lastProgressAt = now; - telemetry.wallMs = performance.now() - wallStart; + if (telemetry) telemetry.wallMs = performance.now() - wallStart; post( { kind: "chunkProgress", @@ -282,7 +295,7 @@ async function scanChunk({ } } - telemetry.wallMs = performance.now() - wallStart; + if (telemetry) telemetry.wallMs = performance.now() - wallStart; post({ kind: "chunkDone", chunkIndex, telemetry }); } catch (error) { post({ @@ -294,6 +307,10 @@ async function scanChunk({ } } +function freshTelemetry(): ScanTelemetry | null { + return collectTelemetry ? createScanTelemetry() : null; +} + self.onmessage = (e: MessageEvent) => { const msg = e.data as WorkerRequest; if (msg.kind === "init") void init(msg); diff --git a/app/features/scanner/worker/client.ts b/app/features/scanner/worker/client.ts index 9b87d378b..e586db72b 100644 --- a/app/features/scanner/worker/client.ts +++ b/app/features/scanner/worker/client.ts @@ -16,7 +16,8 @@ export type ResultHandler = ( export type ErrorHandler = (message: string) => void; export interface DoneInfo { calm: boolean; - telemetry: ScanTelemetry; + /** null unless the client was created with collectTelemetry */ + telemetry: ScanTelemetry | null; } export type DoneHandler = (t: number, info: DoneInfo) => void; export type ChunkProgress = Extract; @@ -29,7 +30,7 @@ export function defaultScanWorkerCount(): number { } interface PendingChunk { - resolve(telemetry: ScanTelemetry): void; + resolve(telemetry: ScanTelemetry | null): void; reject(error: Error): void; onProgress?: ChunkProgressHandler; } @@ -51,7 +52,10 @@ export class AnalyzerClient { // biome-ignore lint/suspicious/noConsole: default sink for worker errors when no handler is passed onError: ErrorHandler = console.error, onDone?: DoneHandler, - options: { suppressSteadyFrames?: boolean } = {}, + options: { + suppressSteadyFrames?: boolean; + collectTelemetry?: boolean; + } = {}, ) { this.#onResult = onResult; this.#onError = onError; @@ -104,6 +108,7 @@ export class AnalyzerClient { kind: "init", assetsBaseUrl: Config.staticAssetsUrl, suppressSteadyFrames: options.suppressSteadyFrames ?? true, + collectTelemetry: options.collectTelemetry ?? false, }); } @@ -141,7 +146,7 @@ export class AnalyzerClient { scanChunk( request: { file: File; chunkIndex: number; tStart: number; tEnd: number }, onProgress?: ChunkProgressHandler, - ): Promise { + ): Promise { if (this.busy) { return Promise.reject(new Error("analyzer is busy")); } diff --git a/app/features/scanner/worker/protocol.ts b/app/features/scanner/worker/protocol.ts index 399ca25a7..ca4d4d466 100644 --- a/app/features/scanner/worker/protocol.ts +++ b/app/features/scanner/worker/protocol.ts @@ -16,6 +16,12 @@ export interface InitRequest { * off to get every detector on every frame */ suppressSteadyFrames?: boolean; + /** + * accumulate scan telemetry counters (and time the detectors) so they can + * be reported back with progress and done messages; default false — the + * VoD tab only asks for them when the telemetry panel is opted into + */ + collectTelemetry?: boolean; } export interface AnalyzeRequest { @@ -70,7 +76,8 @@ export type WorkerResponse = t: number; /** scheduler sees dead air — the caller may widen its sampling stride */ calm: boolean; - telemetry: ScanTelemetry; + /** null when the worker was not asked to collect telemetry */ + telemetry: ScanTelemetry | null; } | { kind: "chunkProgress"; @@ -78,9 +85,9 @@ export type WorkerResponse = /** seconds of video the chunk scan has reached */ t: number; mode: "active" | "skim"; - telemetry: ScanTelemetry; + telemetry: ScanTelemetry | null; /** small bitmap of the latest decoded frame, for the preview canvas */ preview?: ImageBitmap; } - | { kind: "chunkDone"; chunkIndex: number; telemetry: ScanTelemetry } + | { kind: "chunkDone"; chunkIndex: number; telemetry: ScanTelemetry | null } | { kind: "error"; message: string };