mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-29 06:43:17 -05:00
Optimize React renders
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user