diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-26 20:44:07 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-26 20:44:07 +0200 |
| commit | 6b1f38a9a31a0eedaa7abea70f232960469351d5 (patch) | |
| tree | 4050d69056c1d9f14075b784535e2b33719f477b /apps/web/src/diagnostics | |
| parent | 56ca09692a308b22455420d11376ff8d41244c47 (diff) | |
| parent | 0b7597b31fa956c7f3d4838923b545ba5e341422 (diff) | |
| download | opencut-6b1f38a9a31a0eedaa7abea70f232960469351d5.tar.gz | |
refactor: split animation helpers by domain
Diffstat (limited to 'apps/web/src/diagnostics')
| -rw-r--r-- | apps/web/src/diagnostics/render-perf.ts | 182 |
1 files changed, 182 insertions, 0 deletions
diff --git a/apps/web/src/diagnostics/render-perf.ts b/apps/web/src/diagnostics/render-perf.ts new file mode 100644 index 00000000..7b0b7fd9 --- /dev/null +++ b/apps/web/src/diagnostics/render-perf.ts @@ -0,0 +1,182 @@ +/** + * Lightweight rolling perf instrumentation for the render pipeline. + * + * Toggle at runtime from the devtools console: + * window.__renderPerf = true + * + * Every FLUSH_EVERY frames the aggregator dumps: + * - per-span timing summary (count / mean / p50 / p95 / max, in ms) + * - per-counter totals (uploads, canvas allocations by kind, etc.) + * + * Zero overhead when disabled: `isRenderPerfEnabled()` short-circuits before + * any recording happens, so call sites only pay for a global read. + */ + +type SpanSample = number; + +type SpanStats = { + samples: SpanSample[]; +}; + +type CounterStats = { + total: number; + frames: number; +}; + +const FLUSH_EVERY = 60; + +const spans = new Map<string, SpanStats>(); +const counters = new Map<string, CounterStats>(); +const pendingCountersThisFrame = new Map<string, number>(); + +let framesSinceFlush = 0; + +declare global { + interface Window { + __renderPerf?: boolean; + } +} + +export function isRenderPerfEnabled(): boolean { + return typeof window !== "undefined" && window.__renderPerf === true; +} + +export function recordSpan({ + name, + durationMs, +}: { + name: string; + durationMs: number; +}): void { + if (!isRenderPerfEnabled()) return; + let stats = spans.get(name); + if (!stats) { + stats = { samples: [] }; + spans.set(name, stats); + } + stats.samples.push(durationMs); +} + +export async function measureSpanAsync<T>({ + name, + fn, +}: { + name: string; + fn: () => Promise<T>; +}): Promise<T> { + if (!isRenderPerfEnabled()) return fn(); + const start = performance.now(); + try { + return await fn(); + } finally { + recordSpan({ name, durationMs: performance.now() - start }); + } +} + +export function measureSpanSync<T>({ + name, + fn, +}: { + name: string; + fn: () => T; +}): T { + if (!isRenderPerfEnabled()) return fn(); + const start = performance.now(); + try { + return fn(); + } finally { + recordSpan({ name, durationMs: performance.now() - start }); + } +} + +export function incrementCounter({ + name, + by = 1, +}: { + name: string; + by?: number; +}): void { + if (!isRenderPerfEnabled()) return; + pendingCountersThisFrame.set( + name, + (pendingCountersThisFrame.get(name) ?? 0) + by, + ); +} + +/** + * Pulls sub-span timings recorded inside the wasm `renderFrame` call and + * feeds them into the aggregator as ordinary spans. + */ +export function recordWasmFrameProfile( + entries: Array<{ name: string; durationMs: number }>, +): void { + if (!isRenderPerfEnabled()) return; + for (const entry of entries) { + recordSpan({ name: entry.name, durationMs: entry.durationMs }); + } +} + +/** + * Called once per frame by the top of the render pipeline. Rolls the + * pending-frame counters into the aggregate and triggers a flush on cadence. + */ +export function onRenderPerfFrameComplete(): void { + if (!isRenderPerfEnabled()) return; + for (const [name, count] of pendingCountersThisFrame) { + let stats = counters.get(name); + if (!stats) { + stats = { total: 0, frames: 0 }; + counters.set(name, stats); + } + stats.total += count; + stats.frames += 1; + } + pendingCountersThisFrame.clear(); + + framesSinceFlush += 1; + if (framesSinceFlush >= FLUSH_EVERY) { + flush(); + } +} + +function flush(): void { + const spanRows: Array<Record<string, number | string>> = []; + for (const [name, stats] of spans) { + if (stats.samples.length === 0) continue; + const sorted = [...stats.samples].sort((a, b) => a - b); + const p = (q: number) => + sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * q))]; + const sum = sorted.reduce((acc, v) => acc + v, 0); + spanRows.push({ + span: name, + count: sorted.length, + meanMs: +(sum / sorted.length).toFixed(2), + p50Ms: +p(0.5).toFixed(2), + p95Ms: +p(0.95).toFixed(2), + maxMs: +sorted[sorted.length - 1].toFixed(2), + }); + } + spanRows.sort((a, b) => Number(b.meanMs) - Number(a.meanMs)); + + const counterRows: Array<Record<string, number | string>> = []; + for (const [name, stats] of counters) { + counterRows.push({ + counter: name, + perFrame: +(stats.total / Math.max(1, stats.frames)).toFixed(2), + total: stats.total, + frames: stats.frames, + }); + } + counterRows.sort((a, b) => Number(b.perFrame) - Number(a.perFrame)); + + console.groupCollapsed( + `[render-perf] summary over ${framesSinceFlush} frames`, + ); + if (spanRows.length > 0) console.table(spanRows); + if (counterRows.length > 0) console.table(counterRows); + console.groupEnd(); + + spans.clear(); + counters.clear(); + framesSinceFlush = 0; +} |
