diff --git a/packages/app/cypress/component/chart-buttons.cy.tsx b/packages/app/cypress/component/chart-buttons.cy.tsx index 579f1577..8915f4e9 100644 --- a/packages/app/cypress/component/chart-buttons.cy.tsx +++ b/packages/app/cypress/component/chart-buttons.cy.tsx @@ -49,6 +49,44 @@ describe('ChartButtons', () => { }); }); + describe('with MP4 export', () => { + it('shows MP4 option in the export popover and triggers the callback', () => { + const onExportMp4 = cy.stub().as('mp4Export'); + const onExportCsv = cy.stub().as('csvExport'); + cy.mount( +
+
Chart content
+ +
, + ); + cy.get('[data-testid="export-button"]').click(); + cy.get('[data-testid="export-png-button"]').should('be.visible'); + cy.get('[data-testid="export-csv-button"]').should('be.visible'); + cy.get('[data-testid="export-mp4-button"]').should('be.visible').click(); + cy.get('@mp4Export').should('have.been.calledOnce'); + cy.get('@csvExport').should('not.have.been.called'); + }); + + it('shows the popover when only MP4 export is provided (no CSV)', () => { + const onExportMp4 = cy.stub().as('mp4Export'); + cy.mount( +
+
Chart content
+ +
, + ); + cy.get('[data-testid="export-button"]').click(); + cy.get('[data-testid="export-csv-button"]').should('not.exist'); + cy.get('[data-testid="export-mp4-button"]').click(); + cy.get('@mp4Export').should('have.been.calledOnce'); + }); + }); + describe('hideZoomReset', () => { it('hides zoom reset button when hideZoomReset is true', () => { cy.mount( diff --git a/packages/app/cypress/e2e/inference-replay.cy.ts b/packages/app/cypress/e2e/inference-replay.cy.ts new file mode 100644 index 00000000..69e31c4d --- /dev/null +++ b/packages/app/cypress/e2e/inference-replay.cy.ts @@ -0,0 +1,137 @@ +const openReplayDialog = () => { + cy.get('[data-testid="chart-figure"]') + .first() + .within(() => { + cy.get('[data-testid="export-button"]').click(); + }); + cy.get('[data-testid="export-mp4-button"]').first().click(); +}; + +describe('Inference Replay', () => { + before(() => { + cy.window().then((win) => { + win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now())); + }); + cy.visit('/inference'); + cy.get('[data-testid="inference-chart-display"]').should('exist'); + }); + + it('exposes MP4 export in the chart export menu', () => { + cy.get('[data-testid="chart-figure"]') + .first() + .within(() => { + cy.get('[data-testid="export-button"]').click(); + }); + cy.get('[data-testid="export-mp4-button"]').should('be.visible'); + }); + + it('opens the replay preview modal from the MP4 menu item', () => { + openReplayDialog(); + // Assert the dialog itself is visible. ChartDisplay now opens the launcher + // via an imperative ref; the optional-chain `?.open()` would silently + // no-op if the ref ever failed to attach, so this guards against that. + cy.get('[data-testid="replay-dialog-chart-0"]').should('be.visible'); + cy.get('[data-testid="replay-panel-chart-0"]').should('exist'); + cy.get('[data-testid="replay-panel-chart-0"]').then(($panel) => { + const text = $panel.text(); + const hasControls = $panel.find('[data-testid="replay-play-pause"]').length > 0; + const hasMessage = /Loading benchmark history|Not enough history/u.test(text) || hasControls; + expect(hasMessage).to.equal(true); + }); + }); + + it('exposes scrubber + play/pause + speed controls when history is available', () => { + // Wait for history to resolve into either the controls UI or the empty-state message. + cy.get('[data-testid="replay-panel-chart-0"]', { timeout: 15_000 }).should(($panel) => { + const hasControls = $panel.find('[data-testid="replay-play-pause"]').length > 0; + const hasEmpty = /Not enough history/u.test($panel.text()); + expect(hasControls || hasEmpty).to.equal(true); + }); + + cy.get('[data-testid="replay-panel-chart-0"]').then(($panel) => { + if ($panel.find('[data-testid="replay-play-pause"]').length === 0) { + cy.log('Replay history fixture has < 2 dates; skipping interactive checks'); + return; + } + cy.get('[data-testid="replay-scrubber"]').should('exist'); + // The speed trigger is always present; individual SelectItems are only + // mounted in the Radix portal while the dropdown is open. + cy.get('[data-testid="replay-speed-select"]').should('exist'); + cy.get('[data-testid="replay-export-mp4"]').should('exist'); + + // Play, then pause, and confirm the button toggles label. + cy.get('[data-testid="replay-play-pause"]').click().should('contain.text', 'Pause'); + cy.get('[data-testid="replay-play-pause"]').click().should('contain.text', 'Play'); + }); + }); + + it('advances the date overlay and scrubber when Play is pressed', () => { + cy.get('body').then(($body) => { + if ($body.find('[data-testid="replay-play-pause"]').length === 0) { + cy.log('Replay history fixture has < 2 dates; skipping animation check'); + return; + } + cy.get('[data-testid="replay-scrubber"]') + .invoke('val') + .then((startVal) => { + cy.get('[data-testid="replay-date-overlay"]') + .invoke('text') + .then((startDate) => { + cy.get('[data-testid="replay-play-pause"]').click(); + cy.wait(800); + cy.get('[data-testid="replay-play-pause"]').click(); + cy.get('[data-testid="replay-scrubber"]') + .invoke('val') + .should((endVal) => { + expect(Number(endVal)).to.be.greaterThan(Number(startVal)); + }); + cy.get('[data-testid="replay-date-overlay"]') + .invoke('text') + .should((endDate) => { + expect(endDate).not.to.equal(startDate); + }); + }); + }); + }); + }); + + it('re-renders the replay frame when a parent-chart toggle changes', () => { + cy.get('body').then(($body) => { + if ($body.find('[data-testid="replay-panel-chart-0"]').length === 0) return; + // Capture the SVG path data for the first roofline as a stable signature. + cy.get('[data-testid="replay-panel-chart-0"] svg path.roofline-path') + .first() + .invoke('attr', 'd') + .then((beforeD) => { + // Toggle the log-scale setting in the underlying inference context — + // the replay panel shares state with the parent chart, so the chart + // re-renders without us touching the replay UI. + cy.window().then((win) => { + const url = new URL(win.location.href); + const cur = url.searchParams.get('i_log') === '1'; + url.searchParams.set('i_log', cur ? '0' : '1'); + win.history.replaceState(null, '', url.toString()); + // Dispatch a popstate so InferenceContext picks up the change. + win.dispatchEvent(new win.PopStateEvent('popstate')); + }); + cy.wait(400); + cy.get('[data-testid="replay-panel-chart-0"] svg path.roofline-path') + .first() + .invoke('attr', 'd') + .should((afterD) => { + expect(afterD).not.to.equal(beforeD); + }); + }); + }); + }); + + it('closes the modal', () => { + cy.get('body').then(($body) => { + if ($body.find('[data-testid="replay-panel-chart-0"]').length === 0) return; + // Radix Dialog closes on Escape — more robust than picking the X by DOM + // order now that the panel contains its own buttons (Play, Reset, …). + cy.get('body').type('{esc}'); + cy.get('[data-testid="replay-panel-chart-0"]').should('not.exist'); + }); + }); +}); diff --git a/packages/app/cypress/support/mock-data.ts b/packages/app/cypress/support/mock-data.ts index 10d27f1e..0defa033 100644 --- a/packages/app/cypress/support/mock-data.ts +++ b/packages/app/cypress/support/mock-data.ts @@ -82,7 +82,7 @@ export function createMockHardwareConfig(): HardwareConfig { export function createMockChartDefinition(overrides?: Partial): ChartDefinition { return { - chartType: 'scatter', + chartType: 'e2e', heading: 'End-to-End Latency vs Throughput', x: 'conc' as keyof AggDataEntry, x_label: 'Concurrency', diff --git a/packages/app/package.json b/packages/app/package.json index 3d78b10b..1e5e4147 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -59,6 +59,7 @@ "gray-matter": "^4.0.3", "iwanthue": "^2.0.0", "lucide-react": "^1.14.0", + "mp4-muxer": "^5.2.2", "next": "^16.2.6", "next-mdx-remote": "^6.0.0", "next-themes": "^0.4.6", diff --git a/packages/app/src/components/inference/replay/ReplayLauncher.tsx b/packages/app/src/components/inference/replay/ReplayLauncher.tsx new file mode 100644 index 00000000..99d0bacd --- /dev/null +++ b/packages/app/src/components/inference/replay/ReplayLauncher.tsx @@ -0,0 +1,60 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import { forwardRef, useImperativeHandle, useState } from 'react'; + +import type { ChartDefinition } from '@/components/inference/types'; +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; +import { Skeleton } from '@/components/ui/skeleton'; + +// Keep this in sync with REPLAY_HEIGHT + padding/header/controls in ReplayPanel +// so the dialog doesn't resize as the panel transitions through its loading states. +const REPLAY_PANEL_MIN_HEIGHT = 620; + +const ReplayPanel = dynamic(() => import('./ReplayPanel'), { + ssr: false, + loading: () => , +}); + +interface ReplayLauncherProps { + parentChartId: string; + chartDefinition: ChartDefinition; + yLabel: string; + xLabel: string; +} + +export interface ReplayLauncherHandle { + open: () => void; +} + +/** + * Owns its own open state so callers only need a ref + .open() call instead of + * a controlled boolean per chart instance. The dialog mounts the panel lazily, + * keeping mp4-muxer and html-to-image out of the main inference bundle. + */ +const ReplayLauncher = forwardRef( + function ReplayLauncher({ parentChartId, chartDefinition, yLabel, xLabel }, ref) { + const [open, setOpen] = useState(false); + useImperativeHandle(ref, () => ({ open: () => setOpen(true) }), []); + return ( + + + Replay over time + {open && ( + + )} + + + ); + }, +); + +export default ReplayLauncher; diff --git a/packages/app/src/components/inference/replay/ReplayPanel.tsx b/packages/app/src/components/inference/replay/ReplayPanel.tsx new file mode 100644 index 00000000..6d3f5692 --- /dev/null +++ b/packages/app/src/components/inference/replay/ReplayPanel.tsx @@ -0,0 +1,617 @@ +'use client'; + +import { Pause, Play, RotateCcw, Video } from 'lucide-react'; +import { flushSync } from 'react-dom'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { sequenceToIslOsl } from '@semianalysisai/inferencex-constants'; + +import { useInference } from '@/components/inference/InferenceContext'; +import ScatterGraph from '@/components/inference/ui/ScatterGraph'; +import type { ChartDefinition } from '@/components/inference/types'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useBenchmarkHistory } from '@/hooks/api/use-benchmark-history'; +import { track } from '@/lib/analytics'; +import { cn } from '@/lib/utils'; + +import { buildReplayTimeline } from './buildReplayTimeline'; +import type { Mp4ExportError, Mp4ExportStage } from './exportMp4'; +import { buildFrameData, dateAtFraction, shouldCommitFraction, spanMs } from './replayFrameData'; +import { useReducedMotion } from './useReducedMotion'; + +type Mp4ExportGuard = (value: unknown) => value is Mp4ExportError; + +// Lowercase pipeline tokens like "mux"/"flush" are jargon in a user-facing +// banner. The raw stage still flows through telemetry — only the user copy +// is humanized. +const STAGE_LABELS: Partial> = { + render: 'while rendering frames', + encode: 'while encoding video', + flush: 'while finalizing video', + mux: 'while finalizing video', +}; + +interface ReplayPanelProps { + parentChartId: string; + chartDefinition: ChartDefinition; + yLabel: string; + xLabel: string; +} + +const SPEED_OPTIONS: readonly number[] = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; +const REPLAY_BODY_MIN_HEIGHT = 480; + +/** + * Replay panel that drives the actual `` with interpolated frame + * data per tick. React re-renders every frame; ScatterGraph's `transitionDuration` + * is forced to 0 so positions snap to the interpolation instead of being + * smoothed by D3's tween. This trades raw render throughput for full parity + * with the regular chart — every toggle and feature the scatter chart respects + * automatically applies to replay because it IS the scatter chart. + */ +export default function ReplayPanel({ + parentChartId, + chartDefinition, + yLabel, + xLabel, +}: ReplayPanelProps) { + const inference = useInference(); + const { selectedModel, selectedSequence } = inference; + + const { isl = 0, osl = 0 } = sequenceToIslOsl(selectedSequence) ?? {}; + const history = useBenchmarkHistory(selectedModel, isl, osl); + + const effectiveX = + chartDefinition.chartType === 'e2e' + ? inference.selectedE2eXAxisMetric + : inference.selectedXAxisMetric; + + const timeline = useMemo(() => { + if (!history.data) return null; + return buildReplayTimeline( + history.data, + chartDefinition, + inference.selectedYAxisMetric, + effectiveX ?? null, + inference.selectedPrecisions, + ); + }, [ + history.data, + chartDefinition, + inference.selectedYAxisMetric, + effectiveX, + inference.selectedPrecisions, + ]); + + // Track the SVG's position inside our relative wrapper so the date overlay + // can anchor its bottom-right to the chart plot's top-right (the wrapper + // also contains the legend, so we can't anchor to the wrapper edge). + // Callback ref — fires when the wrapper element mounts/unmounts, including + // after the panel transitions out of the loading state. A useEffect with + // [] deps would have run before the wrapper existed and never re-fired. + const [svgOffset, setSvgOffset] = useState<{ right: number; top: number } | null>(null); + const observersRef = useRef<{ size: ResizeObserver; mutation: MutationObserver } | null>(null); + const setChartWrapperEl = useCallback((wrapper: HTMLDivElement | null) => { + if (observersRef.current) { + observersRef.current.size.disconnect(); + observersRef.current.mutation.disconnect(); + observersRef.current = null; + } + if (!wrapper) { + setSvgOffset(null); + return; + } + let svgEl: SVGSVGElement | null = null; + const measure = () => { + const svg = wrapper.querySelector('svg'); + if (!svg) return; + const wRect = wrapper.getBoundingClientRect(); + const sRect = svg.getBoundingClientRect(); + // When the legend sits to the right of the SVG, anchor the date's right + // edge to the legend's left edge (with a small gap) so wide dates like + // "2026-05-13" can't bleed into the legend column. Fall back to the + // SVG's right edge when no legend column is present (mobile/stacked). + // The legend container is positioned over the right edge of the SVG, so + // its bounding rect overlaps the SVG horizontally — anchor the date's + // right edge to the legend's left edge whenever it's present rather + // than checking for non-overlap. + const legend = wrapper.querySelector('[data-testid="chart-legend"]'); + const legendRect = legend?.getBoundingClientRect(); + const rightAnchor = legendRect + ? wRect.right - legendRect.left + 12 + : wRect.right - sRect.right + 10; + setSvgOffset((prev) => { + const next = { + right: Math.max(0, rightAnchor), + top: sRect.top - wRect.top + 24, + }; + if (prev && prev.right === next.right && prev.top === next.top) return prev; + return next; + }); + if (svgEl !== svg) { + sizeRO.observe(svg); + svgEl = svg; + } + }; + const sizeRO = new ResizeObserver(measure); + sizeRO.observe(wrapper); + const mo = new MutationObserver(measure); + mo.observe(wrapper, { childList: true, subtree: true }); + observersRef.current = { size: sizeRO, mutation: mo }; + measure(); + }, []); + useEffect( + () => () => { + observersRef.current?.size.disconnect(); + observersRef.current?.mutation.disconnect(); + observersRef.current = null; + }, + [], + ); + + const panelRef = useRef(null); + + const [fraction, setFraction] = useState(0); + const [playing, setPlaying] = useState(false); + const [speed, setSpeed] = useState(1); + const [isExporting, setIsExporting] = useState(false); + const [exportProgress, setExportProgress] = useState(null); + const [exportError, setExportError] = useState(null); + const abortRef = useRef(null); + + const prefersReducedMotion = useReducedMotion(); + + // Pre-flight feature detection so the Export button is disabled with a clear + // reason on browsers that lack WebCodecs (Firefox today, older Safari). + const hasWebCodecs = useMemo(() => typeof VideoEncoder !== 'undefined', []); + const unavailableReportedRef = useRef(false); + useEffect(() => { + if (!hasWebCodecs && !unavailableReportedRef.current) { + unavailableReportedRef.current = true; + track('inference_replay_export_unavailable', { + userAgent: typeof navigator === 'undefined' ? 'unknown' : navigator.userAgent.slice(0, 200), + }); + } + }, [hasWebCodecs]); + + const speedRef = useRef(speed); + speedRef.current = speed; + const playingRef = useRef(playing); + playingRef.current = playing; + + // Accumulator decoupled from React state so the rAF loop doesn't trigger a + // commit on every tick. Snapshot the previous ref value *before* mutating + // so the predicate compares like-with-like — comparing against the + // React-committed value lags by a frame and would no-op a backward scrub + // that crosses a quantum boundary. + const fractionRef = useRef(0); + const commitFraction = useCallback((next: number, opts?: { force?: boolean }) => { + const clamped = next < 0 ? 0 : Math.min(1, next); + const prev = fractionRef.current; + fractionRef.current = clamped; + const force = opts?.force ?? false; + if (force || shouldCommitFraction(prev, clamped)) setFraction(clamped); + }, []); + + useEffect(() => { + if (!playing || !timeline) return; + // Reduced motion: advance one observed step per ~1.2s without per-frame + // interpolation, so users get a slideshow rather than continuous motion. + if (prefersReducedMotion) { + const stepMs = 1200 / Math.max(0.1, speedRef.current); + const n = timeline.dates.length; + const intervalId = window.setInterval(() => { + if (!playingRef.current) return; + const cur = Math.round(fractionRef.current * (n - 1)); + const nextStep = Math.min(n - 1, cur + 1); + const next = nextStep / (n - 1); + commitFraction(next, { force: true }); + if (nextStep === n - 1) setPlaying(false); + }, stepMs); + return () => window.clearInterval(intervalId); + } + let rafId = 0; + let last = performance.now(); + const totalMs = spanMs(timeline.dates.length); + const step = (now: number) => { + if (!playingRef.current) return; + const dt = now - last; + last = now; + const next = Math.min(1, fractionRef.current + (dt / totalMs) * speedRef.current); + commitFraction(next); + if (next >= 1) setPlaying(false); + rafId = requestAnimationFrame(step); + }; + // When the tab is hidden the browser throttles rAF to ~1Hz, so resuming + // without rebasing produces a multi-second `dt` that jumps the playhead. + // Cancel on hide, rebase + resume on show. + const onVisibility = () => { + if (document.hidden) { + if (rafId !== 0) { + cancelAnimationFrame(rafId); + rafId = 0; + } + return; + } + if (playingRef.current && rafId === 0) { + last = performance.now(); + rafId = requestAnimationFrame(step); + } + }; + document.addEventListener('visibilitychange', onVisibility); + rafId = requestAnimationFrame(step); + return () => { + if (rafId !== 0) cancelAnimationFrame(rafId); + document.removeEventListener('visibilitychange', onVisibility); + }; + }, [playing, timeline, prefersReducedMotion]); + + useEffect(() => { + fractionRef.current = 0; + setFraction(0); + setPlaying(false); + }, [timeline]); + + const frameData = useMemo( + () => (timeline ? buildFrameData(timeline, fraction) : []), + [timeline, fraction], + ); + + const currentDate = useMemo( + () => (timeline ? dateAtFraction(timeline, fraction) : ''), + [timeline, fraction], + ); + + const handlePlayPause = useCallback(() => { + if (playing) { + setPlaying(false); + track('inference_replay_paused', { fraction }); + } else { + if (fractionRef.current >= 1) commitFraction(0, { force: true }); + setPlaying(true); + track('inference_replay_started', { speed }); + } + }, [playing, fraction, speed, commitFraction]); + + const handleScrub = useCallback( + (value: number) => { + commitFraction(value, { force: true }); + setPlaying(false); + track('inference_replay_scrubbed', { fraction: value }); + }, + [commitFraction], + ); + + const handleScrubKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!timeline) return; + const n = timeline.dates.length; + if (n <= 1) return; + const cur = Math.round(fraction * (n - 1)); + let nextStep: number; + switch (e.key) { + case 'ArrowLeft': + case 'ArrowDown': { + nextStep = Math.max(0, cur - 1); + break; + } + case 'ArrowRight': + case 'ArrowUp': { + nextStep = Math.min(n - 1, cur + 1); + break; + } + case 'Home': { + nextStep = 0; + break; + } + case 'End': { + nextStep = n - 1; + break; + } + default: { + return; + } + } + if (nextStep === cur) return; + e.preventDefault(); + handleScrub(nextStep / (n - 1)); + }, + [timeline, fraction, handleScrub], + ); + + const handleSpeedChange = useCallback((v: number) => { + setSpeed(v); + track('inference_replay_speed_changed', { speed: v }); + }, []); + + const handleReset = useCallback(() => { + commitFraction(0, { force: true }); + setPlaying(false); + }, [commitFraction]); + + const handleCancelExport = useCallback(() => { + abortRef.current?.abort(); + }, []); + + const handleExportMp4 = useCallback(async () => { + if (!timeline) return; + setPlaying(false); + setIsExporting(true); + setExportProgress(0); + setExportError(null); + const ac = new AbortController(); + abortRef.current = ac; + const startedAt = performance.now(); + track('inference_replay_export_started', { + model: selectedModel, + chartType: chartDefinition.chartType, + hasWebCodecs, + }); + let stage: Mp4ExportStage = 'init'; + let frameCount = 0; + let lastProgressAt = startedAt; + // Late-bound so the catch can narrow the error after the module loads. + let guard: Mp4ExportGuard | null = null; + try { + const mod = await import('./exportMp4'); + const { exportReplayMp4 } = mod; + guard = mod.isMp4ExportError; + // Export duration is deterministic from timeline length, NOT playback speed + // — the MP4 is an artifact of the dataset, not a recording of the current + // UI session. Capped at 60s. + const durationSec = Math.max(2, Math.min(60, spanMs(timeline.dates.length) / 1000)); + const root = panelRef.current; + if (!root) throw new Error('Replay panel element is not mounted.'); + await exportReplayMp4({ + captureRoot: root, + fileName: `InferenceX_${selectedModel}_${chartDefinition.chartType}_replay`, + durationSec, + signal: ac.signal, + renderFrame: async (t) => { + // flushSync forces React to commit synchronously; two RAFs let the + // browser paint before the capture step reads back the DOM. + flushSync(() => commitFraction(t, { force: true })); + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + }, + onStage: (s) => { + stage = s; + }, + onProgress: (p) => { + lastProgressAt = performance.now(); + frameCount = Math.round(p * durationSec * 30); + setExportProgress(p); + }, + }); + track('inference_replay_export_completed', { + model: selectedModel, + chartType: chartDefinition.chartType, + durationMs: Math.round(performance.now() - startedAt), + }); + } catch (error) { + if (ac.signal.aborted) { + track('inference_replay_export_cancelled', { + model: selectedModel, + chartType: chartDefinition.chartType, + frameCount, + stage, + durationMs: Math.round(performance.now() - startedAt), + }); + return; + } + console.error('MP4 export failed', error); + const message = error instanceof Error ? error.message : 'Export failed.'; + const errorName = error instanceof Error ? error.name : 'unknown'; + let encoderState: VideoEncoder['state'] | 'unknown' = 'unknown'; + let queuedFrames = 0; + if (guard?.(error)) { + stage = error.stage; + encoderState = error.encoderState; + queuedFrames = error.queuedFrames; + } + const elapsedSinceLastProgressMs = Math.round(performance.now() - lastProgressAt); + const stageLabel = STAGE_LABELS[stage]; + setExportError( + hasWebCodecs + ? `${message}${stageLabel ? ` (${stageLabel})` : ''}` + : 'MP4 export needs WebCodecs (Chrome, Edge, or Chromium). Your browser does not support it.', + ); + track('inference_replay_export_failed', { + reason: message.slice(0, 500), + errorName, + userAgent: typeof navigator === 'undefined' ? 'unknown' : navigator.userAgent.slice(0, 200), + hasWebCodecs, + frameCount, + durationMs: Math.round(performance.now() - startedAt), + stage, + encoderState, + queuedFrames, + elapsedSinceLastProgressMs, + }); + } finally { + setIsExporting(false); + setExportProgress(null); + abortRef.current = null; + } + }, [chartDefinition.chartType, parentChartId, selectedModel, timeline, hasWebCodecs]); + + if (history.isLoading || !timeline) { + return ( +
+

Replay over time

+
+

Loading benchmark history…

+
+
+ ); + } + + if (timeline.dates.length < 2) { + return ( +
+

Replay over time

+
+

+ Not enough history yet to replay this chart — at least two distinct benchmark dates are + required. +

+
+
+ ); + } + + return ( +
+
+

Replay over time

+

+ {timeline.dates[0]} → {timeline.dates.at(-1)} • {timeline.dates.length} dates •{' '} + {timeline.configs.length} configs +

+
+ +
+ +
+ {currentDate} +
+
+ +
+ + + handleScrub(Number(e.target.value) / 1000)} + onKeyDown={handleScrubKeyDown} + className="flex-1 min-w-[120px] h-2 cursor-pointer accent-foreground" + aria-label="Replay timeline" + aria-valuetext={currentDate || undefined} + data-testid="replay-scrubber" + /> + + {currentDate} + + + + {isExporting && ( + + )} +
+ {exportError && ( +
+ MP4 export failed: {exportError} + +
+ )} +
+ ); +} diff --git a/packages/app/src/components/inference/replay/__tests__/buildReplayTimeline.test.ts b/packages/app/src/components/inference/replay/__tests__/buildReplayTimeline.test.ts new file mode 100644 index 00000000..f91adae6 --- /dev/null +++ b/packages/app/src/components/inference/replay/__tests__/buildReplayTimeline.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest'; + +import type { BenchmarkRow } from '@/lib/api'; +import type { ChartDefinition } from '@/components/inference/types'; + +import { buildReplayTimeline, computeStepDomain } from '../buildReplayTimeline'; + +const ALL_HW = () => true; + +const interactivityChartDef: ChartDefinition = { + chartType: 'interactivity', + heading: 'vs. Interactivity', + x: 'median_intvty', + x_label: 'Interactivity (tok/s/user)', + y: 'tput_per_gpu', + y_label: 'Token Throughput per GPU', + y_tpPerGpu_title: 'Token Throughput per GPU', +} as unknown as ChartDefinition; + +const baseRow = (overrides: Partial): BenchmarkRow => + ({ + hardware: 'h100', + framework: 'trt', + model: 'DeepSeek-R1-0528', + precision: 'fp4', + spec_method: 'none', + disagg: false, + is_multinode: false, + prefill_tp: 0, + prefill_ep: 0, + prefill_dp_attention: false, + prefill_num_workers: 0, + decode_tp: 8, + decode_ep: 0, + decode_dp_attention: false, + decode_num_workers: 0, + num_prefill_gpu: 0, + num_decode_gpu: 8, + isl: 8192, + osl: 1024, + conc: 32, + image: null, + metrics: { + tput_per_gpu: 1000, + median_intvty: 50, + median_ttft: 0.1, + p99_ttft: 0.2, + }, + date: '2025-01-01', + run_url: null, + ...overrides, + }) as BenchmarkRow; + +describe('buildReplayTimeline', () => { + it('returns empty timeline for empty input', () => { + const t = buildReplayTimeline([], interactivityChartDef, 'y_tpPerGpu', null, ['fp4']); + expect(t.dates).toEqual([]); + expect(t.configs).toEqual([]); + }); + + it('drops rows whose precision is not selected', () => { + const rows = [baseRow({ precision: 'fp4' }), baseRow({ precision: 'fp8', date: '2025-01-02' })]; + const t = buildReplayTimeline(rows, interactivityChartDef, 'y_tpPerGpu', null, ['fp4']); + expect(t.configs).toHaveLength(1); + expect(t.dates).toEqual(['2025-01-01']); + }); + + it('groups rows by config_id and emits stepValues aligned to dates', () => { + const rows = [ + baseRow({ date: '2025-03-01', metrics: { tput_per_gpu: 3000, median_intvty: 70 } }), + baseRow({ date: '2025-01-01', metrics: { tput_per_gpu: 1000, median_intvty: 50 } }), + baseRow({ date: '2025-02-01', metrics: { tput_per_gpu: 2000, median_intvty: 60 } }), + ]; + const t = buildReplayTimeline(rows, interactivityChartDef, 'y_tpPerGpu', null, ['fp4']); + expect(t.configs).toHaveLength(1); + const series = t.configs[0]; + expect(t.dates).toEqual(['2025-01-01', '2025-02-01', '2025-03-01']); + expect(series.stepValues).toHaveLength(3); + expect(series.stepValues.map((s) => s.visible)).toEqual([true, true, true]); + expect(series.stepValues.map((s) => s.y)).toEqual([1000, 2000, 3000]); + }); + + it('marks pre-appearance steps invisible and applies sticky-last after the final observation', () => { + const rows = [ + baseRow({ date: '2025-01-01', metrics: { tput_per_gpu: 1000, median_intvty: 50 }, conc: 8 }), + baseRow({ date: '2025-03-01', metrics: { tput_per_gpu: 1500, median_intvty: 55 }, conc: 8 }), + baseRow({ date: '2025-02-01', metrics: { tput_per_gpu: 2000, median_intvty: 60 }, conc: 16 }), + ]; + const t = buildReplayTimeline(rows, interactivityChartDef, 'y_tpPerGpu', null, ['fp4']); + expect(t.dates).toEqual(['2025-01-01', '2025-02-01', '2025-03-01']); + const c8 = t.configs.find((c) => c.configId.includes('|8|')); + const c16 = t.configs.find((c) => c.configId.includes('|16|')); + expect(c8?.stepValues.map((s) => s.visible)).toEqual([true, true, true]); + expect(c8?.stepValues[1].y).toBe(1000); // sticky-last between step 0 and step 2 + expect(c16?.stepValues.map((s) => s.visible)).toEqual([false, true, true]); + expect(c16?.stepValues[2].y).toBe(2000); // sticky-last after final observation + }); + + it('computeStepDomain returns a tight bounding box that grows as configs appear', () => { + const rows = [ + baseRow({ date: '2025-01-01', metrics: { tput_per_gpu: 100, median_intvty: 10 }, conc: 8 }), + baseRow({ date: '2025-02-01', metrics: { tput_per_gpu: 200, median_intvty: 20 }, conc: 8 }), + baseRow({ + date: '2025-02-01', + metrics: { tput_per_gpu: 5000, median_intvty: 200 }, + conc: 16, + }), + ]; + const t = buildReplayTimeline(rows, interactivityChartDef, 'y_tpPerGpu', null, ['fp4']); + const d0 = computeStepDomain(t, 0, ALL_HW); + const d1 = computeStepDomain(t, 1, ALL_HW); + // Step 0: only the conc=8 config is visible. safeDomain pads degenerate + // single-point domains, so we just check that the bounds fit a reasonable + // window around the observation. + expect(d0.x[0]).toBeLessThanOrEqual(10); + expect(d0.x[1]).toBeGreaterThanOrEqual(10); + expect(d0.x[1]).toBeLessThan(50); + expect(d0.y[1]).toBeGreaterThanOrEqual(100); + expect(d0.y[1]).toBeLessThan(500); + // Step 1: both configs visible, so the domain stretches to fit the new one. + expect(d1.x[1]).toBeGreaterThanOrEqual(200); + expect(d1.y[1]).toBeGreaterThanOrEqual(5000); + }); + + it('computeStepDomain respects hwFilter and shrinks to selected hardware only', () => { + const rows = [ + // h100 with low values + baseRow({ + hardware: 'h100', + framework: 'trt', + date: '2025-01-01', + metrics: { tput_per_gpu: 100, median_intvty: 10 }, + }), + // mi355x with way smaller values + baseRow({ + hardware: 'mi355x', + framework: 'sglang', + date: '2025-01-01', + metrics: { tput_per_gpu: 50, median_intvty: 5 }, + }), + // big-domain GPU on the same step + baseRow({ + hardware: 'b200', + framework: 'trt', + date: '2025-01-01', + metrics: { tput_per_gpu: 5000, median_intvty: 400 }, + }), + ]; + const t = buildReplayTimeline(rows, interactivityChartDef, 'y_tpPerGpu', null, ['fp4']); + const everything = computeStepDomain(t, 0, ALL_HW); + const mi355xOnly = computeStepDomain(t, 0, (hw) => hw.startsWith('mi355x')); + expect(everything.x[1]).toBeGreaterThanOrEqual(400); + expect(mi355xOnly.x[1]).toBeLessThan(50); // padded around 5 + }); + + it('separates configs that differ in concurrency or tp', () => { + const rows = [ + baseRow({ conc: 32 }), + baseRow({ conc: 64, date: '2025-01-02' }), + baseRow({ + decode_tp: 4, + date: '2025-01-03', + metrics: { tput_per_gpu: 500, median_intvty: 30 }, + }), + ]; + const t = buildReplayTimeline(rows, interactivityChartDef, 'y_tpPerGpu', null, ['fp4']); + expect(t.configs.length).toBeGreaterThanOrEqual(2); + }); + + it('computes a global x/y domain spanning all observations', () => { + const rows = [ + baseRow({ date: '2025-01-01', metrics: { tput_per_gpu: 100, median_intvty: 10 } }), + baseRow({ date: '2025-02-01', metrics: { tput_per_gpu: 5000, median_intvty: 200 } }), + ]; + const t = buildReplayTimeline(rows, interactivityChartDef, 'y_tpPerGpu', null, ['fp4']); + expect(t.domain.x[0]).toBeLessThanOrEqual(10); + expect(t.domain.x[1]).toBeGreaterThanOrEqual(200); + expect(t.domain.y[0]).toBeLessThanOrEqual(100); + expect(t.domain.y[1]).toBeGreaterThanOrEqual(5000); + }); + + it('drops rows with non-positive metric values', () => { + const rows = [ + baseRow({ metrics: { tput_per_gpu: 0, median_intvty: 50 } }), + baseRow({ date: '2025-01-02', metrics: { tput_per_gpu: 1000, median_intvty: 0 } }), + baseRow({ date: '2025-01-03', metrics: { tput_per_gpu: 1000, median_intvty: 50 } }), + ]; + const t = buildReplayTimeline(rows, interactivityChartDef, 'y_tpPerGpu', null, ['fp4']); + expect(t.dates).toEqual(['2025-01-03']); + }); +}); diff --git a/packages/app/src/components/inference/replay/__tests__/exportMp4Errors.test.ts b/packages/app/src/components/inference/replay/__tests__/exportMp4Errors.test.ts new file mode 100644 index 00000000..5eb70e1d --- /dev/null +++ b/packages/app/src/components/inference/replay/__tests__/exportMp4Errors.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; + +import { Mp4ExportError, isMp4ExportError } from '../exportMp4'; + +describe('Mp4ExportError', () => { + it('sets name to "Mp4ExportError" so brand checks survive minification', () => { + const e = new Mp4ExportError('boom', { + stage: 'encode', + encoderState: 'unknown', + queuedFrames: 0, + }); + expect(e.name).toBe('Mp4ExportError'); + }); + + it('round-trips stage, encoderState, and queuedFrames', () => { + const e = new Mp4ExportError('boom', { + stage: 'flush', + encoderState: 'closed', + queuedFrames: 4, + }); + expect(e.stage).toBe('flush'); + expect(e.encoderState).toBe('closed'); + expect(e.queuedFrames).toBe(4); + }); + + it('preserves cause when supplied', () => { + const underlying = new TypeError('out of memory'); + const e = new Mp4ExportError('boom', { + stage: 'render', + encoderState: 'configured', + queuedFrames: 2, + cause: underlying, + }); + expect((e as { cause?: unknown }).cause).toBe(underlying); + }); + + it('inherits Error.message via super(message)', () => { + const e = new Mp4ExportError('boom', { + stage: 'mux', + encoderState: 'unknown', + queuedFrames: 0, + }); + expect(e.message).toBe('boom'); + expect(e).toBeInstanceOf(Error); + }); +}); + +describe('isMp4ExportError', () => { + it('returns true for Mp4ExportError instances', () => { + const e = new Mp4ExportError('boom', { + stage: 'init', + encoderState: 'unknown', + queuedFrames: 0, + }); + expect(isMp4ExportError(e)).toBe(true); + }); + + it('returns true for plain objects with the right name brand (dynamic-import realm safety)', () => { + const sentinel = { name: 'Mp4ExportError', message: 'x', stage: 'encode' }; + expect(isMp4ExportError(sentinel)).toBe(true); + }); + + it('returns false for regular Errors', () => { + expect(isMp4ExportError(new Error('boom'))).toBe(false); + expect(isMp4ExportError(new TypeError('boom'))).toBe(false); + }); + + it('returns false for nullish or non-object inputs', () => { + expect(isMp4ExportError(null)).toBe(false); + expect(isMp4ExportError(undefined)).toBe(false); + expect(isMp4ExportError('Mp4ExportError')).toBe(false); + expect(isMp4ExportError(42)).toBe(false); + }); +}); diff --git a/packages/app/src/components/inference/replay/__tests__/interpolateAtTime.test.ts b/packages/app/src/components/inference/replay/__tests__/interpolateAtTime.test.ts new file mode 100644 index 00000000..19baac1e --- /dev/null +++ b/packages/app/src/components/inference/replay/__tests__/interpolateAtTime.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { interpolateAtStep, type PerStepValue } from '../interpolateAtTime'; + +const v = (visible: boolean, x: number, y: number): PerStepValue => ({ visible, x, y }); + +describe('interpolateAtStep', () => { + it('returns invisible for empty stepValues', () => { + expect(interpolateAtStep([], 0)).toEqual({ visible: false, x: 0, y: 0 }); + }); + + it('returns the exact step when idxFloat lands on an integer', () => { + const steps = [v(true, 100, 50), v(true, 200, 75)]; + expect(interpolateAtStep(steps, 0)).toEqual({ visible: true, x: 100, y: 50 }); + expect(interpolateAtStep(steps, 1)).toEqual({ visible: true, x: 200, y: 75 }); + }); + + it('lerps linearly between two visible steps', () => { + const steps = [v(true, 0, 0), v(true, 100, 100)]; + const r = interpolateAtStep(steps, 0.5); + expect(r).toEqual({ visible: true, x: 50, y: 50 }); + }); + + it('pops in at the destination during an invisible→visible segment', () => { + const steps = [v(false, 0, 0), v(true, 200, 75)]; + const r = interpolateAtStep(steps, 0.25); + expect(r).toEqual({ visible: true, x: 200, y: 75 }); + }); + + it('keeps both endpoints invisible across an invisible→invisible segment', () => { + const steps = [v(false, 0, 0), v(false, 0, 0)]; + expect(interpolateAtStep(steps, 0.5)).toEqual({ visible: false, x: 0, y: 0 }); + }); + + it('clamps idxFloat to the valid range and returns the last step at idxFloat ≥ n-1', () => { + const steps = [v(true, 10, 1), v(true, 20, 2), v(true, 30, 3)]; + expect(interpolateAtStep(steps, 5)).toEqual({ visible: true, x: 30, y: 3 }); + expect(interpolateAtStep(steps, -1)).toEqual({ visible: true, x: 10, y: 1 }); + }); +}); diff --git a/packages/app/src/components/inference/replay/__tests__/replayFrameData.test.ts b/packages/app/src/components/inference/replay/__tests__/replayFrameData.test.ts new file mode 100644 index 00000000..a1327098 --- /dev/null +++ b/packages/app/src/components/inference/replay/__tests__/replayFrameData.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from 'vitest'; + +import type { InferenceData } from '@/components/inference/types'; + +import type { ReplayTimeline } from '../buildReplayTimeline'; +import { + FRACTION_COMMIT_QUANTUM, + buildFrameData, + dateAtFraction, + shouldCommitFraction, + spanMs, + stepFloatAtFraction, +} from '../replayFrameData'; + +const baseTemplate = { + hwKey: 'b200', + precision: 'fp8', + tp: 8, + conc: 64, +} as unknown as InferenceData; + +function makeTimeline(): ReplayTimeline { + return { + dates: ['2025-09-01', '2025-09-02', '2025-09-03'], + configs: [ + { + configId: 'a', + hwKey: 'b200', + precision: 'fp8', + template: baseTemplate, + stepValues: [ + { visible: true, x: 0, y: 100 }, + { visible: true, x: 10, y: 200 }, + { visible: true, x: 20, y: 300 }, + ], + }, + { + configId: 'b', + hwKey: 'h100', + precision: 'fp8', + template: { ...baseTemplate, hwKey: 'h100' } as InferenceData, + // Stays invisible across the first two steps so a true "omits invisible + // configs" assertion is meaningful — `interpolateAtStep` pops a config + // in for the *whole* invisible→visible segment, so we need both + // bracketing steps invisible for the config to actually be skipped. + stepValues: [ + { visible: false, x: 0, y: 0 }, + { visible: false, x: 0, y: 0 }, + { visible: true, x: 15, y: 150 }, + ], + }, + ], + domain: { x: [0, 20], y: [0, 300] }, + }; +} + +describe('stepFloatAtFraction', () => { + it('pins endpoints at fraction 0 and 1', () => { + expect(stepFloatAtFraction(0, 3)).toBe(0); + expect(stepFloatAtFraction(1, 3)).toBe(2); + }); + + it('is monotonically non-decreasing', () => { + let prev = stepFloatAtFraction(0, 5); + for (let i = 1; i <= 100; i++) { + const cur = stepFloatAtFraction(i / 100, 5); + expect(cur).toBeGreaterThanOrEqual(prev); + prev = cur; + } + }); + + it('lands on integer step at segment boundaries', () => { + // 4 dates → segments at fraction 0, 1/3, 2/3, 1 + expect(stepFloatAtFraction(0, 4)).toBe(0); + expect(stepFloatAtFraction(1 / 3, 4)).toBeCloseTo(1, 6); + expect(stepFloatAtFraction(2 / 3, 4)).toBeCloseTo(2, 6); + expect(stepFloatAtFraction(1, 4)).toBe(3); + }); + + it('returns 0 when there is at most one date', () => { + expect(stepFloatAtFraction(0.5, 0)).toBe(0); + expect(stepFloatAtFraction(0.5, 1)).toBe(0); + }); + + it('clamps out-of-range fractions', () => { + expect(stepFloatAtFraction(-1, 3)).toBe(0); + expect(stepFloatAtFraction(2, 3)).toBe(2); + }); +}); + +describe('spanMs', () => { + it('is at least 1500ms even for tiny timelines', () => { + expect(spanMs(0)).toBe(1500); + expect(spanMs(1)).toBe(1500); + }); + + it('scales linearly with date count', () => { + expect(spanMs(10)).toBe(8000); + expect(spanMs(20)).toBe(16000); + }); + + it('caps at 30s for very long histories', () => { + expect(spanMs(95)).toBe(30_000); + expect(spanMs(1000)).toBe(30_000); + }); + + it('respects a minimum of 4500ms once the floor kicks in', () => { + expect(spanMs(5)).toBe(4500); + }); +}); + +describe('dateAtFraction', () => { + it('returns the first date at fraction 0', () => { + const t = makeTimeline(); + expect(dateAtFraction(t, 0)).toBe('2025-09-01'); + }); + + it('returns the last date at fraction 1', () => { + const t = makeTimeline(); + expect(dateAtFraction(t, 1)).toBe('2025-09-03'); + }); + + it('returns the date the playhead is currently within for intermediate fractions', () => { + const t = makeTimeline(); + expect(dateAtFraction(t, 0.5)).toBe('2025-09-02'); + }); + + it('returns empty string for an empty timeline', () => { + const empty: ReplayTimeline = { dates: [], configs: [], domain: { x: [0, 1], y: [0, 1] } }; + expect(dateAtFraction(empty, 0.5)).toBe(''); + }); +}); + +describe('shouldCommitFraction', () => { + const quantumStep = 1 / FRACTION_COMMIT_QUANTUM; + + it('skips when the quantized value is unchanged', () => { + expect(shouldCommitFraction(0.5, 0.5)).toBe(false); + expect(shouldCommitFraction(0.5, 0.5 + quantumStep / 10)).toBe(false); + }); + + it('commits when the quantized value changes by one full quantum', () => { + expect(shouldCommitFraction(0.5, 0.5 + quantumStep)).toBe(true); + expect(shouldCommitFraction(0.5, 0.5 - quantumStep)).toBe(true); + }); + + it('commits across the rounding boundary', () => { + // 0.5004 → round*1000 = 500, 0.5006 → round*1000 = 501 + expect(shouldCommitFraction(0.5004, 0.5006)).toBe(true); + }); +}); + +describe('commitFraction throttle (rAF-loop invariant)', () => { + // Mirrors ReplayPanel.commitFraction: snapshot fractionRef BEFORE mutating + // it, then ask the pure predicate whether to call setFraction. The throttle + // is load-bearing — if the predicate is given the React-committed value + // instead of the ref's previous value, a backward scrub that crosses a + // quantum boundary would silently no-op the commit. + function makeCommitter() { + const fractionRef = { current: 0 }; + const commits: number[] = []; + const setFraction = (v: number) => commits.push(v); + const commit = (next: number, opts?: { force?: boolean }) => { + const clamped = next < 0 ? 0 : Math.min(1, next); + const prev = fractionRef.current; + fractionRef.current = clamped; + const force = opts?.force ?? false; + if (force || shouldCommitFraction(prev, clamped)) setFraction(clamped); + }; + return { fractionRef, commits, commit }; + } + + it('advances fractionRef every tick but commits only when the quantum changes', () => { + const { fractionRef, commits, commit } = makeCommitter(); + // Sub-quantum increments. 0.0001 * 4 = 0.0004 — all round to 0, no commits. + const subQuantum = 1 / (FRACTION_COMMIT_QUANTUM * 10); + for (let i = 1; i <= 4; i++) commit(i * subQuantum); + expect(fractionRef.current).toBeCloseTo(4 * subQuantum); + expect(commits).toHaveLength(0); + // Fifth tick lands on 0.0005 — round(0.5) === 1, crossing the first + // quantum boundary → one commit. + commit(5 * subQuantum); + expect(commits).toHaveLength(1); + expect(commits[0]).toBeCloseTo(5 * subQuantum); + }); + + it('force=true always commits even when the predicate would skip', () => { + const { commits, commit } = makeCommitter(); + commit(0.5, { force: true }); + commit(0.5, { force: true }); + expect(commits).toEqual([0.5, 0.5]); + }); + + it('commits a backward scrub that crosses a quantum boundary', () => { + const { fractionRef, commits, commit } = makeCommitter(); + commit(0.8); // forward, commits + fractionRef.current = 0.8; // simulate the ref already at the committed value + commit(0.6); // backward across many quanta — must commit + expect(commits.at(-1)).toBe(0.6); + }); + + it('clamps to [0, 1]', () => { + const { fractionRef, commit } = makeCommitter(); + commit(-1); + expect(fractionRef.current).toBe(0); + commit(2); + expect(fractionRef.current).toBe(1); + }); +}); + +describe('buildFrameData', () => { + it('emits one InferenceData per visible config at the given fraction', () => { + const t = makeTimeline(); + const out = buildFrameData(t, 0); + // At fraction 0 only config "a" is visible (config "b" pops in at step 1). + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ hwKey: 'b200', x: 0, y: 100 }); + }); + + it('omits invisible configs', () => { + const t = makeTimeline(); + const out = buildFrameData(t, 0); + expect(out.every((d) => d.hwKey !== 'h100')).toBe(true); + }); + + it('lerps positions between step values', () => { + const t = makeTimeline(); + // fraction 0.25 → idxFloat ≈ 0.0625 after cubic ease, mostly at step 0 + const out = buildFrameData(t, 0.25); + const a = out.find((d) => d.hwKey === 'b200'); + expect(a).toBeDefined(); + expect(a!.x).toBeGreaterThan(0); + expect(a!.x).toBeLessThan(10); + }); + + it('preserves template fields (precision, tp, conc, hwKey) on every frame', () => { + const t = makeTimeline(); + const out = buildFrameData(t, 1); + for (const d of out) { + expect(d.precision).toBe('fp8'); + expect(d.tp).toBe(8); + expect(d.conc).toBe(64); + } + }); + + it('returns empty when the timeline has zero configs', () => { + const empty: ReplayTimeline = { + dates: ['2025-09-01'], + configs: [], + domain: { x: [0, 1], y: [0, 1] }, + }; + expect(buildFrameData(empty, 0.5)).toEqual([]); + }); +}); diff --git a/packages/app/src/components/inference/replay/buildReplayTimeline.ts b/packages/app/src/components/inference/replay/buildReplayTimeline.ts new file mode 100644 index 00000000..be076418 --- /dev/null +++ b/packages/app/src/components/inference/replay/buildReplayTimeline.ts @@ -0,0 +1,224 @@ +import type { BenchmarkRow } from '@/lib/api'; +import { rowToAggDataEntry } from '@/lib/benchmark-transform'; +import { createChartDataPoint, getHardwareKey } from '@/lib/chart-utils'; + +import type { + AggDataEntry, + ChartDefinition, + InferenceData, + YAxisMetricKey, +} from '@/components/inference/types'; + +import type { PerStepValue } from './interpolateAtTime'; + +export interface ReplayConfigSeries { + configId: string; + hwKey: string; + precision: string; + template: InferenceData; + // One entry per `dates[i]`; sticky-last carries the last observation forward. + stepValues: PerStepValue[]; +} + +export interface ReplayTimeline { + dates: string[]; + configs: ReplayConfigSeries[]; + /** Global bounding box across all observations, all steps. */ + domain: { x: [number, number]; y: [number, number] }; +} + +export interface StepDomain { + x: [number, number]; + y: [number, number]; +} + +// Axes shrink to fit configs that pass `hwFilter` (usually `activeHwTypes`). +export function computeStepDomain( + timeline: ReplayTimeline, + stepIndex: number, + hwFilter: (hwKey: string) => boolean, +): StepDomain { + if (timeline.configs.length === 0) return { x: [0, 1], y: [0, 1] }; + const i = Math.max(0, Math.min(timeline.dates.length - 1, stepIndex)); + let xMin = Infinity; + let xMax = -Infinity; + let yMin = Infinity; + let yMax = -Infinity; + for (const c of timeline.configs) { + if (!hwFilter(c.hwKey)) continue; + const v = c.stepValues[i]; + if (!v?.visible) continue; + if (v.x < xMin) xMin = v.x; + if (v.x > xMax) xMax = v.x; + if (v.y < yMin) yMin = v.y; + if (v.y > yMax) yMax = v.y; + } + return { x: safeDomain(xMin, xMax), y: safeDomain(yMin, yMax) }; +} + +const buildPointConfigId = (point: InferenceData): string => { + let key = `${point.hwKey}|${point.precision}|${point.tp}|${point.conc}|${point.decode_ep ?? 0}|${point.prefill_tp ?? 0}|${point.prefill_ep ?? 0}`; + if (point.disagg) key += `|disagg|${point.num_prefill_gpu ?? 0}|${point.num_decode_gpu ?? 0}`; + return key; +}; + +const safeDomain = (lo: number, hi: number): [number, number] => { + if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [0, 1]; + if (lo === hi) { + // Pad degenerate single-point domains so axes don't collapse to a line. + const pad = lo === 0 ? 1 : Math.abs(lo) * 0.1; + return [lo - pad, hi + pad]; + } + return lo < hi ? [lo, hi] : [hi, lo]; +}; + +// Mirrors useChartData + processOverlayChartData so replay frames sit on the +// same axes the static chart shows. +function resolveXAxisField( + chartDef: ChartDefinition, + selectedYAxisMetric: string, + selectedXAxisMetric: string | null, +): string { + const metricTitle = + (chartDef[`${selectedYAxisMetric}_title` as keyof ChartDefinition] as string) || ''; + const isInputMetric = metricTitle.toLowerCase().includes('input'); + const isTtftOverride = + selectedXAxisMetric === 'p99_ttft' || selectedXAxisMetric === 'median_ttft'; + + if (selectedXAxisMetric && chartDef.chartType === 'interactivity' && isInputMetric) { + return selectedXAxisMetric; + } + if (chartDef.chartType === 'interactivity' && isInputMetric) { + const xOverrideKey = `${selectedYAxisMetric}_x` as keyof ChartDefinition; + return (chartDef[xOverrideKey] as string) || chartDef.x; + } + if (chartDef.chartType === 'e2e' && isTtftOverride) { + return selectedXAxisMetric!; + } + return chartDef.x; +} + +export function buildReplayTimeline( + rows: BenchmarkRow[], + chartDef: ChartDefinition, + selectedYAxisMetric: string, + selectedXAxisMetric: string | null, + selectedPrecisions: readonly string[], +): ReplayTimeline { + if (rows.length === 0) { + return { + dates: [], + configs: [], + domain: { x: [0, 1], y: [0, 1] }, + }; + } + + const xAxisField = resolveXAxisField(chartDef, selectedYAxisMetric, selectedXAxisMetric); + const metricKey = selectedYAxisMetric.replace('y_', '') as YAxisMetricKey; + const isDefaultY = selectedYAxisMetric === 'y' || !selectedYAxisMetric; + + const grouped = new Map< + string, + { + hwKey: string; + precision: string; + observations: { point: InferenceData; dateMs: number }[]; + } + >(); + let xMin = Infinity; + let xMax = -Infinity; + let yMin = Infinity; + let yMax = -Infinity; + const dateSet = new Set(); + + for (const row of rows) { + if (!selectedPrecisions.includes(row.precision)) continue; + + const entry = rowToAggDataEntry(row); + entry.hwKey = getHardwareKey(entry); + const point = createChartDataPoint( + row.date, + entry, + chartDef.x as keyof AggDataEntry, + chartDef.y as keyof AggDataEntry, + entry.hwKey, + ); + + const yMetric = isDefaultY + ? point.y + : ((point[metricKey] as { y: number } | undefined)?.y ?? null); + if (yMetric === null) continue; + + const xVal = + xAxisField === chartDef.x + ? point.x + : (point[xAxisField as keyof InferenceData] as number | undefined); + if (typeof xVal !== 'number' || !Number.isFinite(xVal) || !Number.isFinite(yMetric)) continue; + if (xVal <= 0 || yMetric <= 0) continue; + + const finalPoint: InferenceData = { ...point, x: xVal, y: yMetric }; + const configId = buildPointConfigId(finalPoint); + const dateMs = Date.parse(`${row.date}T00:00:00Z`); + if (Number.isNaN(dateMs)) continue; + + let bucket = grouped.get(configId); + if (!bucket) { + bucket = { + hwKey: String(finalPoint.hwKey ?? ''), + precision: finalPoint.precision, + observations: [], + }; + grouped.set(configId, bucket); + } + bucket.observations.push({ point: finalPoint, dateMs }); + dateSet.add(row.date); + if (xVal < xMin) xMin = xVal; + if (xVal > xMax) xMax = xVal; + if (yMetric < yMin) yMin = yMetric; + if (yMetric > yMax) yMax = yMetric; + } + + const dates = [...dateSet].toSorted(); + const dateMsList = dates.map((d) => Date.parse(`${d}T00:00:00Z`)); + + const configs: ReplayConfigSeries[] = []; + for (const [configId, bucket] of grouped) { + bucket.observations.sort((a, b) => a.dateMs - b.dateMs); + + const byDate = new Map(); + for (const o of bucket.observations) byDate.set(o.dateMs, o); + const dedup = [...byDate.values()].toSorted((a, b) => a.dateMs - b.dateMs); + + if (dedup.length === 0) continue; + + const stepValues: PerStepValue[] = []; + let obsIdx = 0; + let latest: { x: number; y: number } | null = null; + for (const stepMs of dateMsList) { + while (obsIdx < dedup.length && dedup[obsIdx].dateMs <= stepMs) { + const p = dedup[obsIdx].point; + latest = { x: p.x, y: p.y }; + obsIdx++; + } + stepValues.push( + latest === null + ? { visible: false, x: 0, y: 0 } + : { visible: true, x: latest.x, y: latest.y }, + ); + } + + configs.push({ + configId, + hwKey: bucket.hwKey, + precision: bucket.precision, + template: dedup[0].point, + stepValues, + }); + } + + return { + dates, + configs, + domain: { x: safeDomain(xMin, xMax), y: safeDomain(yMin, yMax) }, + }; +} diff --git a/packages/app/src/components/inference/replay/exportMp4.ts b/packages/app/src/components/inference/replay/exportMp4.ts new file mode 100644 index 00000000..676c30ff --- /dev/null +++ b/packages/app/src/components/inference/replay/exportMp4.ts @@ -0,0 +1,469 @@ +import type { ArrayBufferTarget as ArrayBufferTargetType, Muxer as MuxerType } from 'mp4-muxer'; + +export type Mp4ExportStage = 'init' | 'render' | 'encode' | 'flush' | 'mux'; + +// Brand check on the `name` field — `instanceof` is unreliable here because +// `exportMp4.ts` is dynamically imported, so the class identity can differ +// between the caller's static type-only import and the runtime instance. +export function isMp4ExportError(value: unknown): value is Mp4ExportError { + return ( + typeof value === 'object' && + value !== null && + (value as { name?: unknown }).name === 'Mp4ExportError' + ); +} + +/** Stage-tagged error thrown by exportReplayMp4; lets the caller attribute failures to the actual pipeline phase. */ +export class Mp4ExportError extends Error { + readonly stage: Mp4ExportStage; + readonly encoderState: VideoEncoder['state'] | 'unknown'; + readonly queuedFrames: number; + + constructor( + message: string, + options: { + stage: Mp4ExportStage; + encoderState: VideoEncoder['state'] | 'unknown'; + queuedFrames: number; + cause?: unknown; + }, + ) { + super(message); + this.name = 'Mp4ExportError'; + this.stage = options.stage; + this.encoderState = options.encoderState; + this.queuedFrames = options.queuedFrames; + // tsconfig target is ES2017 — Error's options-arg form is ES2022, so the + // manual assignment is still required to preserve `cause`. + if (options.cause !== undefined) (this as { cause?: unknown }).cause = options.cause; + } +} + +interface ExportOptions { + /** Live replay panel element captured each frame. Must be in the DOM. */ + captureRoot: HTMLElement; + /** + * Advance the replay to the given fraction [0, 1] and resolve once the new + * frame has been painted. Called once per output frame. The caller is + * responsible for flushing React state and waiting for paint. + */ + renderFrame: (fraction: number) => Promise; + fileName: string; + fps?: number; + durationSec?: number; + bitrate?: number; + onProgress?: (fraction: number) => void; + /** Fires when the pipeline advances stages, so callers can record where a failure happened. */ + onStage?: (stage: Mp4ExportStage) => void; + /** Aborting before completion throws an AbortError without writing the file. */ + signal?: AbortSignal; +} + +const CSS_VAR_RE = /var\(--([^)]+)\)/u; +const WATERMARK_HEIGHT = 48; +const WATERMARK_TEXT = 'InferenceX — github.com/SemiAnalysisAI/InferenceX'; + +// Mutates the supplied root in place — call only on a clone; baking onto the +// live panel would freeze it on current theme. +function resolveCssVarsForExport(root: HTMLElement) { + const rootStyles = getComputedStyle(document.documentElement); + + function resolve(raw: string): string { + let resolved = raw; + let match: RegExpExecArray | null; + while ((match = CSS_VAR_RE.exec(resolved)) !== null) { + const computed = rootStyles.getPropertyValue(`--${match[1]}`).trim(); + const next = resolved.replace(match[0], computed || match[0]); + if (next === resolved) break; + resolved = next; + } + return resolved; + } + + const PRESENTATION_ATTRS = ['fill', 'stroke', 'color', 'stop-color']; + for (const el of [...root.querySelectorAll('svg, svg *')] as SVGElement[]) { + for (const attr of PRESENTATION_ATTRS) { + const val = el.getAttribute(attr); + if (val && CSS_VAR_RE.test(val)) el.setAttribute(attr, resolve(val)); + } + for (const prop of el.style) { + const val = el.style.getPropertyValue(prop); + if (val && CSS_VAR_RE.test(val)) el.style.setProperty(prop, resolve(val)); + } + } + + const COMPUTED_SELECTORS: { selector: string; attr: string; cssProp: string }[] = [ + { selector: '.chart-root .grid line', attr: 'stroke', cssProp: 'stroke' }, + { selector: '.chart-root .x-axis .domain', attr: 'stroke', cssProp: 'stroke' }, + { selector: '.chart-root .y-axis .domain', attr: 'stroke', cssProp: 'stroke' }, + { selector: '.chart-root .tick line', attr: 'stroke', cssProp: 'stroke' }, + { selector: '.chart-root .tick text', attr: 'fill', cssProp: 'fill' }, + { selector: '.x-axis-label, .y-axis-label', attr: 'fill', cssProp: 'fill' }, + ]; + for (const { selector, attr, cssProp } of COMPUTED_SELECTORS) { + for (const el of [...root.querySelectorAll(selector)] as SVGElement[]) { + const current = el.getAttribute(attr); + if (!current || CSS_VAR_RE.test(current)) { + const computed = getComputedStyle(el).getPropertyValue(cssProp); + if (computed) el.setAttribute(attr, computed.trim()); + } + } + } +} + +// html-to-image can't resolve var(--*) tokens used by Tailwind text utilities, +// so bake live computed colors onto the clone. +function bakeTextColorsFromLive(liveRoot: HTMLElement, cloneRoot: HTMLElement) { + const liveEls = [ + liveRoot, + ...liveRoot.querySelectorAll('h1, h2, h3, h4, p, span, label, button'), + ]; + const cloneEls = [ + cloneRoot, + ...cloneRoot.querySelectorAll('h1, h2, h3, h4, p, span, label, button'), + ]; + const len = Math.min(liveEls.length, cloneEls.length); + for (let i = 0; i < len; i++) { + const liveStyle = getComputedStyle(liveEls[i]); + const c = cloneEls[i]; + if (liveStyle.color) c.style.color = liveStyle.color; + } +} + +// Drop the live `max-h-[480px] overflow-y-auto` wrapper so every legend item +// appears in the rasterized frame. +function expandLegendForExport(cloneRoot: HTMLElement) { + const legend = cloneRoot.querySelector('[data-testid="replay-legend"]'); + if (legend) { + const scrollHost = legend.parentElement; + if (scrollHost) { + scrollHost.style.maxHeight = 'none'; + scrollHost.style.overflow = 'visible'; + scrollHost.style.height = 'auto'; + } + } +} + +const skipNoExport = (node: Node) => + !((node as Element).classList && (node as Element).classList.contains('no-export')); + +/** Draw the panel canvas onto a slightly taller canvas with an InferenceX watermark bar. */ +function drawWithWatermark( + source: HTMLCanvasElement, + bgColor: string, + isDark: boolean, +): HTMLCanvasElement { + const out = document.createElement('canvas'); + out.width = source.width; + out.height = source.height + WATERMARK_HEIGHT; + const ctx = out.getContext('2d'); + if (!ctx) return source; + ctx.fillStyle = bgColor || (isDark ? '#0a0a0a' : '#ffffff'); + ctx.fillRect(0, 0, out.width, out.height); + ctx.drawImage(source, 0, 0); + ctx.fillStyle = isDark ? '#1a1a2e' : '#f5f5f5'; + ctx.fillRect(0, source.height, out.width, WATERMARK_HEIGHT); + ctx.fillStyle = isDark ? '#aaa' : '#555'; + ctx.font = 'bold 16px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(WATERMARK_TEXT, out.width / 2, source.height + WATERMARK_HEIGHT / 2); + return out; +} + +// Per-frame: caller advances replay → clone live panel → bake colors → toCanvas → encode. +export async function exportReplayMp4(opts: ExportOptions): Promise { + const { + captureRoot: livePanel, + renderFrame, + fileName, + fps = 30, + durationSec = 6, + bitrate = 6_000_000, + onProgress, + onStage, + signal, + } = opts; + + let stage: Mp4ExportStage = 'init'; + const advanceStage = (next: Mp4ExportStage) => { + if (stage === next) return; + stage = next; + onStage?.(next); + }; + + const throwIfAborted = () => { + if (signal?.aborted) { + const err = new Error('Export cancelled'); + err.name = 'AbortError'; + throw err; + } + }; + + if (typeof VideoEncoder === 'undefined' || typeof VideoFrame === 'undefined') { + throw new TypeError('WebCodecs is not available in this browser. Try Chrome.'); + } + + if (!livePanel.isConnected) { + throw new Error('Replay panel element is not in the DOM.'); + } + + const [{ Muxer, ArrayBufferTarget }, { toCanvas }] = await Promise.all([ + import('mp4-muxer'), + import('@jpinsonneau/html-to-image'), + ]); + + // Off-screen host: kept positioned far off-canvas (not display:none, because + // html-to-image needs computed styles to be available). + const liveRect = livePanel.getBoundingClientRect(); + const host = document.createElement('div'); + host.setAttribute('aria-hidden', 'true'); + host.style.cssText = [ + 'position:fixed', + 'left:-100000px', + 'top:0', + 'pointer-events:none', + 'opacity:0', + `width:${Math.ceil(liveRect.width)}px`, + ].join(';'); + document.body.append(host); + + const bgColor = + getComputedStyle(document.documentElement).getPropertyValue('--background').trim() || '#fff'; + const isDark = + document.documentElement.classList.contains('dark') || + document.documentElement.classList.contains('minecraft'); + + let outWidth = 0; + let outHeight = 0; + let muxer: MuxerType | null = null; + let encoder: VideoEncoder | null = null; + const totalFrames = Math.max(2, Math.floor(durationSec * fps)); + + // Captured so a VideoEncoder error callback (which can fire at any point + // during encode/flush) surfaces as a checkable error instead of an + // un-awaitable throw from inside an async callback. Boxed so TS doesn't + // narrow the field to `never` — the only write is inside a callback TS + // can't see firing. The snapshot captures encoder state at the *moment* + // the error fires; reading it lazily after `close()` reports `closed`/0, + // hiding the actual back-pressure that caused the failure. + const encoderErrorBox: { + current: Error | null; + snapshot: { encoderState: VideoEncoder['state'] | 'unknown'; queuedFrames: number } | null; + } = { current: null, snapshot: null }; + let muxerFinalized = false; + + const failureSnapshot = () => + encoderErrorBox.snapshot ?? { + encoderState: encoder?.state ?? ('unknown' as const), + queuedFrames: encoder?.encodeQueueSize ?? 0, + }; + + try { + for (let i = 0; i < totalFrames; i++) { + throwIfAborted(); + if (encoderErrorBox.current !== null) { + const err = encoderErrorBox.current; + throw new Mp4ExportError(err.message, { + stage, + ...failureSnapshot(), + cause: err, + }); + } + const t = totalFrames === 1 ? 1 : i / (totalFrames - 1); + advanceStage('render'); + await renderFrame(t); + + // Per-frame clone: React commits new dot positions on the live SVG, so a + // deep clone each frame captures the current state. + host.replaceChildren(); + const clone = livePanel.cloneNode(true) as HTMLElement; + clone.removeAttribute('id'); + clone.style.width = `${Math.ceil(liveRect.width)}px`; + host.append(clone); + bakeTextColorsFromLive(livePanel, clone); + expandLegendForExport(clone); + resolveCssVarsForExport(clone); + + // Collapse .no-export boxes entirely. html-to-image's `filter` skips + // rendering, but the cloned nodes still take layout space — leaving + // dead space below the chart (controls bar) and inside the legend + // (search input, switches, action links). Matches the PNG export path. + for (const el of clone.querySelectorAll('.no-export')) { + el.style.display = 'none'; + } + + // Legend scroll container has a `border-b` divider that only makes sense + // when the bottom controls below it are visible; with .no-export gone + // the line dangles, so strip it once nothing visible remains below. + const legendContainer = clone.querySelector('[data-testid="chart-legend"]'); + if (legendContainer) { + const scrollContainer = + legendContainer.querySelector('ul, [class*="overflow"]'); + if (scrollContainer) { + const sibling = scrollContainer.nextElementSibling as HTMLElement | null; + const hasVisibleControls = + sibling && + sibling.style.display !== 'none' && + [...sibling.children].some((child) => (child as HTMLElement).style.display !== 'none'); + if (!hasVisibleControls) { + scrollContainer.style.borderBottom = 'none'; + scrollContainer.style.paddingBottom = '0'; + } + } + } + + const captured = await toCanvas(clone, { + pixelRatio: 1, + cacheBust: false, + backgroundColor: bgColor, + filter: skipNoExport, + }); + + const watermarked = drawWithWatermark(captured, bgColor, isDark); + + // Lock encoder dimensions to the first watermarked frame and pad/crop + // subsequent frames to match (small reflow noise can shift the captured + // size by a pixel or two; H.264 needs stable dims). + if (i === 0) { + // Round UP to the nearest even pixel and letterbox into the resulting + // canvas. Rounding down silently crops the rightmost/bottom pixel + // column of the watermark on odd dimensions (e.g. 1281 → 1280). + outWidth = Math.max(2, Math.ceil(watermarked.width / 2) * 2); + outHeight = Math.max(2, Math.ceil(watermarked.height / 2) * 2); + const newMuxer = new Muxer({ + target: new ArrayBufferTarget(), + video: { codec: 'avc', width: outWidth, height: outHeight }, + fastStart: 'in-memory', + }); + // oxlint-disable-next-line no-loop-func + const newEncoder = new VideoEncoder({ + // oxlint-disable-next-line no-loop-func + output: (chunk, meta) => { + // Flip stage on the first chunk the encoder hands us: this is the + // earliest point where a muxer-thrown error would be attributable + // to muxing, not encoding. Without this, any throw from + // addVideoChunk surfaces while stage is still 'encode'. + advanceStage('mux'); + newMuxer.addVideoChunk(chunk, meta); + }, + // oxlint-disable-next-line no-loop-func + error: (e: unknown) => { + encoderErrorBox.current = e instanceof Error ? e : new Error(String(e)); + // Snapshot synchronously: by the time the catch runs we may + // have already closed the encoder, hiding the back-pressure. + encoderErrorBox.snapshot = { + encoderState: newEncoder.state, + queuedFrames: newEncoder.encodeQueueSize, + }; + }, + }); + newEncoder.configure({ + codec: 'avc1.640028', + width: outWidth, + height: outHeight, + bitrate, + framerate: fps, + }); + muxer = newMuxer; + encoder = newEncoder; + } + + const fit = document.createElement('canvas'); + fit.width = outWidth; + fit.height = outHeight; + const fctx = fit.getContext('2d'); + if (!fctx) throw new Error('Could not allocate frame canvas'); + fctx.fillStyle = bgColor; + fctx.fillRect(0, 0, outWidth, outHeight); + // Centre into the fixed encoder canvas instead of anchoring to (0,0). + // outW/outH are ceiled from frame 0, so subsequent frames are usually + // ≤ that size — letterbox bars fill with bgColor. If reflow noise + // pushes a frame larger, the source rect crops symmetrically rather + // than dropping the right/bottom edge. + const drawW = Math.min(watermarked.width, outWidth); + const drawH = Math.min(watermarked.height, outHeight); + const srcX = Math.floor((watermarked.width - drawW) / 2); + const srcY = Math.floor((watermarked.height - drawH) / 2); + const dstX = Math.floor((outWidth - drawW) / 2); + const dstY = Math.floor((outHeight - drawH) / 2); + fctx.drawImage(watermarked, srcX, srcY, drawW, drawH, dstX, dstY, drawW, drawH); + + // mp4-muxer rejects null durations on encoded chunks; WebCodecs leaves + // `duration` unset on VideoFrame unless we pass it through here. + const frame = new VideoFrame(fit, { + timestamp: Math.round((i / fps) * 1_000_000), + duration: Math.round(1_000_000 / fps), + }); + advanceStage('encode'); + encoder!.encode(frame, { keyFrame: i % fps === 0 }); + frame.close(); + + onProgress?.(i / (totalFrames - 1)); + } + + if (!muxer || !encoder) { + throw new Mp4ExportError('Encoder was never initialized.', { stage, ...failureSnapshot() }); + } + advanceStage('flush'); + await Promise.race([ + encoder.flush(), + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('Encoder flush timed out after 30s.')), 30_000); + }), + ]); + if (encoderErrorBox.current !== null) { + const err = encoderErrorBox.current; + throw new Mp4ExportError(err.message, { + stage, + ...failureSnapshot(), + cause: err, + }); + } + encoder.close(); + advanceStage('mux'); + muxer.finalize(); + muxerFinalized = true; + + const blob = new Blob([muxer.target.buffer], { type: 'video/mp4' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${fileName}-${Date.now()}.mp4`; + document.body.append(link); + link.click(); + link.remove(); + // Revoking synchronously races Chromium's async download dispatch — the + // blob URL is freed before the browser reads it, so the file lands as the + // bare blob UUID with no extension. Defer until the download has started. + setTimeout(() => URL.revokeObjectURL(url), 1000); + onProgress?.(1); + } catch (error) { + if (error instanceof Mp4ExportError) throw error; + if (error instanceof Error && error.name === 'AbortError') throw error; + const message = error instanceof Error ? error.message : String(error); + throw new Mp4ExportError(message, { stage, ...failureSnapshot(), cause: error }); + } finally { + // VideoEncoder is a native resource — relying on GC orphans GPU/codec + // slots on error paths (esp. flush timeout, which throws but leaves the + // encoder still draining). + if (encoder && encoder.state !== 'closed') { + try { + encoder.close(); + } catch { + // Some Chromium builds throw on double-close; swallow. + } + } + // Double-finalize corrupts the MP4 box structure; only finalize here on + // error paths where the muxer was constructed but never reached the + // happy-path finalize. + if (muxer && !muxerFinalized) { + try { + muxer.finalize(); + } catch { + // Best-effort cleanup; nothing to surface to the caller. + } + } + host.remove(); + } +} diff --git a/packages/app/src/components/inference/replay/interpolateAtTime.ts b/packages/app/src/components/inference/replay/interpolateAtTime.ts new file mode 100644 index 00000000..b1d4c740 --- /dev/null +++ b/packages/app/src/components/inference/replay/interpolateAtTime.ts @@ -0,0 +1,33 @@ +export interface PerStepValue { + visible: boolean; + x: number; + y: number; +} + +// invisible→visible pops in at destination so new dots land on the frontier +// instead of dragging across from (0,0). +export function interpolateAtStep( + stepValues: readonly PerStepValue[], + idxFloat: number, +): PerStepValue { + const n = stepValues.length; + if (n === 0) return { visible: false, x: 0, y: 0 }; + + const clamped = Math.max(0, Math.min(n - 1, idxFloat)); + const idxLow = Math.min(n - 1, Math.floor(clamped)); + const idxHigh = Math.min(n - 1, idxLow + 1); + const a = stepValues[idxLow]; + const b = stepValues[idxHigh]; + + if (idxLow === idxHigh) return { visible: a.visible, x: a.x, y: a.y }; + if (!a.visible && !b.visible) return { visible: false, x: 0, y: 0 }; + if (a.visible && !b.visible) return { visible: true, x: a.x, y: a.y }; + if (!a.visible && b.visible) return { visible: true, x: b.x, y: b.y }; + + const frac = clamped - idxLow; + return { + visible: true, + x: a.x + (b.x - a.x) * frac, + y: a.y + (b.y - a.y) * frac, + }; +} diff --git a/packages/app/src/components/inference/replay/replayFrameData.ts b/packages/app/src/components/inference/replay/replayFrameData.ts new file mode 100644 index 00000000..41680892 --- /dev/null +++ b/packages/app/src/components/inference/replay/replayFrameData.ts @@ -0,0 +1,52 @@ +import type { InferenceData } from '@/components/inference/types'; + +import type { ReplayTimeline } from './buildReplayTimeline'; +import { interpolateAtStep } from './interpolateAtTime'; + +export function buildFrameData(timeline: ReplayTimeline, fraction: number): InferenceData[] { + const idxFloat = stepFloatAtFraction(fraction, timeline.dates.length); + const out: InferenceData[] = []; + for (const c of timeline.configs) { + const r = interpolateAtStep(c.stepValues, idxFloat); + if (!r.visible) continue; + out.push({ ...c.template, x: r.x, y: r.y }); + } + return out; +} + +// Cubic ease-in-out per segment: playhead settles on observed dates, accelerates between them. +export function stepFloatAtFraction(fraction: number, n: number): number { + if (n <= 1) return 0; + const raw = Math.max(0, Math.min(1, fraction)) * (n - 1); + const idxLow = Math.floor(raw); + const segFrac = raw - idxLow; + const eased = segFrac < 0.5 ? 4 * segFrac ** 3 : 1 - (-2 * segFrac + 2) ** 3 / 2; + return idxLow + eased; +} + +// ~800ms per observed step, capped at 30s so long histories still finish in reasonable time. +export function spanMs(numDates: number): number { + if (numDates <= 1) return 1500; + return Math.min(30_000, Math.max(4500, numDates * 800)); +} + +// Scrubber-resolution quantum (1/1000) used to throttle React commits while +// the rAF loop advances continuously through the underlying ref. +export const FRACTION_COMMIT_QUANTUM = 1000; + +// True when `next` differs from `prev` by at least one quantum tick. The +// caller decides whether to bypass this entirely (force) — keeping the +// predicate pure makes it match its name. +export function shouldCommitFraction(prev: number, next: number): boolean { + return Math.round(prev * FRACTION_COMMIT_QUANTUM) !== Math.round(next * FRACTION_COMMIT_QUANTUM); +} + +// Floor the eased step (same math as the renderer's interpolation) so the +// label changes only when the visible interpolation crosses into the next +// segment, not when the playhead is halfway through it. +export function dateAtFraction(timeline: ReplayTimeline, fraction: number): string { + const dates = timeline.dates; + if (dates.length === 0) return ''; + const step = Math.floor(stepFloatAtFraction(fraction, dates.length)); + return dates[Math.max(0, Math.min(dates.length - 1, step))] ?? ''; +} diff --git a/packages/app/src/components/inference/replay/useReducedMotion.ts b/packages/app/src/components/inference/replay/useReducedMotion.ts new file mode 100644 index 00000000..adb12439 --- /dev/null +++ b/packages/app/src/components/inference/replay/useReducedMotion.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from 'react'; + +const QUERY = '(prefers-reduced-motion: reduce)'; + +export function useReducedMotion(): boolean { + const [reduced, setReduced] = useState(false); + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; + const mq = window.matchMedia(QUERY); + setReduced(mq.matches); + const onChange = (e: MediaQueryListEvent) => setReduced(e.matches); + mq.addEventListener('change', onChange); + return () => mq.removeEventListener('change', onChange); + }, []); + return reduced; +} diff --git a/packages/app/src/components/inference/types.ts b/packages/app/src/components/inference/types.ts index 0ea63fca..a0e9232d 100644 --- a/packages/app/src/components/inference/types.ts +++ b/packages/app/src/components/inference/types.ts @@ -190,8 +190,10 @@ export type YAxisMetricKey = * @property {string} y_label - The label for the y-axis. * @property {'up' | 'down'} roofline - Specifies the direction of the roofline calculation (e.g., "up" for higher is better, "down" for lower is better). */ +export type InferenceChartType = 'e2e' | 'interactivity'; + export interface ChartDefinition { - chartType: string; + chartType: InferenceChartType; heading: string; x: keyof AggDataEntry; x_label: string; @@ -347,6 +349,19 @@ export interface ScatterGraphProps { * on top of the official chart data with a distinct visual style (triangles). */ overlayData?: OverlayData; + /** + * D3 transition duration in ms used when data or scales change. Defaults to + * the regular interactive value (750). The replay panel passes 0 so frames + * snap to interpolated positions instead of fighting a 750ms tween. + */ + transitionDuration?: number; + /** + * Apply `.nice()` to x/y scale domains. Defaults to true. Replay disables + * this so the domain endpoints shift continuously between frames instead of + * snapping to rounded tick values (which produces visible "jumps" mid + * playback). + */ + niceAxes?: boolean; } /** * @file types.ts diff --git a/packages/app/src/components/inference/ui/ChartDisplay.tsx b/packages/app/src/components/inference/ui/ChartDisplay.tsx index 71323b99..68f46809 100644 --- a/packages/app/src/components/inference/ui/ChartDisplay.tsx +++ b/packages/app/src/components/inference/ui/ChartDisplay.tsx @@ -1,7 +1,7 @@ 'use client'; import { track } from '@/lib/analytics'; import dynamic from 'next/dynamic'; -import { useMemo, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { BarChart3, ChevronDown, Table2, X } from 'lucide-react'; import chartDefinitions from '@/components/inference/inference-chart-config.json'; @@ -48,6 +48,7 @@ import ComparisonChangelog from './ComparisonChangelog'; import CustomCosts from './CustomCosts'; import CustomPowers from './CustomPowers'; import GPUGraph from './GPUGraph'; +import ReplayLauncher, { type ReplayLauncherHandle } from '../replay/ReplayLauncher'; import TrendChart from './TrendChart'; const ModelArchitectureDiagram = dynamic(() => import('./ModelArchitectureDiagram'), { @@ -158,6 +159,7 @@ export default function ChartDisplay() { } = useComparisonChangelogs(selectedGPUs, selectedDateRange, dateRangeAvailableDates); const [viewModes, setViewModes] = useState>({}); + const replayHandlesRef = useRef>({}); const getViewMode = (index: number): InferenceViewMode => viewModes[index] ?? 'chart'; const handleViewModeChange = (index: number, value: InferenceViewMode) => { setViewModes((prev) => ({ ...prev, [index]: value })); @@ -319,197 +321,189 @@ export default function ChartDisplay() { )) : effectiveGraphs.length === 0 ? [] - : effectiveGraphs.map((graph, graphIndex) => ( -
-
- 0 - ? 'gpu_timeseries' - : graph.chartDefinition.chartType === 'e2e' - ? 'latency' - : 'interactivity' - } - leadingControls={ - handleViewModeChange(graphIndex, v)} - ariaLabel="View mode" - testId={`inference-view-toggle-${graphIndex}`} - /> - } - hideImageExport={getViewMode(graphIndex) === 'table'} - setIsLegendExpanded={setIsLegendExpanded} - exportFileName={`InferenceX_${selectedModel}_${graph.chartDefinition.chartType}`} - onExportCsv={() => { - const isTimeline = - selectedDateRange.startDate && - selectedDateRange.endDate && - selectedGPUs.length > 0; - const visibleData = graph.data.filter((d) => - isTimeline - ? activeDates.has(`${d.date}_${d.hwKey}`) - : activeHwTypes.has(d.hwKey as string) && - selectedPrecisions.includes(d.precision), - ); - const { headers, rows } = inferenceChartToCsv( - visibleData, - graph.model, - graph.sequence, - ); - exportToCsv( - `InferenceX_${selectedModel}_${graph.chartDefinition.chartType}`, - headers, - rows, - ); - }} - /> - - {(() => { - const chartCaption = ( - <> -

- { - graph.chartDefinition[ - `${selectedYAxisMetric}_title` as keyof typeof graph.chartDefinition - ] - }{' '} - {(() => { - // For Input metrics with dynamic x-axis, use dynamic heading - const metricTitle = - (graph.chartDefinition[ + : effectiveGraphs.map((graph, graphIndex) => { + const isTimelineMode = Boolean( + selectedDateRange.startDate && selectedDateRange.endDate && selectedGPUs.length > 0, + ); + const replayAvailable = getViewMode(graphIndex) === 'chart' && !isTimelineMode; + return ( +
+
+ handleViewModeChange(graphIndex, v)} + ariaLabel="View mode" + testId={`inference-view-toggle-${graphIndex}`} + /> + } + hideImageExport={getViewMode(graphIndex) === 'table'} + setIsLegendExpanded={setIsLegendExpanded} + exportFileName={`InferenceX_${selectedModel}_${graph.chartDefinition.chartType}`} + onExportMp4={ + replayAvailable ? () => replayHandlesRef.current[graphIndex]?.open() : undefined + } + onExportCsv={() => { + const visibleData = graph.data.filter((d) => + isTimelineMode + ? activeDates.has(`${d.date}_${d.hwKey}`) + : activeHwTypes.has(d.hwKey as string) && + selectedPrecisions.includes(d.precision), + ); + const { headers, rows } = inferenceChartToCsv( + visibleData, + graph.model, + graph.sequence, + ); + exportToCsv( + `InferenceX_${selectedModel}_${graph.chartDefinition.chartType}`, + headers, + rows, + ); + }} + /> + + {(() => { + const chartCaption = ( + <> +

+ { + graph.chartDefinition[ `${selectedYAxisMetric}_title` as keyof typeof graph.chartDefinition - ] as string) || ''; - const isInputMetric = metricTitle.toLowerCase().includes('input'); - if ( - graph.chartDefinition.chartType === 'interactivity' && - isInputMetric && - selectedXAxisMetric - ) { - if (selectedXAxisMetric === 'p99_ttft') { - return 'vs. P99 Time To First Token'; - } else if (selectedXAxisMetric === 'median_ttft') { - return 'vs. Median Time To First Token'; + ] + }{' '} + {(() => { + // For Input metrics with dynamic x-axis, use dynamic heading + const metricTitle = + (graph.chartDefinition[ + `${selectedYAxisMetric}_title` as keyof typeof graph.chartDefinition + ] as string) || ''; + const isInputMetric = metricTitle.toLowerCase().includes('input'); + if ( + graph.chartDefinition.chartType === 'interactivity' && + isInputMetric && + selectedXAxisMetric + ) { + if (selectedXAxisMetric === 'p99_ttft') { + return 'vs. P99 Time To First Token'; + } else if (selectedXAxisMetric === 'median_ttft') { + return 'vs. Median Time To First Token'; + } } - } - // For e2e chart: render clickable inline dropdown for x-axis - if (graph.chartDefinition.chartType === 'e2e') { - const xAxisLabel = - selectedE2eXAxisMetric === 'p99_ttft' - ? 'P99 TTFT' - : selectedE2eXAxisMetric === 'median_ttft' - ? 'Median TTFT' - : 'End-to-end Latency'; - const xAxisOptions = [ - { value: null, label: 'End-to-end Latency' }, - { value: 'p99_ttft', label: 'P99 TTFT' }, - { value: 'median_ttft', label: 'Median TTFT' }, - ]; - const zoomPrefix = - selectedDateRange.startDate && - selectedDateRange.endDate && - selectedGPUs.length > 0 - ? 'gpu_timeseries' - : 'latency'; + // For e2e chart: render clickable inline dropdown for x-axis + if (graph.chartDefinition.chartType === 'e2e') { + const xAxisLabel = + selectedE2eXAxisMetric === 'p99_ttft' + ? 'P99 TTFT' + : selectedE2eXAxisMetric === 'median_ttft' + ? 'Median TTFT' + : 'End-to-end Latency'; + const xAxisOptions = [ + { value: null, label: 'End-to-end Latency' }, + { value: 'p99_ttft', label: 'P99 TTFT' }, + { value: 'median_ttft', label: 'Median TTFT' }, + ]; + const zoomPrefix = + selectedDateRange.startDate && + selectedDateRange.endDate && + selectedGPUs.length > 0 + ? 'gpu_timeseries' + : 'latency'; + return ( + { + setSelectedE2eXAxisMetric(value); + track('latency_x_axis_metric_selected', { + metric: value ?? 'median_e2el', + }); + window.dispatchEvent( + new CustomEvent( + `${zoomPrefix}_zoom_reset_chart-${graphIndex}`, + ), + ); + }} + /> + ); + } + + // Fall back to configured heading return ( - { - setSelectedE2eXAxisMetric(value); - track('latency_x_axis_metric_selected', { - metric: value ?? 'median_e2el', - }); - window.dispatchEvent( - new CustomEvent(`${zoomPrefix}_zoom_reset_chart-${graphIndex}`), - ); - }} - /> + graph.chartDefinition[ + `${selectedYAxisMetric}_heading` as keyof typeof graph.chartDefinition + ] || graph.chartDefinition.heading ); - } - - // Fall back to configured heading - return ( - graph.chartDefinition[ - `${selectedYAxisMetric}_heading` as keyof typeof graph.chartDefinition - ] || graph.chartDefinition.heading - ); - })()} -

-

- {getModelLabel(graph.model as Model)} •{' '} - {selectedPrecisions - .map((prec) => getPrecisionLabel(prec as Precision)) - .join(', ')}{' '} - • {getSequenceLabel(graph.sequence as Sequence)} •{' '} - {isUnofficialRun - ? 'Source: UNOFFICIAL' - : 'Source: SemiAnalysis InferenceX™'} - {selectedRunDate && ( - <> - {' '} - • Updated:{' '} - {new Date(`${selectedRunDate}T00:00:00Z`).toLocaleDateString('en-US', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - timeZone: 'UTC', - })} - - )} -

- - - - ); - - if (getViewMode(graphIndex) === 'table') { - const overlay = - graph.chartDefinition.chartType === 'e2e' - ? overlayDataByChartType.e2e - : overlayDataByChartType.interactivity; - const overlayRows = (overlay?.data ?? []).filter((p) => - selectedPrecisions.includes(p.precision), - ); - return ( - <> - {chartCaption} - 0 ? [...graph.data, ...overlayRows] : graph.data - } - chartDefinition={graph.chartDefinition} - selectedYAxisMetric={selectedYAxisMetric} - /> + })()} +

+

+ {getModelLabel(graph.model as Model)} •{' '} + {selectedPrecisions + .map((prec) => getPrecisionLabel(prec as Precision)) + .join(', ')}{' '} + • {getSequenceLabel(graph.sequence as Sequence)} •{' '} + {isUnofficialRun + ? 'Source: UNOFFICIAL' + : 'Source: SemiAnalysis InferenceX™'} + {selectedRunDate && ( + <> + {' '} + • Updated:{' '} + {new Date(`${selectedRunDate}T00:00:00Z`).toLocaleDateString( + 'en-US', + { + year: 'numeric', + month: '2-digit', + day: '2-digit', + timeZone: 'UTC', + }, + )} + + )} +

+ + ); - } - return selectedDateRange.startDate && - selectedDateRange.endDate && - selectedGPUs.length > 0 ? ( - - ) : ( -
- + selectedPrecisions.includes(p.precision), + ); + return ( + <> + {chartCaption} + 0 ? [...graph.data, ...overlayRows] : graph.data + } + chartDefinition={graph.chartDefinition} + selectedYAxisMetric={selectedYAxisMetric} + /> + + ); + } + + return selectedDateRange.startDate && + selectedDateRange.endDate && + selectedGPUs.length > 0 ? ( + - {selectedGPUs.length > 0 && - (!selectedDateRange.startDate || !selectedDateRange.endDate) && ( -
-

- Select a date range to view GPU comparison -

-
- )} -
- ); - })()} -
-
-
- )); + ) : ( +
+ + {selectedGPUs.length > 0 && + (!selectedDateRange.startDate || !selectedDateRange.endDate) && ( +
+

+ Select a date range to view GPU comparison +

+
+ )} +
+ ); + })()} + {replayAvailable && ( + { + replayHandlesRef.current[graphIndex] = handle; + }} + parentChartId={`chart-${graphIndex}`} + chartDefinition={graph.chartDefinition} + yLabel={`${ + graph.chartDefinition[ + `${selectedYAxisMetric}_label` as keyof typeof graph.chartDefinition + ] + }`} + xLabel={graph.chartDefinition.x_label} + /> + )} + + + + ); + }); return (
diff --git a/packages/app/src/components/inference/ui/ScatterGraph.tsx b/packages/app/src/components/inference/ui/ScatterGraph.tsx index a68d5aac..f9a73aa8 100644 --- a/packages/app/src/components/inference/ui/ScatterGraph.tsx +++ b/packages/app/src/components/inference/ui/ScatterGraph.tsx @@ -112,6 +112,8 @@ const ScatterGraph = React.memo( showAllHardwareTypes = false, hardwareConfigOverride, overlayData, + transitionDuration = 750, + niceAxes = true, }: ScatterGraphProps) => { const { activeHwTypes, @@ -447,10 +449,10 @@ const ScatterGraph = React.memo( return { type: (useLog ? 'log' : 'linear') as 'log' | 'linear', domain, - nice: true, + nice: niceAxes, _isLog: useLog, }; - }, [visiblePoints, isInputTputMetric, xLabel, scaleType]); + }, [visiblePoints, isInputTputMetric, xLabel, scaleType, niceAxes]); const yScaleConfig = useMemo(() => { const ext = @@ -472,9 +474,9 @@ const ScatterGraph = React.memo( return { type: (useLog ? 'log' : 'linear') as 'log' | 'linear', domain: [yMin, ext[1] * 1.05] as [number, number], - nice: true, + nice: niceAxes, }; - }, [visiblePoints, isInputTputMetric, logScale]); + }, [visiblePoints, isInputTputMetric, logScale, niceAxes]); // --- Axis configs --- const xAxisConfig = useMemo( @@ -1909,7 +1911,7 @@ const ScatterGraph = React.memo( layers={layers} zoom={zoomConfig} tooltip={tooltipConfig} - transitionDuration={750} + transitionDuration={transitionDuration} onRender={onRender} noDataOverlay={ filteredData.length === 0 && processedOverlayData.length === 0 ? ( diff --git a/packages/app/src/components/ui/chart-buttons.tsx b/packages/app/src/components/ui/chart-buttons.tsx index 3f878e2f..14c0e2c0 100644 --- a/packages/app/src/components/ui/chart-buttons.tsx +++ b/packages/app/src/components/ui/chart-buttons.tsx @@ -1,7 +1,7 @@ 'use client'; import { track } from '@/lib/analytics'; -import { Download, FileSpreadsheet, Image, RotateCcw } from 'lucide-react'; +import { Download, FileSpreadsheet, Image, RotateCcw, Video } from 'lucide-react'; import { type ReactNode, useState } from 'react'; import { useChartExport } from '@/hooks/useChartExport'; @@ -24,6 +24,8 @@ interface ChartButtonsProps { hideImageExport?: boolean; /** Optional callback to export chart data as CSV */ onExportCsv?: () => void; + /** Optional callback to open the MP4 export preview (e.g., replay modal) */ + onExportMp4?: () => void; /** Human-readable base name for exported files (e.g. "DeepSeek-R1_throughput_interactivity"). Falls back to chartId. */ exportFileName?: string; /** @@ -51,6 +53,7 @@ export function ChartButtons({ hideZoomReset, hideImageExport, onExportCsv, + onExportMp4, exportFileName, leadingControls, className, @@ -77,6 +80,12 @@ export function ChartButtons({ window.dispatchEvent(new CustomEvent('inferencex:action')); }; + const handleExportMp4 = () => { + setPopoverOpen(false); + track(`${analyticsPrefix}_mp4_preview_opened`); + onExportMp4?.(); + }; + return (
{leadingControls} - {onExportCsv ? ( + {onExportCsv || onExportMp4 ? ( - + {onExportCsv && ( + + )} + {onExportMp4 && ( + + )} ) : ( diff --git a/packages/app/src/lib/d3-chart/D3Chart/D3Chart.tsx b/packages/app/src/lib/d3-chart/D3Chart/D3Chart.tsx index 5d5b6d2b..2a18b9cd 100644 --- a/packages/app/src/lib/d3-chart/D3Chart/D3Chart.tsx +++ b/packages/app/src/lib/d3-chart/D3Chart/D3Chart.tsx @@ -1,12 +1,16 @@ 'use client'; import React, { useImperativeHandle, useRef } from 'react'; +import * as d3 from 'd3'; import { D3ChartWrapper } from '@/components/ui/d3-chart-wrapper'; import { useChartTooltipHandlers } from '@/hooks/useChartTooltipHandlers'; import { useChartZoom } from '@/hooks/useChartZoom'; import { useResponsiveChartDimensions } from '@/hooks/useResponsiveChartDimensions'; +import type { ContinuousScale } from '../types'; + +import { isBandScale, type BuiltScale } from './scale-builders'; import type { D3ChartHandle, D3ChartProps } from './types'; import { useD3ChartRenderer } from './useD3ChartRenderer'; @@ -38,6 +42,7 @@ function D3ChartInner( ) { const svgRef = useRef(null); const tooltipRef = useRef(null); + const scalesRef = useRef<{ xScale: BuiltScale; yScale: BuiltScale } | null>(null); const { dimensions, setContainerRef } = useResponsiveChartDimensions({ height }); @@ -73,6 +78,19 @@ function D3ChartInner( pinTooltip: pinTooltip as (point: unknown, isOverlay?: boolean) => void, getSvgElement: () => svgRef.current, getTooltipElement: () => tooltipRef.current, + getScales: () => scalesRef.current, + refreshDataPositions: () => { + const svg = svgRef.current; + const scales = scalesRef.current; + if (!svg || !scales) return; + if (isBandScale(scales.xScale) || isBandScale(scales.yScale)) return; + const transform = d3.zoomTransform(svg); + const curX = transform.rescaleX(scales.xScale as ContinuousScale); + const curY = transform.rescaleY(scales.yScale as ContinuousScale); + d3.select(svg) + .selectAll('.dot-group') + .attr('transform', (d) => `translate(${curX(d.x)},${curY(d.y)})`); + }, }), [dismissTooltip, hideTooltipElements, pinnedPoint, pinnedPointIsOverlay, isPinned, pinTooltip], ); @@ -104,6 +122,7 @@ function D3ChartInner( svgRef, tooltipRef, dimensions, + scalesRef, setupZoom, zoomTransformRef, isPinned, diff --git a/packages/app/src/lib/d3-chart/D3Chart/types.ts b/packages/app/src/lib/d3-chart/D3Chart/types.ts index c753ea84..3062784e 100644 --- a/packages/app/src/lib/d3-chart/D3Chart/types.ts +++ b/packages/app/src/lib/d3-chart/D3Chart/types.ts @@ -2,6 +2,7 @@ import type * as d3 from 'd3'; import type { ChartLayout, ChartMargin, ContinuousScale } from '../types'; import type { AnyScale } from '../chart-update'; +import type { BuiltScale } from './scale-builders'; import type { BarConfig } from '../layers/bars'; import type { HorizontalBarConfig } from '../layers/horizontal-bars'; import type { PointConfig } from '../layers/points'; @@ -224,6 +225,15 @@ export interface D3ChartHandle { pinTooltip: (point: unknown, isOverlay?: boolean) => void; getSvgElement: () => SVGSVGElement | null; getTooltipElement: () => HTMLDivElement | null; + /** Current x/y scales (post-render). Null before the first render. */ + getScales: () => { xScale: BuiltScale; yScale: BuiltScale } | null; + /** + * Re-apply `.dot-group` transforms from currently-bound datum `x`/`y` values using + * current scales (zoom-aware). Use to drive imperative position updates outside the + * normal React render path (e.g. replay animation frames). No-op when scales are + * unavailable or when neither scale is continuous. + */ + refreshDataPositions: () => void; } // --------------------------------------------------------------------------- diff --git a/packages/app/src/lib/d3-chart/D3Chart/useD3ChartRenderer.ts b/packages/app/src/lib/d3-chart/D3Chart/useD3ChartRenderer.ts index 884dfa15..c25944ca 100644 --- a/packages/app/src/lib/d3-chart/D3Chart/useD3ChartRenderer.ts +++ b/packages/app/src/lib/d3-chart/D3Chart/useD3ChartRenderer.ts @@ -14,6 +14,8 @@ interface RendererDeps { svgRef: React.RefObject; tooltipRef: React.RefObject; dimensions: { width: number; height: number }; + /** Owned by D3Chart so the imperative handle can read current scales. */ + scalesRef: React.MutableRefObject<{ xScale: BuiltScale; yScale: BuiltScale } | null>; setupZoom: ( svg: d3.Selection, width: number, @@ -75,6 +77,7 @@ export function useD3ChartRenderer(props: D3ChartProps, deps: RendererDeps svgRef, tooltipRef, dimensions, + scalesRef, setupZoom, zoomTransformRef, isPinned, @@ -84,8 +87,8 @@ export function useD3ChartRenderer(props: D3ChartProps, deps: RendererDeps attachHandlers, } = deps; - // Store scales in ref so zoom handler can read them without stale closures - const scalesRef = useRef<{ xScale: BuiltScale; yScale: BuiltScale } | null>(null); + // scalesRef is owned by D3Chart so the imperative handle can read it; the renderer + // writes the freshly-built scales into it on every render below. const layoutRef = useRef(null); const prevDataRef = useRef(data); const prevScalesRef = useRef({ xScaleConfig, yScaleConfig }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f95fe60..14505e57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,6 +125,9 @@ importers: lucide-react: specifier: ^1.14.0 version: 1.14.0(react@19.2.6) + mp4-muxer: + specifier: ^5.2.2 + version: 5.2.2 next: specifier: ^16.2.6 version: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -2270,6 +2273,9 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/dom-webcodecs@0.1.18': + resolution: {integrity: sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -2346,6 +2352,9 @@ packages: '@types/webxr@0.5.24': resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@types/wicg-file-system-access@2020.9.8': + resolution: {integrity: sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -4424,6 +4433,10 @@ packages: engines: {node: '>=18'} hasBin: true + mp4-muxer@5.2.2: + resolution: {integrity: sha512-dhozjTywI0h2qFzeShagt8YYw811fh1XlwiDCE2f6Aeqf6xG2CyuShoSa5E0AZDO8pPF0JOZ3wOmWBNWIGdSpQ==} + deprecated: This library is superseded by Mediabunny. Please migrate to it. + ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} @@ -7319,6 +7332,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/dom-webcodecs@0.1.18': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -7397,6 +7412,8 @@ snapshots: '@types/webxr@0.5.24': {} + '@types/wicg-file-system-access@2020.9.8': {} + '@types/yauzl@2.10.3': dependencies: '@types/node': 25.6.2 @@ -9203,7 +9220,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 25.6.2 + '@types/node': 25.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -9923,6 +9940,11 @@ snapshots: requirejs: 2.3.8 requirejs-config-file: 4.0.0 + mp4-muxer@5.2.2: + dependencies: + '@types/dom-webcodecs': 0.1.18 + '@types/wicg-file-system-access': 2020.9.8 + ms@2.1.2: {} ms@2.1.3: {}