diff --git a/app/features/scanner/components/SessionView.tsx b/app/features/scanner/components/SessionView.tsx index 2b478662f..209b9d277 100644 --- a/app/features/scanner/components/SessionView.tsx +++ b/app/features/scanner/components/SessionView.tsx @@ -40,6 +40,13 @@ import { useDebug } from "./use-debug"; const NO_KEYS: ReadonlySet = new Set(); +/** + * 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 + * the unchanged cards skip rendering. + */ +const builtCache = new WeakMap[]>(); + type LobbyGroup = "private" | "x" | "other"; const LOBBY_GROUPS: LobbyGroup[] = ["private", "x", "other"]; @@ -97,7 +104,7 @@ export function SessionView({ const debug = useDebug(); const [playing, setPlaying] = useState(null); - const built = buildScannerMatches(events); + const built = cachedBuild(events); const skipReasons = ingestSkipReasons(built); const clipsByMatch = built.map((b, index) => clipsOf(b.match, built[index + 1]?.match, clips), @@ -237,6 +244,14 @@ function MatchList({ ); } +function cachedBuild(events: readonly ScanEvent[]): BuiltMatch[] { + const cached = builtCache.get(events); + if (cached) return cached; + const built = buildScannerMatches(events); + builtCache.set(events, built); + return built; +} + function lobbyGroup(lobby: ScannerLobby | null): LobbyGroup { if (lobby === "PRIVATE") return "private"; if (lobby === "X") return "x"; diff --git a/app/features/scanner/components/VodView.tsx b/app/features/scanner/components/VodView.tsx index 5d3a17451..571b4195e 100644 --- a/app/features/scanner/components/VodView.tsx +++ b/app/features/scanner/components/VodView.tsx @@ -44,6 +44,7 @@ import { startVodScan, uploadVodScan, useVodScan, + useVodScanProgress, vodScanFrame, } from "./vod-scan"; import { refreshVods } from "./vods-feed"; @@ -96,19 +97,7 @@ function ScanVodView({ name }: { name: string }) { ) : scanning ? (
-
- Scanning - - - {scan.progress - ? `${Math.round((scan.progress.t / Math.max(1, scan.progress.duration)) * 100)}% · ${formatPosition(scan.progress.t)} / ${formatPosition(scan.progress.duration)}${scan.progress.rate > 0 ? ` · ${scan.progress.rate.toFixed(1)}× realtime` : ""}` - : "opening the file…"} - -
+
@@ -133,11 +122,36 @@ function ScanVodView({ name }: { name: string }) {
) } - telemetry={debug && telemetryOn ? scan.telemetry : null} + telemetry={debug && telemetryOn ? : null} /> ); } +function ScanProgressRow() { + const { progress } = useVodScanProgress(); + + return ( +
+ Scanning + + + {progress + ? `${Math.round((progress.t / Math.max(1, progress.duration)) * 100)}% · ${formatPosition(progress.t)} / ${formatPosition(progress.duration)}${progress.rate > 0 ? ` · ${progress.rate.toFixed(1)}× realtime` : ""}` + : "opening the file…"} + +
+ ); +} + +function ScanTelemetryPanel() { + const { telemetry } = useVodScanProgress(); + return telemetry ? : null; +} + /** A saved scan reopened from the landing. */ function StoredVodView({ name }: { name: string }) { const stored = useStoredVod(name); @@ -223,7 +237,7 @@ function VodSessionView({ getFrame: (event: ScanEvent) => (() => Promise) | undefined; onUpload: (built: SessionInfo["built"][number]) => void; status: React.ReactNode; - telemetry: ScanTelemetry | null; + telemetry: React.ReactNode; }) { const [, setParams] = useSearchParamsTyped(scannerSearchParams); const clips = useClips(); @@ -317,7 +331,7 @@ function VodSessionView({ )} > {status} - {telemetry ? : null} + {telemetry} ); } diff --git a/app/features/scanner/components/live-session.ts b/app/features/scanner/components/live-session.ts index 6c77af7e4..1202e22a8 100644 --- a/app/features/scanner/components/live-session.ts +++ b/app/features/scanner/components/live-session.ts @@ -33,7 +33,7 @@ import { import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index"; import { PLAYER_STATUS_EVENT_TYPE } from "../core/detectors/objective/player-status"; import { SCOREBOARD_EVENT_TYPES } from "../core/detectors/registry"; -import type { DetectedEvent, GateResult } from "../core/detectors/types"; +import type { DetectedEvent } from "../core/detectors/types"; import type { ScannerMatch } from "../core/scanner-match"; import { TimelineBuilder } from "../core/timeline/index"; import { @@ -107,10 +107,6 @@ export interface LiveSnapshot { /** what the clip encoder is getting from that track; null while clips are off */ audioSignal: AudioSignal | null; clips: ClipsState; - /** highest gate score on the latest frame (debug) */ - gateScore: number | null; - /** a detector fired on the latest frame */ - detecting: boolean; } const IDLE: LiveSnapshot = { @@ -122,8 +118,6 @@ const IDLE: LiveSnapshot = { audioError: null, audioSignal: null, clips: "off", - gateScore: null, - detecting: false, }; let snapshot = IDLE; @@ -140,7 +134,6 @@ let releaseCaptureLock: (() => void) | null = null; let unsubscribeFeed: (() => void) | null = null; let timeline = new TimelineBuilder(); const storedIds = new WeakMap(); -const gates = new Map(); // the open match is known to be a non-SZ mode, so counter reads are // misreads of another mode's overlay and are not collected at all let objectiveBlocked = false; @@ -201,7 +194,6 @@ export async function startCapture(): Promise { } set({ ...IDLE, status: "starting" }); objectiveBlocked = false; - gates.clear(); cuts.length = 0; ownClipIds.clear(); timeline = new TimelineBuilder(); @@ -379,14 +371,6 @@ function onWorkerError(message: string): void { function onResult( result: Parameters[0]>[0], ): void { - // one result arrives per detector per frame; status reflects whether any fired - gates.set(result.detector, result.gate); - const scores = [...gates.values()]; - const detecting = scores.some((g) => g.pass); - set({ - gateScore: Math.max(...scores.map((g) => g.score)), - detecting, - }); if (!result.gate.pass) return; for (const event of result.events as DetectedEvent[]) { if ( diff --git a/app/features/scanner/components/vod-scan.ts b/app/features/scanner/components/vod-scan.ts index 80283e170..9a0f16172 100644 --- a/app/features/scanner/components/vod-scan.ts +++ b/app/features/scanner/components/vod-scan.ts @@ -60,6 +60,11 @@ const SEEK_CALM_STRIDE_S = 2.5; const MAX_VOD_CLIPS = 20; const UI_UPDATE_INTERVAL_MS = 250; +/** + * Each publish re-renders every match card (expanded ones draw whole charts), + * so found events reach the view at most this often, not per event. + */ +const EVENTS_PUBLISH_INTERVAL_MS = 1000; export type VodScanStatus = "idle" | "scanning" | "done" | "error"; @@ -78,28 +83,37 @@ export interface VodScanSnapshot { name: string | null; status: VodScanStatus; error: string | null; - progress: VodScanProgress | null; /** what the scan found, chronological; reloaded from the store once saved */ events: ScanEvent[]; clipsWork: ClipsWork | null; - telemetry: ScanTelemetry | 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. */ +export interface VodScanProgressSnapshot { + progress: VodScanProgress | null; + telemetry: ScanTelemetry | null; +} + const IDLE: VodScanSnapshot = { name: null, status: "idle", error: null, - progress: null, events: [], clipsWork: null, - telemetry: null, uploading: false, }; +const IDLE_PROGRESS: VodScanProgressSnapshot = { + progress: null, + telemetry: null, +}; + let snapshot = IDLE; const listeners = new Set<() => void>(); +let progressSnapshot = IDLE_PROGRESS; +const progressListeners = new Set<() => void>(); let previewCanvas: HTMLCanvasElement | null = null; /** lossless PNGs of the frames the detectors analyzed this scan, until saved */ let frames = new WeakMap(); @@ -130,6 +144,24 @@ function set(patch: Partial): void { for (const listener of listeners) listener(); } +export function useVodScanProgress(): VodScanProgressSnapshot { + return useSyncExternalStore( + subscribeProgress, + () => progressSnapshot, + () => IDLE_PROGRESS, + ); +} + +function subscribeProgress(listener: () => void): () => void { + progressListeners.add(listener); + return () => progressListeners.delete(listener); +} + +function setProgress(patch: Partial): void { + progressSnapshot = { ...progressSnapshot, ...patch }; + for (const listener of progressListeners) listener(); +} + /** The view showing the scan hands over its canvas for the frame preview. */ export function setVodPreviewCanvas(canvas: HTMLCanvasElement | null): void { previewCanvas = canvas; @@ -188,6 +220,9 @@ export async function startVodScan( const update = (patch: Partial) => { if (own === generation) set(patch); }; + const updateProgress = (patch: Partial) => { + if (own === generation) setProgress(patch); + }; const preview = (frame: ImageBitmap | VideoFrame) => { if (own === generation) drawPreview(frame); }; @@ -196,12 +231,19 @@ export async function startVodScan( name: file.name, status: "scanning", }); + setProgress(IDLE_PROGRESS); const timeline = new TimelineBuilder(); let events: ScanEvent[] = []; let clients: AnalyzerClient[] = []; const thumbnailWork: Promise[] = []; - const publish = () => update({ events }); + let publishTimer: ReturnType | null = null; + const publish = () => { + publishTimer ??= setTimeout(() => { + publishTimer = null; + update({ events }); + }, EVENTS_PUBLISH_INTERVAL_MS); + }; try { // seek fallback: latest per-frame done info + the waiter for the next one @@ -295,7 +337,7 @@ export async function startVodScan( (c) => Math.min(c.t, c.tEnd) - c.tStart, ); const elapsed = (now - started) / 1000; - update({ + updateProgress({ progress: { t: covered, duration, @@ -337,7 +379,7 @@ export async function startVodScan( ); abortChunks = null; if (abort.aborted) return; - update({ telemetry: mergedTelemetry() }); + updateProgress({ telemetry: mergedTelemetry() }); await finalize(duration); return; } @@ -367,7 +409,7 @@ export async function startVodScan( // frame to the worker detaches it preview(frame); const elapsed = (now - started) / 1000; - update({ + updateProgress({ progress: { t, duration: vod.duration, @@ -392,13 +434,15 @@ export async function startVodScan( if (!abort.aborted) update({ status: "error", error: describeError(error) }); } finally { + if (publishTimer !== null) clearTimeout(publishTimer); for (const client of clients) client.dispose(); } async function finalize(duration: number): Promise { await Promise.all(thumbnailWork); events = withoutInvalidObjectives(events); - update({ events, progress: { t: duration, duration, rate: 0 } }); + update({ events }); + updateProgress({ progress: { t: duration, duration, rate: 0 } }); await saveVod( { name: file.name,