Optimize React renders

This commit is contained in:
Kalle
2026-09-26 13:08:32 +03:00
parent 7792c64b01
commit f0d4b3400a
4 changed files with 100 additions and 43 deletions

View File

@@ -40,6 +40,13 @@ import { useDebug } from "./use-debug";
const NO_KEYS: ReadonlySet<React.Key> = 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<readonly ScanEvent[], BuiltMatch<ScanEvent>[]>();
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<ScannerClip | null>(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<ScanEvent>[] {
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";

View File

@@ -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 }) {
</div>
) : scanning ? (
<div className={styles.scanning}>
<div className={styles.progressRow}>
<StatusPill>Scanning</StatusPill>
<progress
className={styles.progress}
value={scan.progress?.t ?? 0}
max={scan.progress?.duration ?? 1}
/>
<span className={styles.progressText}>
{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…"}
</span>
</div>
<ScanProgressRow />
<div className={styles.previewRow}>
<canvas ref={setVodPreviewCanvas} className={styles.preview} />
<div className={styles.previewNotes}>
@@ -133,11 +122,36 @@ function ScanVodView({ name }: { name: string }) {
</div>
)
}
telemetry={debug && telemetryOn ? scan.telemetry : null}
telemetry={debug && telemetryOn ? <ScanTelemetryPanel /> : null}
/>
);
}
function ScanProgressRow() {
const { progress } = useVodScanProgress();
return (
<div className={styles.progressRow}>
<StatusPill>Scanning</StatusPill>
<progress
className={styles.progress}
value={progress?.t ?? 0}
max={progress?.duration ?? 1}
/>
<span className={styles.progressText}>
{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…"}
</span>
</div>
);
}
function ScanTelemetryPanel() {
const { telemetry } = useVodScanProgress();
return telemetry ? <TelemetryPanel telemetry={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<Blob | undefined>) | 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 ? <TelemetryPanel telemetry={telemetry} /> : null}
{telemetry}
</SessionView>
);
}

View File

@@ -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<DetectedEvent, number>();
const gates = new Map<string, GateResult>();
// 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<void> {
}
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<ConstructorParameters<typeof AnalyzerClient>[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<FixtureData>[]) {
if (

View File

@@ -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<ScanEvent, Blob>();
@@ -130,6 +144,24 @@ function set(patch: Partial<VodScanSnapshot>): 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<VodScanProgressSnapshot>): 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<VodScanSnapshot>) => {
if (own === generation) set(patch);
};
const updateProgress = (patch: Partial<VodScanProgressSnapshot>) => {
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<void>[] = [];
const publish = () => update({ events });
let publishTimer: ReturnType<typeof setTimeout> | 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<void> {
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,