From f70f1dfad2bbda069ea9d82fd5fba1f37d124cff Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Thu, 7 May 2026 14:17:27 -0500 Subject: [PATCH 01/31] feat(replay): replay over time + MP4 export --- .../app/cypress/e2e/inference-replay.cy.ts | 59 ++ packages/app/package.json | 1 + .../inference/replay/ReplayController.ts | 525 ++++++++++++++++++ .../inference/replay/ReplayLauncher.tsx | 77 +++ .../inference/replay/ReplayLegend.tsx | 61 ++ .../inference/replay/ReplayPanel.tsx | 453 +++++++++++++++ .../__tests__/buildReplayTimeline.test.ts | 191 +++++++ .../__tests__/interpolateAtTime.test.ts | 40 ++ .../inference/replay/buildReplayTimeline.ts | 249 +++++++++ .../components/inference/replay/exportMp4.ts | 320 +++++++++++ .../inference/replay/interpolateAtTime.ts | 63 +++ .../components/inference/ui/ChartDisplay.tsx | 18 + .../app/src/lib/d3-chart/D3Chart/D3Chart.tsx | 19 + .../app/src/lib/d3-chart/D3Chart/types.ts | 10 + .../d3-chart/D3Chart/useD3ChartRenderer.ts | 7 +- pnpm-lock.yaml | 22 + 16 files changed, 2113 insertions(+), 2 deletions(-) create mode 100644 packages/app/cypress/e2e/inference-replay.cy.ts create mode 100644 packages/app/src/components/inference/replay/ReplayController.ts create mode 100644 packages/app/src/components/inference/replay/ReplayLauncher.tsx create mode 100644 packages/app/src/components/inference/replay/ReplayLegend.tsx create mode 100644 packages/app/src/components/inference/replay/ReplayPanel.tsx create mode 100644 packages/app/src/components/inference/replay/__tests__/buildReplayTimeline.test.ts create mode 100644 packages/app/src/components/inference/replay/__tests__/interpolateAtTime.test.ts create mode 100644 packages/app/src/components/inference/replay/buildReplayTimeline.ts create mode 100644 packages/app/src/components/inference/replay/exportMp4.ts create mode 100644 packages/app/src/components/inference/replay/interpolateAtTime.ts 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..8675581d --- /dev/null +++ b/packages/app/cypress/e2e/inference-replay.cy.ts @@ -0,0 +1,59 @@ +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('renders a Replay launcher under each scatter chart', () => { + cy.get('[data-testid^="replay-launcher-"]').should('have.length.at.least', 1); + cy.get('[data-testid^="replay-launcher-"]').first().should('contain', 'Replay'); + }); + + it('opens the replay panel when the launcher is clicked', () => { + cy.get('[data-testid="replay-launcher-chart-0"]').click(); + cy.get('[data-testid="replay-panel-chart-0"]').should('exist'); + // Either the loading message, the "not enough history" message, or the controls. + 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/.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/.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'); + cy.get('[data-testid="replay-speed-1x"]').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('closes the modal via the dialog close button', () => { + cy.get('body').then(($body) => { + if ($body.find('[data-testid="replay-panel-chart-0"]').length === 0) return; + // shadcn Dialog renders an X button inside the dialog content. + cy.get('[data-testid^="replay-dialog-"]').find('button').first().click(); + cy.get('[data-testid="replay-panel-chart-0"]').should('not.exist'); + cy.get('[data-testid="replay-launcher-chart-0"]').should('be.visible'); + }); + }); +}); diff --git a/packages/app/package.json b/packages/app/package.json index 6b1fe480..c1386aba 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -58,6 +58,7 @@ "gray-matter": "^4.0.3", "iwanthue": "^2.0.0", "lucide-react": "^1.14.0", + "mp4-muxer": "^5.2.2", "next": "^16.2.4", "next-mdx-remote": "^6.0.0", "next-themes": "^0.4.6", diff --git a/packages/app/src/components/inference/replay/ReplayController.ts b/packages/app/src/components/inference/replay/ReplayController.ts new file mode 100644 index 00000000..b60899d5 --- /dev/null +++ b/packages/app/src/components/inference/replay/ReplayController.ts @@ -0,0 +1,525 @@ +import * as d3 from 'd3'; + +import { formatLargeNumber, logTickFormat } from '@/lib/chart-rendering'; +import { + paretoFrontLowerLeft, + paretoFrontLowerRight, + paretoFrontUpperLeft, + paretoFrontUpperRight, +} from '@/lib/chart-utils'; +import { createLogoWatermark } from '@/lib/d3-chart/watermark'; + +import type { InferenceData } from '@/components/inference/types'; +import { getPointLabel } from '@/components/inference/utils/tooltipUtils'; + +import type { ReplayTimeline } from './buildReplayTimeline'; +import { interpolateAtStep } from './interpolateAtTime'; + +export type RooflineDirection = 'upper_right' | 'upper_left' | 'lower_left' | 'lower_right'; + +interface MutableConfig { + configId: string; + hwKey: string; + precision: string; + template: InferenceData; + visible: boolean; + x: number; + y: number; +} + +export interface ReplayControllerOptions { + /** SVG element the controller will own end-to-end. */ + svg: SVGSVGElement; + width: number; + height: number; + margin: { top: number; right: number; bottom: number; left: number }; + xLabel: string; + yLabel: string; + timeline: ReplayTimeline; + rooflineDirection: RooflineDirection; + /** Color for an hwKey. Read every tick (no closure freezing). */ + getColor: (hwKey: string) => string; + /** Whether an hwKey passes the user's legend filter. Read every tick. */ + isHwActive: (hwKey: string) => boolean; + /** "Optimal only" toggle. Read every tick. */ + isHideNonOptimal: () => boolean; + /** Log-scale toggle. Read every tick. */ + isLogScale: () => boolean; + /** Currently-selected precisions. Read every tick. */ + selectedPrecisions: () => readonly string[]; + /** Whether to suppress per-dot text labels. Read every tick. */ + hidePointLabels: () => boolean; + /** Use the longer TEP/EP/DPAEP label format vs. plain TP. */ + useAdvancedLabels: () => boolean; + /** Throttled ~10 Hz callback with the current observed-date label, fraction-of-playback, and step index. */ + onFrame?: (currentDate: string, fraction: number, stepIndex: number) => void; + /** Fired once when playback reaches the end. */ + onComplete?: () => void; +} + +const PARETO_FN: Record = { + upper_left: paretoFrontUpperLeft, + upper_right: paretoFrontUpperRight, + lower_left: paretoFrontLowerLeft, + lower_right: paretoFrontLowerRight, +}; + +const PAD_LINEAR = 0.08; +const PAD_LOG = 1.18; + +function padDomain(min: number, max: number, log: boolean): [number, number] { + if (!Number.isFinite(min) || !Number.isFinite(max)) return log ? [0.001, 1] : [0, 1]; + if (min === max) { + const pad = min === 0 ? 1 : Math.abs(min) * 0.1; + return [min - pad, max + pad]; + } + if (log) { + if (min <= 0) return [Math.max(0.001, max / 1000), max * PAD_LOG]; + return [min / PAD_LOG, max * PAD_LOG]; + } + const span = max - min; + const pad = span * PAD_LINEAR; + return [min >= 0 ? Math.max(0, min - pad) : min - pad, max + pad]; +} + +/** + * Self-contained replay chart. Builds its own SVG structure (clip-path, + * grid/axis groups, zoom group with dots + rooflines) once on construction, + * then redraws everything imperatively per tick — no React re-renders for + * axes, scales, or layers. The panel only owns control-bar state. + * + * Lifecycle: + * - constructor — builds structure, renders frame 0 + * - play() / pause() — toggle the rAF loop + * - seekToFraction(t) — jump to a position (paused) + * - renderFrame(t) — synchronous deterministic render (used by exporter) + * - setSpeed(n) — change playback multiplier + * - dispose() — cancel rAF, wipe the SVG + */ +export class ReplayController { + private opts: ReplayControllerOptions; + private innerWidth: number; + private innerHeight: number; + private rootGroup: d3.Selection; + private gridGroup: d3.Selection; + private xAxisGroup: d3.Selection; + private yAxisGroup: d3.Selection; + private rooflinesGroup: d3.Selection; + private dotsGroup: d3.Selection; + private dateOverlay: d3.Selection; + private configs: MutableConfig[]; + private fraction = 0; + private speed = 1; + private playing = false; + private rafId: number | null = null; + private lastTickAt = 0; + private lastBroadcastAt = 0; + + constructor(opts: ReplayControllerOptions) { + this.opts = opts; + this.innerWidth = Math.max(0, opts.width - opts.margin.left - opts.margin.right); + this.innerHeight = Math.max(0, opts.height - opts.margin.top - opts.margin.bottom); + + this.configs = opts.timeline.configs.map((c) => ({ + configId: c.configId, + hwKey: c.hwKey, + precision: c.precision, + template: c.template, + visible: false, + x: 0, + y: 0, + })); + + const svg = d3.select(opts.svg); + svg.selectAll('*').remove(); + svg.attr('width', opts.width).attr('height', opts.height); + + const chartHash = Math.random().toString(36).slice(2, 9); + const clipId = `replay-clip-${chartHash}`; + const defs = svg.append('defs'); + defs + .append('clipPath') + .attr('id', clipId) + .append('rect') + .attr('width', this.innerWidth) + .attr('height', this.innerHeight); + + // InferenceX logo watermark behind the data, matching the main charts. + createLogoWatermark( + svg, + defs, + opts.width, + opts.height, + this.innerWidth, + this.innerHeight, + opts.margin, + `replay-${chartHash}`, + ); + + this.rootGroup = svg + .append('g') + .attr('class', 'chart-root') + .attr('transform', `translate(${opts.margin.left},${opts.margin.top})`); + + this.gridGroup = this.rootGroup.append('g').attr('class', 'grid'); + this.xAxisGroup = this.rootGroup + .append('g') + .attr('class', 'x-axis') + .attr('transform', `translate(0,${this.innerHeight})`); + this.yAxisGroup = this.rootGroup.append('g').attr('class', 'y-axis'); + + svg + .append('text') + .attr('class', 'x-axis-label') + .attr('x', opts.margin.left + this.innerWidth / 2) + .attr('y', opts.height - 10) + .attr('text-anchor', 'middle') + .attr('font-size', '12px') + .text(opts.xLabel); + svg + .append('text') + .attr('class', 'y-axis-label') + .attr('transform', `translate(16,${opts.margin.top + this.innerHeight / 2}) rotate(-90)`) + .attr('text-anchor', 'middle') + .attr('font-size', '12px') + .text(opts.yLabel); + + const zoomGroup = this.rootGroup.append('g').attr('clip-path', `url(#${clipId})`); + this.rooflinesGroup = zoomGroup.append('g').attr('class', 'rooflines'); + this.dotsGroup = zoomGroup.append('g').attr('class', 'dots'); + + // Big date overlay rendered into the SVG so it shows in MP4 frames too. + this.dateOverlay = this.rootGroup + .append('text') + .attr('class', 'replay-date-overlay') + .attr('x', this.innerWidth - 8) + .attr('y', 28) + .attr('text-anchor', 'end') + .attr('font-size', '28px') + .attr('font-weight', '700') + .attr('fill', 'var(--foreground)') + .style('opacity', 0.85) + .style('font-variant-numeric', 'tabular-nums') + .text(''); + + this.renderCurrent(); + } + + private spanMs(): number { + // Total wall-clock duration at 1× speed. ~800 ms per observed step gives + // each transition room to read; capped at 30 s so very long histories + // still finish in a reasonable time. + const n = this.opts.timeline.dates.length; + if (n <= 1) return 1500; + return Math.min(30_000, Math.max(4500, n * 800)); + } + + private stepFloatAtFraction(t: number): number { + const n = this.opts.timeline.dates.length; + if (n <= 1) return 0; + const raw = Math.max(0, Math.min(1, t)) * (n - 1); + // Cubic ease-in-out per segment: dots and rooflines settle on observed + // dates and accelerate between them, instead of cruising at constant + // speed. The integer parts of `raw` are preserved (segment boundaries are + // still aligned with observed dates) — only the fractional part is eased. + 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; + } + + setSpeed(s: number): void { + this.speed = Math.max(0.1, Math.min(8, s)); + } + + getSpeed(): number { + return this.speed; + } + + /** Wall-clock duration of a full playback at the controller's current speed. */ + getDurationMs(): number { + return this.spanMs() / this.speed; + } + + isPlaying(): boolean { + return this.playing; + } + + getFraction(): number { + return this.fraction; + } + + play(): void { + if (this.playing) return; + this.playing = true; + if (this.fraction >= 1) this.fraction = 0; + this.lastTickAt = performance.now(); + this.scheduleTick(); + } + + pause(): void { + this.playing = false; + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + } + + seekToFraction(t: number): void { + this.pause(); + this.fraction = Math.max(0, Math.min(1, t)); + this.renderCurrent(); + this.broadcast(); + } + + /** Synchronous render at a logical fraction. Used by the MP4 exporter. */ + renderFrame(t: number): void { + this.fraction = Math.max(0, Math.min(1, t)); + this.renderCurrent(); + } + + dispose(): void { + this.pause(); + d3.select(this.opts.svg).selectAll('*').remove(); + } + + private scheduleTick(): void { + this.rafId = requestAnimationFrame(this.tick); + } + + private tick = (now: number): void => { + if (!this.playing) return; + const dt = now - this.lastTickAt; + this.lastTickAt = now; + this.fraction = Math.min(1, this.fraction + (dt / this.spanMs()) * this.speed); + + this.renderCurrent(); + + if (now - this.lastBroadcastAt > 100) { + this.lastBroadcastAt = now; + this.broadcast(); + } + + if (this.fraction >= 1) { + this.playing = false; + this.broadcast(); + this.opts.onComplete?.(); + return; + } + this.scheduleTick(); + }; + + private broadcast(): void { + const idxFloat = this.stepFloatAtFraction(this.fraction); + const step = Math.round(idxFloat); + const date = + this.opts.timeline.dates[Math.max(0, Math.min(this.opts.timeline.dates.length - 1, step))] ?? + ''; + this.opts.onFrame?.(date, this.fraction, step); + } + + private renderCurrent(): void { + const { + timeline, + isHwActive, + isHideNonOptimal, + isLogScale, + getColor, + rooflineDirection, + selectedPrecisions, + hidePointLabels, + useAdvancedLabels, + } = this.opts; + const idxFloat = this.stepFloatAtFraction(this.fraction); + + // 1. Interpolate per-config positions and compute the visible bounding box. + let xMin = Infinity; + let xMax = -Infinity; + let yMin = Infinity; + let yMax = -Infinity; + const visibleConfigs: MutableConfig[] = []; + const precisions = selectedPrecisions(); + + for (let i = 0; i < this.configs.length; i++) { + const orig = timeline.configs[i]; + const m = this.configs[i]; + const r = interpolateAtStep(orig.stepValues, idxFloat); + m.x = r.x; + m.y = r.y; + m.visible = r.visible && isHwActive(m.hwKey) && precisions.includes(m.precision); + if (m.visible) { + visibleConfigs.push(m); + if (m.x < xMin) xMin = m.x; + if (m.x > xMax) xMax = m.x; + if (m.y < yMin) yMin = m.y; + if (m.y > yMax) yMax = m.y; + } + } + + // 2. Domain + scales (recomputed every tick — that's the point). + const log = isLogScale(); + const xScale = log + ? d3 + .scaleLog() + .domain(padDomain(xMin, xMax, true)) + .range([0, this.innerWidth]) + .nice() + : d3 + .scaleLinear() + .domain(padDomain(xMin, xMax, false)) + .range([0, this.innerWidth]) + .nice(); + const yScale = log + ? d3 + .scaleLog() + .domain(padDomain(yMin, yMax, true)) + .range([this.innerHeight, 0]) + .nice() + : d3 + .scaleLinear() + .domain(padDomain(yMin, yMax, false)) + .range([this.innerHeight, 0]) + .nice(); + + // 3. Axes. + const xAxis = log + ? d3 + .axisBottom(xScale) + .ticks(6) + .tickFormat(logTickFormat(xScale as d3.ScaleLogarithmic)) + : d3 + .axisBottom(xScale) + .ticks(6) + .tickFormat((d) => formatLargeNumber(d as number)); + const yAxis = log + ? d3 + .axisLeft(yScale) + .ticks(5) + .tickFormat(logTickFormat(yScale as d3.ScaleLogarithmic)) + : d3 + .axisLeft(yScale) + .ticks(5) + .tickFormat((d) => formatLargeNumber(d as number)); + this.xAxisGroup.call(xAxis as any); + this.yAxisGroup.call(yAxis as any); + + // 4. Grid lines. + const xTicks = xScale.ticks(6); + const yTicks = yScale.ticks(5); + // No inline stroke — the global stylesheet styles `.chart-root .grid line` + // with `stroke: var(--border-alt)` so replay gridlines match the rest of + // the dashboard. Inline attrs would defeat that. + const gridX = this.gridGroup.selectAll('.grid-x').data(xTicks); + gridX.exit().remove(); + gridX + .enter() + .append('line') + .attr('class', 'grid-x') + .merge(gridX as any) + .attr('x1', (d: number) => xScale(d) ?? 0) + .attr('x2', (d: number) => xScale(d) ?? 0) + .attr('y1', 0) + .attr('y2', this.innerHeight); + const gridY = this.gridGroup.selectAll('.grid-y').data(yTicks); + gridY.exit().remove(); + gridY + .enter() + .append('line') + .attr('class', 'grid-y') + .merge(gridY as any) + .attr('x1', 0) + .attr('x2', this.innerWidth) + .attr('y1', (d: number) => yScale(d) ?? 0) + .attr('y2', (d: number) => yScale(d) ?? 0); + + // 5. Pareto + rooflines. + const byHw = new Map(); + for (const c of visibleConfigs) { + let bucket = byHw.get(c.hwKey); + if (!bucket) { + bucket = []; + byHw.set(c.hwKey, bucket); + } + bucket.push(c); + } + const paretoFn = PARETO_FN[rooflineDirection]; + interface RoofEntry { + hw: string; + pts: { x: number; y: number; src: MutableConfig }[]; + } + const rooflines: RoofEntry[] = []; + const optimalSet = new Set(); + for (const [hw, pts] of byHw) { + if (pts.length < 2) continue; + const front = paretoFn( + pts.map((p) => ({ x: p.x, y: p.y, src: p }) as unknown as InferenceData), + ) as unknown as { x: number; y: number; src: MutableConfig }[]; + front.sort((a, b) => a.x - b.x); + if (front.length >= 2) { + rooflines.push({ hw, pts: front }); + for (const p of front) optimalSet.add(p.src); + } + } + const lineGen = d3 + .line<{ x: number; y: number }>() + .x((d) => xScale(d.x) ?? 0) + .y((d) => yScale(d.y) ?? 0) + .curve(d3.curveMonotoneX); + + const roofSel = this.rooflinesGroup + .selectAll('.replay-roofline') + .data(rooflines, (d) => d.hw); + roofSel.exit().remove(); + const roofEnter = roofSel + .enter() + .append('path') + .attr('class', 'replay-roofline') + .attr('fill', 'none'); + roofEnter + .merge(roofSel as any) + .attr('stroke', (d: RoofEntry) => getColor(d.hw)) + .attr('stroke-width', 2) + .attr('d', (d: RoofEntry) => lineGen(d.pts) ?? ''); + + // 6. Dots. + const hideNonOptimal = isHideNonOptimal(); + const dotData = visibleConfigs.filter((c) => !hideNonOptimal || optimalSet.has(c)); + const dotSel = this.dotsGroup + .selectAll('.replay-dot-group') + .data(dotData, (d) => d.configId); + dotSel.exit().remove(); + const dotEnter = dotSel.enter().append('g').attr('class', 'replay-dot-group'); + dotEnter.append('circle').attr('class', 'replay-dot').attr('r', 5); + dotEnter + .append('text') + .attr('class', 'replay-dot-label') + .attr('text-anchor', 'middle') + .attr('font-size', '10px') + .attr('pointer-events', 'none') + .attr('fill', 'var(--foreground)') + .attr('dy', -8); + + const merged = dotEnter.merge(dotSel as any); + merged.attr('transform', (d: MutableConfig) => `translate(${xScale(d.x)},${yScale(d.y)})`); + merged.select('.replay-dot').attr('fill', (d: MutableConfig) => getColor(d.hwKey)); + + // 7. Labels. + const hide = hidePointLabels(); + const advanced = useAdvancedLabels(); + merged + .select('.replay-dot-label') + .style('display', hide ? 'none' : 'block') + .text((d: MutableConfig) => + hide ? '' : advanced ? getPointLabel(d.template) : String(d.template.tp), + ); + + // 8. Date overlay — rendered into the SVG so it shows in MP4 frames too. + const dates = timeline.dates; + if (dates.length > 0) { + const stepRound = Math.max(0, Math.min(dates.length - 1, Math.round(idxFloat))); + this.dateOverlay.text(dates[stepRound]); + } else { + this.dateOverlay.text(''); + } + } +} 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..0f57a8fd --- /dev/null +++ b/packages/app/src/components/inference/replay/ReplayLauncher.tsx @@ -0,0 +1,77 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import { useState } from 'react'; +import { Film } from 'lucide-react'; + +import type { ChartDefinition } from '@/components/inference/types'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { Skeleton } from '@/components/ui/skeleton'; +import { track } from '@/lib/analytics'; + +const ReplayPanel = dynamic(() => import('./ReplayPanel'), { + ssr: false, + loading: () => , +}); + +interface ReplayLauncherProps { + parentChartId: string; + chartDefinition: ChartDefinition; + yLabel: string; + xLabel: string; +} + +/** + * Tiny eager button that, on first click, dynamically imports the replay panel + * and mounts it inside a modal Dialog. Keeps mp4-muxer, html-to-image, and the + * replay controller out of the main inference bundle until a user opts in. + */ +export default function ReplayLauncher({ + parentChartId, + chartDefinition, + yLabel, + xLabel, +}: ReplayLauncherProps) { + const [open, setOpen] = useState(false); + + const handleOpen = () => { + setOpen(true); + track('inference_replay_opened', { + chartId: parentChartId, + chartType: chartDefinition.chartType, + }); + }; + + return ( + <> +
+ +
+ + + {open && ( + + )} + + + + ); +} diff --git a/packages/app/src/components/inference/replay/ReplayLegend.tsx b/packages/app/src/components/inference/replay/ReplayLegend.tsx new file mode 100644 index 00000000..9b3bcbc3 --- /dev/null +++ b/packages/app/src/components/inference/replay/ReplayLegend.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { cn } from '@/lib/utils'; + +export interface ReplayLegendItem { + hwKey: string; + label: string; + color: string; + active: boolean; +} + +interface ReplayLegendProps { + items: ReplayLegendItem[]; + onToggle: (hwKey: string) => void; +} + +/** + * Compact list-style legend for the replay panel. Active-first sort, fixed + * narrow width, no expand/search/precision-shape chrome — just colored + * swatch + GPU label so both the live preview and the rasterized MP4 frame + * stay tight. + */ +export default function ReplayLegend({ items, onToggle }: ReplayLegendProps) { + const sorted = [...items].toSorted((a, b) => { + if (a.active !== b.active) return a.active ? -1 : 1; + return a.label.localeCompare(b.label); + }); + + return ( +
    + {sorted.map((item) => ( +
  • + +
  • + ))} +
+ ); +} 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..5d6a3f91 --- /dev/null +++ b/packages/app/src/components/inference/replay/ReplayPanel.tsx @@ -0,0 +1,453 @@ +'use client'; + +import { Pause, Play, RotateCcw, Video } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { sequenceToIslOsl } from '@semianalysisai/inferencex-constants'; + +import { useInference } from '@/components/inference/InferenceContext'; +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 { useThemeColors } from '@/hooks/useThemeColors'; +import { track } from '@/lib/analytics'; +import { getHardwareConfig, getModelSortIndex } from '@/lib/constants'; +import { cn, getDisplayLabel } from '@/lib/utils'; + +import { buildReplayTimeline } from './buildReplayTimeline'; +import ReplayLegend, { type ReplayLegendItem } from './ReplayLegend'; +import { ReplayController, type RooflineDirection } from './ReplayController'; + +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_HEIGHT = 480; +const REPLAY_MARGIN = { top: 20, right: 20, bottom: 56, left: 64 }; + +/** + * Lazy-loaded replay panel. The SVG is fully driven by `ReplayController` — + * React only manages the controls bar, fetch state, and the small legend. + * Filter values are read by the controller through ref-based getters every + * tick so toggles take effect immediately without rebuilding the chart. + */ +export default function ReplayPanel({ + parentChartId, + chartDefinition, + yLabel, + xLabel, +}: ReplayPanelProps) { + const inference = useInference(); + const { + selectedModel, + selectedSequence, + selectedYAxisMetric, + selectedXAxisMetric, + selectedE2eXAxisMetric, + selectedPrecisions, + activeHwTypes, + toggleHwType, + highContrast, + logScale, + hideNonOptimal, + hidePointLabels, + useAdvancedLabels, + } = inference; + + const { isl = 0, osl = 0 } = sequenceToIslOsl(selectedSequence) ?? {}; + const history = useBenchmarkHistory(selectedModel, isl, osl); + + const effectiveX = + chartDefinition.chartType === 'e2e' ? selectedE2eXAxisMetric : selectedXAxisMetric; + + const timeline = useMemo(() => { + if (!history.data) return null; + return buildReplayTimeline( + history.data, + chartDefinition, + selectedYAxisMetric, + effectiveX ?? null, + selectedPrecisions, + ); + }, [history.data, chartDefinition, selectedYAxisMetric, effectiveX, selectedPrecisions]); + + const hwKeys = useMemo( + () => (timeline ? [...new Set(timeline.configs.map((c) => c.hwKey))] : []), + [timeline], + ); + const { resolveColor, getCssColor } = useThemeColors({ + highContrast, + identifiers: hwKeys, + activeKeys: hwKeys, + }); + const getColor = useCallback( + (hwKey: string) => getCssColor(resolveColor(hwKey)), + [getCssColor, resolveColor], + ); + + // Refs — controller reads these every tick. + const activeHwTypesRef = useRef(activeHwTypes); + const hideNonOptimalRef = useRef(hideNonOptimal); + const logScaleRef = useRef(logScale); + const selectedPrecisionsRef = useRef(selectedPrecisions); + const hidePointLabelsRef = useRef(hidePointLabels); + const useAdvancedLabelsRef = useRef(useAdvancedLabels); + const getColorRef = useRef(getColor); + activeHwTypesRef.current = activeHwTypes; + hideNonOptimalRef.current = hideNonOptimal; + logScaleRef.current = logScale; + selectedPrecisionsRef.current = selectedPrecisions; + hidePointLabelsRef.current = hidePointLabels; + useAdvancedLabelsRef.current = useAdvancedLabels; + getColorRef.current = getColor; + + const svgRef = useRef(null); + const controllerRef = useRef(null); + const observerRef = useRef(null); + const [width, setWidth] = useState(0); + + const [playing, setPlaying] = useState(false); + const [fraction, setFraction] = useState(0); + const [speed, setSpeed] = useState(1); + const [currentDate, setCurrentDate] = useState(''); + const [isExporting, setIsExporting] = useState(false); + const [exportProgress, setExportProgress] = useState(null); + + // Callback ref — runs whenever the chart container element mounts/unmounts, + // including after the panel transitions out of its loading state. A plain + // useEffect with `[]` deps would have fired before the chart div existed. + const setContainerEl = useCallback((el: HTMLDivElement | null) => { + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + if (!el) { + setWidth(0); + return; + } + const initial = el.getBoundingClientRect().width; + if (initial > 0) setWidth(Math.floor(initial)); + const ro = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + const w = entry.contentRect.width; + if (w > 0) setWidth(Math.floor(w)); + }); + ro.observe(el); + observerRef.current = ro; + }, []); + + useEffect( + () => () => { + observerRef.current?.disconnect(); + observerRef.current = null; + }, + [], + ); + + const rooflineDirection = + (chartDefinition[ + `${selectedYAxisMetric}_roofline` as keyof ChartDefinition + ] as RooflineDirection) ?? 'upper_left'; + + // Reset playhead state when the timeline changes (model/sequence/metric switch). + useEffect(() => { + setFraction(0); + setCurrentDate(timeline?.dates[0] ?? ''); + setPlaying(false); + }, [timeline]); + + // Build / rebuild the controller when timeline or width changes. Filter + // values flow through refs so mid-playback toggles never reach this effect. + useEffect(() => { + if (!timeline || timeline.configs.length === 0) return; + if (!svgRef.current || width <= 0) return; + + const controller = new ReplayController({ + svg: svgRef.current, + width, + height: REPLAY_HEIGHT, + margin: REPLAY_MARGIN, + xLabel, + yLabel, + timeline, + rooflineDirection, + getColor: (hw) => getColorRef.current(hw), + isHwActive: (hw) => activeHwTypesRef.current.has(hw), + isHideNonOptimal: () => hideNonOptimalRef.current, + isLogScale: () => logScaleRef.current, + selectedPrecisions: () => selectedPrecisionsRef.current, + hidePointLabels: () => hidePointLabelsRef.current, + useAdvancedLabels: () => useAdvancedLabelsRef.current, + onFrame: (date, frac) => { + setCurrentDate(date); + setFraction(frac); + }, + onComplete: () => setPlaying(false), + }); + controllerRef.current = controller; + controller.setSpeed(speed); + controller.renderFrame(fraction); + return () => { + controller.dispose(); + controllerRef.current = null; + }; + // We deliberately exclude `speed`, `fraction`, and `getColor` — speed/fraction + // live on the controller, getColor is read through a ref each tick. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [timeline, width, xLabel, yLabel, rooflineDirection]); + + useEffect(() => { + controllerRef.current?.setSpeed(speed); + }, [speed]); + + // Repaint after a filter toggle when paused (controller already picks up + // refs on the next tick when playing). + useEffect(() => { + const c = controllerRef.current; + if (!c) return; + if (!c.isPlaying()) c.renderFrame(c.getFraction()); + }, [ + activeHwTypes, + hideNonOptimal, + logScale, + hidePointLabels, + useAdvancedLabels, + selectedPrecisions, + ]); + + const handlePlayPause = useCallback(() => { + const c = controllerRef.current; + if (!c) return; + if (c.isPlaying()) { + c.pause(); + setPlaying(false); + track('inference_replay_paused', { fraction: c.getFraction() }); + } else { + c.play(); + setPlaying(true); + track('inference_replay_started', { speed }); + } + }, [speed]); + + const handleScrub = useCallback((value: number) => { + const c = controllerRef.current; + if (!c) return; + c.seekToFraction(value); + setFraction(value); + setPlaying(false); + track('inference_replay_scrubbed', { fraction: value }); + }, []); + + const handleSpeedChange = useCallback((v: number) => { + setSpeed(v); + track('inference_replay_speed_changed', { speed: v }); + }, []); + + const handleReset = useCallback(() => { + const c = controllerRef.current; + if (!c) return; + c.seekToFraction(0); + setFraction(0); + setPlaying(false); + setCurrentDate(timeline?.dates[0] ?? ''); + }, [timeline]); + + const handleExportMp4 = useCallback(async () => { + if (!timeline || !controllerRef.current) return; + setIsExporting(true); + setExportProgress(0); + track('inference_replay_export_started', { + model: selectedModel, + chartType: chartDefinition.chartType, + }); + try { + const { exportReplayMp4 } = await import('./exportMp4'); + // Output duration tracks the controller's current playback speed: 1× → ~spanMs, + // 2× → half that, 0.25× → 4×. Capped at 60 s so rare extreme settings don't + // produce hundred-megabyte files. + const durationSec = Math.max(2, Math.min(60, controllerRef.current.getDurationMs() / 1000)); + await exportReplayMp4({ + captureRootId: `replay-panel-${parentChartId}`, + controller: controllerRef.current, + fileName: `InferenceX_${selectedModel}_${chartDefinition.chartType}_replay`, + durationSec, + onProgress: (p) => setExportProgress(p), + }); + track('inference_replay_export_completed', { + model: selectedModel, + chartType: chartDefinition.chartType, + }); + } catch (error) { + console.error('MP4 export failed', error); + const message = error instanceof Error ? error.message : 'Export failed.'; + alert( + `MP4 export failed: ${message}\n\nIf you're not on Chrome, try Chrome — MP4 export uses WebCodecs, which may be unavailable in other browsers.`, + ); + track('inference_replay_export_failed', { reason: message }); + } finally { + setIsExporting(false); + setExportProgress(null); + controllerRef.current?.renderFrame(controllerRef.current.getFraction()); + } + }, [chartDefinition.chartType, parentChartId, selectedModel, timeline]); + + const legendItems = useMemo(() => { + if (!timeline) return []; + const seen = new Set(); + const items: ReplayLegendItem[] = []; + for (const c of timeline.configs) { + if (seen.has(c.hwKey)) continue; + seen.add(c.hwKey); + const hwConfig = getHardwareConfig(c.hwKey); + items.push({ + hwKey: c.hwKey, + label: getDisplayLabel(hwConfig), + color: getCssColor(resolveColor(c.hwKey)), + active: activeHwTypes.has(c.hwKey), + }); + } + return items.toSorted( + (a, b) => + getModelSortIndex(a.hwKey) - getModelSortIndex(b.hwKey) || a.label.localeCompare(b.label), + ); + }, [timeline, activeHwTypes, resolveColor, getCssColor]); + + if (history.isLoading || !timeline) { + return ( +
+

Replay

+

Loading benchmark history…

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

Replay

+

+ 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 +

+
+ +
+
+ +
+ {legendItems.length > 0 && ( +
+ toggleHwType(hw)} /> +
+ )} +
+ +
+ + + handleScrub(Number(e.target.value) / 1000)} + className="flex-1 min-w-[120px] h-2 cursor-pointer accent-foreground" + aria-label="Replay timeline" + data-testid="replay-scrubber" + /> + + {currentDate} + + + +
+
+ ); +} 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__/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/buildReplayTimeline.ts b/packages/app/src/components/inference/replay/buildReplayTimeline.ts new file mode 100644 index 00000000..c8abb459 --- /dev/null +++ b/packages/app/src/components/inference/replay/buildReplayTimeline.ts @@ -0,0 +1,249 @@ +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]`. `visible=false` for steps before the config's + * first observation; sticky-last carries the final 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]; +} + +/** + * Compute a tight bounding box at a given step from the configs whose hwKey + * passes `hwFilter`. The replay panel calls this with `activeHwTypes` so axes + * shrink to fit the currently-selected GPU(s) rather than the full timeline + * fleet. + * + * Pass `() => true` to disable filtering and get the full visible domain. + */ +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]; +}; + +/** + * Resolve which x-axis field a chart definition + selected metric should use. + * Mirrors the logic in `useChartData` and `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; +} + +/** + * Build the per-config history timeline for a replay session. + * + * For every (config_id) seen in `rows`, produce a sorted observation list of + * (dateMs, x, y) using the same metric resolution the live chart uses. Returns + * the list of distinct dates (ascending) and a global x/y domain spanning the + * whole history so axes can stay pinned during playback. + * + * Filtering matches the chart: rows whose precision is not in + * `selectedPrecisions` are dropped, and rows missing the requested metric on + * the `InferenceData` shape are dropped. Empty input → empty timeline. + */ +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 as any)[xAxisField] 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; + + // Build per-step values: at step i, the config's value is its latest + // observation with dateMs <= dates[i]. Sticky-last carries forward. + 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..feaf7908 --- /dev/null +++ b/packages/app/src/components/inference/replay/exportMp4.ts @@ -0,0 +1,320 @@ +import type { ReplayController } from './ReplayController'; + +interface ExportOptions { + /** DOM id of the replay panel root to capture each frame. */ + captureRootId: string; + controller: ReplayController; + fileName: string; + fps?: number; + durationSec?: number; + bitrate?: number; + onProgress?: (fraction: number) => void; +} + +const CSS_VAR_RE = /var\(--([^)]+)\)/; +const WATERMARK_HEIGHT = 48; +const WATERMARK_TEXT = 'InferenceX — github.com/SemiAnalysisAI/InferenceX'; + +/** + * Bake `var(--*)` references inside an SVG subtree into resolved colors. + * Mutates the supplied root in place — must only be called on a clone, never + * on the live panel (otherwise the live UI would be stuck on baked colors and + * stop responding to theme switches after an export). + */ +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()); + } + } + } +} + +/** + * Copy each live element's computed text color onto the matching clone element + * as an inline style. html-to-image can't resolve `var(--muted-foreground)` and + * similar tokens used by Tailwind text utilities, so we bake the resolved + * colors directly. Mutates only the clone tree. + */ +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; + } +} + +/** + * Unconstrain the legend's outer scroll viewport so every item appears in the + * rasterized frame. The mini legend itself is already compact — we just need + * to drop the `max-h-[480px] overflow-y-auto` wrapper that engages scroll in + * the live preview. + */ +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')); + +function waitTwoFrames(): Promise { + return new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + resolve(); + }); + }); + }); +} + +/** 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; +} + +interface MuxerLike { + addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata): void; + finalize(): void; + target: { buffer: ArrayBuffer }; +} + +/** + * Render the replay timeline to MP4 (H.264) using WebCodecs + mp4-muxer. + * + * Per-frame "screenshot mode" capture: the live panel is cloned into an + * off-screen container, no-export controls are filtered out, CSS variables + * and computed text colors are baked onto the clone, the SVG is re-cloned + * each frame from the live chart so position mutations land in the export, + * and the final canvas is stamped with the InferenceX watermark bar. + * + * Crucially the LIVE panel is never modified — the user-visible UI keeps its + * normal interactive look while the encode loop runs against the clone. + * + * Falls back with a clear error when WebCodecs is unavailable (mainly Firefox + * without the experimental flag). + */ +export async function exportReplayMp4(opts: ExportOptions): Promise { + const { + captureRootId, + controller, + fileName, + fps = 30, + durationSec = 6, + bitrate = 6_000_000, + onProgress, + } = opts; + + if (typeof VideoEncoder === 'undefined' || typeof VideoFrame === 'undefined') { + throw new TypeError('WebCodecs is not available in this browser. Try Chrome.'); + } + + const livePanel = document.querySelector(`#${captureRootId}`); + if (!livePanel) throw new Error(`Replay panel "${captureRootId}" not found in the DOM.`); + + const [{ Muxer, ArrayBufferTarget }, { toCanvas }] = await Promise.all([ + import('mp4-muxer'), + import('@jpinsonneau/html-to-image'), + ]); + + controller.pause(); + + // 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: MuxerLike | null = null; + let encoder: VideoEncoder | null = null; + const totalFrames = Math.max(2, Math.floor(durationSec * fps)); + + try { + for (let i = 0; i < totalFrames; i++) { + const t = totalFrames === 1 ? 1 : i / (totalFrames - 1); + controller.renderFrame(t); + await waitTwoFrames(); + + // Per-frame clone: the controller mutates dot positions and the + // replay-roofline-layer paths in place 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); + + 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) { + outWidth = Math.max(2, Math.floor(watermarked.width / 2) * 2); + outHeight = Math.max(2, Math.floor(watermarked.height / 2) * 2); + const newMuxer = new Muxer({ + target: new ArrayBufferTarget(), + video: { codec: 'avc', width: outWidth, height: outHeight }, + fastStart: 'in-memory', + }) as unknown as MuxerLike; + const newEncoder = new VideoEncoder({ + output: (chunk, meta) => newMuxer.addVideoChunk(chunk, meta), + error: (e) => { + throw e; + }, + }); + 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); + fctx.drawImage( + watermarked, + 0, + 0, + Math.min(watermarked.width, outWidth), + Math.min(watermarked.height, outHeight), + 0, + 0, + Math.min(watermarked.width, outWidth), + Math.min(watermarked.height, outHeight), + ); + + const frame = new VideoFrame(fit, { timestamp: Math.round((i / fps) * 1_000_000) }); + encoder!.encode(frame, { keyFrame: i % fps === 0 }); + frame.close(); + + onProgress?.(i / (totalFrames - 1)); + } + + if (!muxer || !encoder) throw new Error('Encoder was never initialized.'); + await encoder.flush(); + encoder.close(); + muxer.finalize(); + + 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(); + URL.revokeObjectURL(url); + onProgress?.(1); + } finally { + 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..c7bf4b82 --- /dev/null +++ b/packages/app/src/components/inference/replay/interpolateAtTime.ts @@ -0,0 +1,63 @@ +/** + * Per-step value for a single config. Precomputed in `buildReplayTimeline` so + * the rAF loop can interpolate without re-scanning calendar history each tick. + * + * - `visible: false` → config has no observation by this step + * - `visible: true` → config has an observation by this step (sticky-last + * carries the value forward through later empty steps) + */ +export interface PerStepValue { + visible: boolean; + x: number; + y: number; +} + +export interface InterpolationResult { + visible: boolean; + x: number; + y: number; +} + +/** + * Resolve a config's (x, y, visible) at a logical step index by linearly + * interpolating between the bracketing per-step values. Step-indexed playback + * gives every observed date equal screen time and collapses out empty calendar + * gaps — the visual emphasis lands on actual benchmark events, not calendar + * months. + * + * Visibility transitions: + * - both invisible: stays invisible. + * - both visible: lerp x/y by `idxFloat - floor`. + * - invisible → visible (config appears in this segment): pop in at the + * destination position from the start of the segment so the new dot is + * immediately on the frontier instead of dragging across from (0,0). + * - visible → invisible (would only occur if a config disappears from the + * dataset, which the upstream sticky-last logic prevents): stays at the + * last visible value. + */ +export function interpolateAtStep( + stepValues: readonly PerStepValue[], + idxFloat: number, +): InterpolationResult { + 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/ui/ChartDisplay.tsx b/packages/app/src/components/inference/ui/ChartDisplay.tsx index 0f7fa75b..51c696f0 100644 --- a/packages/app/src/components/inference/ui/ChartDisplay.tsx +++ b/packages/app/src/components/inference/ui/ChartDisplay.tsx @@ -48,6 +48,7 @@ import ComparisonChangelog from './ComparisonChangelog'; import CustomCosts from './CustomCosts'; import CustomPowers from './CustomPowers'; import GPUGraph from './GPUGraph'; +import ReplayLauncher from '../replay/ReplayLauncher'; import TrendChart from './TrendChart'; const ModelArchitectureDiagram = dynamic(() => import('./ModelArchitectureDiagram'), { @@ -538,6 +539,23 @@ export default function ChartDisplay() { ); })()} + {getViewMode(graphIndex) === 'chart' && + !( + selectedDateRange.startDate && + selectedDateRange.endDate && + selectedGPUs.length > 0 + ) && ( + + )} 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 41dfa836..39911d9e 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 2d79eba0..39340738 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: lucide-react: specifier: ^1.14.0 version: 1.14.0(react@19.2.5) + mp4-muxer: + specifier: ^5.2.2 + version: 5.2.2 next: specifier: ^16.2.4 version: 16.2.4(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -2174,6 +2177,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==} @@ -2247,6 +2253,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==} @@ -3928,6 +3937,10 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + mp4-muxer@5.2.2: + resolution: {integrity: sha512-dhozjTywI0h2qFzeShagt8YYw811fh1XlwiDCE2f6Aeqf6xG2CyuShoSa5E0AZDO8pPF0JOZ3wOmWBNWIGdSpQ==} + deprecated: This library is superseded by Mediabunny. Please migrate to it. + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -6478,6 +6491,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 @@ -6552,6 +6567,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.0 @@ -8586,6 +8603,11 @@ snapshots: minipass@7.1.3: {} + mp4-muxer@5.2.2: + dependencies: + '@types/dom-webcodecs': 0.1.18 + '@types/wicg-file-system-access': 2020.9.8 + ms@2.1.3: {} nanoid@3.3.11: {} From 86d9a5222ebd9b0c26565eb6bfbcd847ee5957ec Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Tue, 12 May 2026 22:12:28 -0500 Subject: [PATCH 02/31] feat(replay): move replay launcher into chart export menu as MP4 option --- .../cypress/component/chart-buttons.cy.tsx | 38 ++ .../app/cypress/e2e/inference-replay.cy.ts | 28 +- .../inference/replay/ReplayLauncher.tsx | 78 ++-- .../inference/replay/ReplayPanel.tsx | 36 +- .../components/inference/ui/ChartDisplay.tsx | 426 +++++++++--------- .../app/src/components/ui/chart-buttons.tsx | 47 +- 6 files changed, 364 insertions(+), 289 deletions(-) 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 index 8675581d..1dd8d19e 100644 --- a/packages/app/cypress/e2e/inference-replay.cy.ts +++ b/packages/app/cypress/e2e/inference-replay.cy.ts @@ -1,3 +1,12 @@ +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) => { @@ -7,19 +16,23 @@ describe('Inference Replay', () => { cy.get('[data-testid="inference-chart-display"]').should('exist'); }); - it('renders a Replay launcher under each scatter chart', () => { - cy.get('[data-testid^="replay-launcher-"]').should('have.length.at.least', 1); - cy.get('[data-testid^="replay-launcher-"]').first().should('contain', 'Replay'); + 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 panel when the launcher is clicked', () => { - cy.get('[data-testid="replay-launcher-chart-0"]').click(); + it('opens the replay preview modal from the MP4 menu item', () => { + openReplayDialog(); cy.get('[data-testid="replay-panel-chart-0"]').should('exist'); // Either the loading message, the "not enough history" message, or the controls. 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/.test(text) || hasControls; + const hasMessage = /Loading benchmark history|Not enough history/u.test(text) || hasControls; expect(hasMessage).to.equal(true); }); }); @@ -28,7 +41,7 @@ describe('Inference Replay', () => { // 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/.test($panel.text()); + const hasEmpty = /Not enough history/u.test($panel.text()); expect(hasControls || hasEmpty).to.equal(true); }); @@ -53,7 +66,6 @@ describe('Inference Replay', () => { // shadcn Dialog renders an X button inside the dialog content. cy.get('[data-testid^="replay-dialog-"]').find('button').first().click(); cy.get('[data-testid="replay-panel-chart-0"]').should('not.exist'); - cy.get('[data-testid="replay-launcher-chart-0"]').should('be.visible'); }); }); }); diff --git a/packages/app/src/components/inference/replay/ReplayLauncher.tsx b/packages/app/src/components/inference/replay/ReplayLauncher.tsx index 0f57a8fd..e0a6de88 100644 --- a/packages/app/src/components/inference/replay/ReplayLauncher.tsx +++ b/packages/app/src/components/inference/replay/ReplayLauncher.tsx @@ -1,18 +1,18 @@ 'use client'; import dynamic from 'next/dynamic'; -import { useState } from 'react'; -import { Film } from 'lucide-react'; import type { ChartDefinition } from '@/components/inference/types'; -import { Button } from '@/components/ui/button'; -import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; import { Skeleton } from '@/components/ui/skeleton'; -import { track } from '@/lib/analytics'; + +// 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: () => , + loading: () => , }); interface ReplayLauncherProps { @@ -20,58 +20,40 @@ interface ReplayLauncherProps { chartDefinition: ChartDefinition; yLabel: string; xLabel: string; + open: boolean; + onOpenChange: (open: boolean) => void; } /** - * Tiny eager button that, on first click, dynamically imports the replay panel - * and mounts it inside a modal Dialog. Keeps mp4-muxer, html-to-image, and the - * replay controller out of the main inference bundle until a user opts in. + * Controlled dialog that mounts the replay panel lazily. Keeps mp4-muxer, + * html-to-image, and the replay controller out of the main inference bundle + * until the parent opens this dialog (typically from the export menu's MP4 + * entry). */ export default function ReplayLauncher({ parentChartId, chartDefinition, yLabel, xLabel, + open, + onOpenChange, }: ReplayLauncherProps) { - const [open, setOpen] = useState(false); - - const handleOpen = () => { - setOpen(true); - track('inference_replay_opened', { - chartId: parentChartId, - chartType: chartDefinition.chartType, - }); - }; - return ( - <> -
- -
- - - {open && ( - - )} - - - + + + Replay over time + {open && ( + + )} + + ); } diff --git a/packages/app/src/components/inference/replay/ReplayPanel.tsx b/packages/app/src/components/inference/replay/ReplayPanel.tsx index 5d6a3f91..dc15fe5d 100644 --- a/packages/app/src/components/inference/replay/ReplayPanel.tsx +++ b/packages/app/src/components/inference/replay/ReplayPanel.tsx @@ -327,21 +327,33 @@ export default function ReplayPanel({ if (history.isLoading || !timeline) { return ( -
-

Replay

-

Loading benchmark history…

+
+

Replay over time

+
+

Loading benchmark history…

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

Replay

-

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

+
+

Replay over time

+
+

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

+
); } @@ -442,9 +454,9 @@ export default function ReplayPanel({ >
diff --git a/packages/app/src/components/inference/ui/ChartDisplay.tsx b/packages/app/src/components/inference/ui/ChartDisplay.tsx index e4a8bced..c04cf386 100644 --- a/packages/app/src/components/inference/ui/ChartDisplay.tsx +++ b/packages/app/src/components/inference/ui/ChartDisplay.tsx @@ -159,6 +159,7 @@ export default function ChartDisplay() { } = useComparisonChangelogs(selectedGPUs, selectedDateRange, dateRangeAvailableDates); const [viewModes, setViewModes] = useState>({}); + const [replayOpen, setReplayOpen] = useState>({}); const getViewMode = (index: number): InferenceViewMode => viewModes[index] ?? 'chart'; const handleViewModeChange = (index: number, value: InferenceViewMode) => { setViewModes((prev) => ({ ...prev, [index]: value })); @@ -320,197 +321,191 @@ 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 + ? () => setReplayOpen((prev) => ({ ...prev, [graphIndex]: true })) + : 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 -

-
- )} -
- ); - })()} - {getViewMode(graphIndex) === 'chart' && - !( - selectedDateRange.startDate && - selectedDateRange.endDate && - selectedGPUs.length > 0 - ) && ( + ) : ( +
+ + {selectedGPUs.length > 0 && + (!selectedDateRange.startDate || !selectedDateRange.endDate) && ( +
+

+ Select a date range to view GPU comparison +

+
+ )} +
+ ); + })()} + {replayAvailable && ( setReplayOpen((prev) => ({ ...prev, [graphIndex]: o }))} /> )} -
-
-
- )); + + + + ); + }); return (
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 && ( + + )} ) : ( From 72b7aa9b7cfc35d69f974b9bf8ff14bd20ca63e8 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Tue, 12 May 2026 22:24:13 -0500 Subject: [PATCH 03/31] feat(replay): honor showLineLabels setting in replay/MP4 export --- .../inference/replay/ReplayController.ts | 140 +++++++++++++++++- .../inference/replay/ReplayPanel.tsx | 6 + 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/inference/replay/ReplayController.ts b/packages/app/src/components/inference/replay/ReplayController.ts index b60899d5..09cebd2f 100644 --- a/packages/app/src/components/inference/replay/ReplayController.ts +++ b/packages/app/src/components/inference/replay/ReplayController.ts @@ -7,7 +7,9 @@ import { paretoFrontUpperLeft, paretoFrontUpperRight, } from '@/lib/chart-utils'; +import { getHardwareConfig } from '@/lib/constants'; import { createLogoWatermark } from '@/lib/d3-chart/watermark'; +import { getDisplayLabel } from '@/lib/utils'; import type { InferenceData } from '@/components/inference/types'; import { getPointLabel } from '@/components/inference/utils/tooltipUtils'; @@ -51,6 +53,10 @@ export interface ReplayControllerOptions { hidePointLabels: () => boolean; /** Use the longer TEP/EP/DPAEP label format vs. plain TP. */ useAdvancedLabels: () => boolean; + /** Whether to render per-roofline hw labels along each line. Read every tick. */ + showLineLabels: () => boolean; + /** Used by the line-label placement algorithm to pick interactivity-vs-endpoint style. */ + chartType: 'e2e' | 'interactivity'; /** Throttled ~10 Hz callback with the current observed-date label, fraction-of-playback, and step index. */ onFrame?: (currentDate: string, fraction: number, stepIndex: number) => void; /** Fired once when playback reaches the end. */ @@ -106,6 +112,7 @@ export class ReplayController { private yAxisGroup: d3.Selection; private rooflinesGroup: d3.Selection; private dotsGroup: d3.Selection; + private lineLabelsGroup: d3.Selection; private dateOverlay: d3.Selection; private configs: MutableConfig[]; private fraction = 0; @@ -187,6 +194,7 @@ export class ReplayController { const zoomGroup = this.rootGroup.append('g').attr('clip-path', `url(#${clipId})`); this.rooflinesGroup = zoomGroup.append('g').attr('class', 'rooflines'); this.dotsGroup = zoomGroup.append('g').attr('class', 'dots'); + this.lineLabelsGroup = zoomGroup.append('g').attr('class', 'line-labels'); // Big date overlay rendered into the SVG so it shows in MP4 frames too. this.dateOverlay = this.rootGroup @@ -329,6 +337,8 @@ export class ReplayController { selectedPrecisions, hidePointLabels, useAdvancedLabels, + showLineLabels, + chartType, } = this.opts; const idxFloat = this.stepFloatAtFraction(this.fraction); @@ -513,7 +523,135 @@ export class ReplayController { hide ? '' : advanced ? getPointLabel(d.template) : String(d.template.tp), ); - // 8. Date overlay — rendered into the SVG so it shows in MP4 frames too. + // 8. Line labels — one label per hw roofline, placed along the line + // (interactivity charts use greedy collision avoidance, e2e/ttft uses + // endpoint labels with vertical de-overlap). Mirrors ScatterGraph. + interface LineLabel { + key: string; + label: string; + color: string; + x: number; + y: number; + visible: boolean; + } + const lineLabels: LineLabel[] = []; + if (showLineLabels() && rooflines.length > 0) { + const LABEL_H = 18; + const LABEL_W = 120; + if (chartType === 'interactivity') { + const placed: { x: number; y: number }[] = []; + const collides = (cx: number, cy: number) => + placed.some((p) => Math.abs(p.y - cy) < LABEL_H && Math.abs(p.x - cx) < LABEL_W); + const sorted = [...rooflines].toSorted( + (a, b) => (yScale(a.pts[0].y) ?? 0) - (yScale(b.pts[0].y) ?? 0), + ); + for (const entry of sorted) { + const pts = entry.pts; + const label = getDisplayLabel(getHardwareConfig(entry.hw)); + const candidates = [ + pts[Math.min(1, pts.length - 1)], + pts[Math.floor(pts.length / 2)], + pts[Math.max(0, Math.floor((pts.length * 2) / 3))], + pts.at(-1)!, + ]; + let foundPlacement = false; + for (const pt of candidates) { + const px = xScale(pt.x) ?? 0; + const py = yScale(pt.y) ?? 0; + if (!collides(px, py)) { + lineLabels.push({ + key: entry.hw, + label, + color: getColor(entry.hw), + x: px, + y: py, + visible: true, + }); + placed.push({ x: px, y: py }); + foundPlacement = true; + break; + } + } + if (!foundPlacement) { + const pt = pts[0]; + lineLabels.push({ + key: entry.hw, + label, + color: getColor(entry.hw), + x: xScale(pt.x) ?? 0, + y: yScale(pt.y) ?? 0, + visible: false, + }); + } + } + } else { + for (const entry of rooflines) { + const pt = entry.pts.at(-1)!; + lineLabels.push({ + key: entry.hw, + label: getDisplayLabel(getHardwareConfig(entry.hw)), + color: getColor(entry.hw), + x: xScale(pt.x) ?? 0, + y: yScale(pt.y) ?? 0, + visible: true, + }); + } + const yRange = yScale.range(); + const top = Math.min(yRange[0], yRange[1]) + LABEL_H; + const bottom = Math.max(yRange[0], yRange[1]) - LABEL_H; + lineLabels.sort((a, b) => a.y - b.y); + for (let pass = 0; pass < 5; pass++) { + for (let i = 1; i < lineLabels.length; i++) { + const overlap = lineLabels[i - 1].y + LABEL_H - lineLabels[i].y; + if (overlap > 0) { + const half = overlap / 2; + lineLabels[i - 1].y -= half; + lineLabels[i].y += half; + } + } + for (const l of lineLabels) { + l.y = Math.max(top, Math.min(bottom, l.y)); + } + } + } + } + + const labelSel = this.lineLabelsGroup + .selectAll('.replay-line-label') + .data(lineLabels, (d) => d.key); + labelSel.exit().remove(); + const labelEnter = labelSel + .enter() + .append('g') + .attr('class', 'replay-line-label') + .style('pointer-events', 'none'); + labelEnter.append('rect').attr('rx', 4).attr('ry', 4).attr('opacity', 0.95); + labelEnter + .append('text') + .attr('text-anchor', 'start') + .attr('dominant-baseline', 'central') + .attr('fill', 'white') + .attr('font-size', '10px') + .attr('font-weight', '600'); + const labelMerged = labelEnter.merge(labelSel as any); + labelMerged + .attr('transform', (d: LineLabel) => `translate(${d.x + 8},${d.y - 14})`) + .style('opacity', (d: LineLabel) => (d.visible ? 1 : 0)); + labelMerged.each(function (d: LineLabel) { + const g = d3.select(this); + const text = g.select('text').text(d.label); + const bbox = (text.node() as SVGTextElement).getBBox(); + const px = 5; + const py = 3; + g.select('rect') + .attr('x', bbox.x - px) + .attr('y', bbox.y - py) + .attr('width', bbox.width + px * 2) + .attr('height', bbox.height + py * 2) + .attr('fill', d.color); + }); + + // 9. Date overlay — rendered into the SVG so it shows in MP4 frames too. const dates = timeline.dates; if (dates.length > 0) { const stepRound = Math.max(0, Math.min(dates.length - 1, Math.round(idxFloat))); diff --git a/packages/app/src/components/inference/replay/ReplayPanel.tsx b/packages/app/src/components/inference/replay/ReplayPanel.tsx index dc15fe5d..2c3302ac 100644 --- a/packages/app/src/components/inference/replay/ReplayPanel.tsx +++ b/packages/app/src/components/inference/replay/ReplayPanel.tsx @@ -64,6 +64,7 @@ export default function ReplayPanel({ hideNonOptimal, hidePointLabels, useAdvancedLabels, + showLineLabels, } = inference; const { isl = 0, osl = 0 } = sequenceToIslOsl(selectedSequence) ?? {}; @@ -104,6 +105,7 @@ export default function ReplayPanel({ const selectedPrecisionsRef = useRef(selectedPrecisions); const hidePointLabelsRef = useRef(hidePointLabels); const useAdvancedLabelsRef = useRef(useAdvancedLabels); + const showLineLabelsRef = useRef(showLineLabels); const getColorRef = useRef(getColor); activeHwTypesRef.current = activeHwTypes; hideNonOptimalRef.current = hideNonOptimal; @@ -111,6 +113,7 @@ export default function ReplayPanel({ selectedPrecisionsRef.current = selectedPrecisions; hidePointLabelsRef.current = hidePointLabels; useAdvancedLabelsRef.current = useAdvancedLabels; + showLineLabelsRef.current = showLineLabels; getColorRef.current = getColor; const svgRef = useRef(null); @@ -191,6 +194,8 @@ export default function ReplayPanel({ selectedPrecisions: () => selectedPrecisionsRef.current, hidePointLabels: () => hidePointLabelsRef.current, useAdvancedLabels: () => useAdvancedLabelsRef.current, + showLineLabels: () => showLineLabelsRef.current, + chartType: chartDefinition.chartType === 'e2e' ? 'e2e' : 'interactivity', onFrame: (date, frac) => { setCurrentDate(date); setFraction(frac); @@ -225,6 +230,7 @@ export default function ReplayPanel({ logScale, hidePointLabels, useAdvancedLabels, + showLineLabels, selectedPrecisions, ]); From 9a14d05dfbd2ed35b0d3d7ac878a64f067ec2ca7 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Tue, 12 May 2026 22:56:16 -0500 Subject: [PATCH 04/31] chore: add unicode flag to CSS_VAR_RE regex --- packages/app/src/components/inference/replay/exportMp4.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/inference/replay/exportMp4.ts b/packages/app/src/components/inference/replay/exportMp4.ts index feaf7908..5a319f55 100644 --- a/packages/app/src/components/inference/replay/exportMp4.ts +++ b/packages/app/src/components/inference/replay/exportMp4.ts @@ -11,7 +11,7 @@ interface ExportOptions { onProgress?: (fraction: number) => void; } -const CSS_VAR_RE = /var\(--([^)]+)\)/; +const CSS_VAR_RE = /var\(--([^)]+)\)/u; const WATERMARK_HEIGHT = 48; const WATERMARK_TEXT = 'InferenceX — github.com/SemiAnalysisAI/InferenceX'; From 7e7703d26a604ea2f6a409d00988705d54bd8f95 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Wed, 13 May 2026 00:06:29 -0500 Subject: [PATCH 05/31] refactor(replay): drive ScatterGraph directly so all chart toggles flow through --- .../inference/replay/ReplayController.ts | 663 ------------------ .../inference/replay/ReplayLegend.tsx | 61 -- .../inference/replay/ReplayPanel.tsx | 373 ++++------ .../components/inference/replay/exportMp4.ts | 31 +- .../inference/replay/replayFrameData.ts | 54 ++ .../app/src/components/inference/types.ts | 6 + .../components/inference/ui/ScatterGraph.tsx | 3 +- 7 files changed, 221 insertions(+), 970 deletions(-) delete mode 100644 packages/app/src/components/inference/replay/ReplayController.ts delete mode 100644 packages/app/src/components/inference/replay/ReplayLegend.tsx create mode 100644 packages/app/src/components/inference/replay/replayFrameData.ts diff --git a/packages/app/src/components/inference/replay/ReplayController.ts b/packages/app/src/components/inference/replay/ReplayController.ts deleted file mode 100644 index 09cebd2f..00000000 --- a/packages/app/src/components/inference/replay/ReplayController.ts +++ /dev/null @@ -1,663 +0,0 @@ -import * as d3 from 'd3'; - -import { formatLargeNumber, logTickFormat } from '@/lib/chart-rendering'; -import { - paretoFrontLowerLeft, - paretoFrontLowerRight, - paretoFrontUpperLeft, - paretoFrontUpperRight, -} from '@/lib/chart-utils'; -import { getHardwareConfig } from '@/lib/constants'; -import { createLogoWatermark } from '@/lib/d3-chart/watermark'; -import { getDisplayLabel } from '@/lib/utils'; - -import type { InferenceData } from '@/components/inference/types'; -import { getPointLabel } from '@/components/inference/utils/tooltipUtils'; - -import type { ReplayTimeline } from './buildReplayTimeline'; -import { interpolateAtStep } from './interpolateAtTime'; - -export type RooflineDirection = 'upper_right' | 'upper_left' | 'lower_left' | 'lower_right'; - -interface MutableConfig { - configId: string; - hwKey: string; - precision: string; - template: InferenceData; - visible: boolean; - x: number; - y: number; -} - -export interface ReplayControllerOptions { - /** SVG element the controller will own end-to-end. */ - svg: SVGSVGElement; - width: number; - height: number; - margin: { top: number; right: number; bottom: number; left: number }; - xLabel: string; - yLabel: string; - timeline: ReplayTimeline; - rooflineDirection: RooflineDirection; - /** Color for an hwKey. Read every tick (no closure freezing). */ - getColor: (hwKey: string) => string; - /** Whether an hwKey passes the user's legend filter. Read every tick. */ - isHwActive: (hwKey: string) => boolean; - /** "Optimal only" toggle. Read every tick. */ - isHideNonOptimal: () => boolean; - /** Log-scale toggle. Read every tick. */ - isLogScale: () => boolean; - /** Currently-selected precisions. Read every tick. */ - selectedPrecisions: () => readonly string[]; - /** Whether to suppress per-dot text labels. Read every tick. */ - hidePointLabels: () => boolean; - /** Use the longer TEP/EP/DPAEP label format vs. plain TP. */ - useAdvancedLabels: () => boolean; - /** Whether to render per-roofline hw labels along each line. Read every tick. */ - showLineLabels: () => boolean; - /** Used by the line-label placement algorithm to pick interactivity-vs-endpoint style. */ - chartType: 'e2e' | 'interactivity'; - /** Throttled ~10 Hz callback with the current observed-date label, fraction-of-playback, and step index. */ - onFrame?: (currentDate: string, fraction: number, stepIndex: number) => void; - /** Fired once when playback reaches the end. */ - onComplete?: () => void; -} - -const PARETO_FN: Record = { - upper_left: paretoFrontUpperLeft, - upper_right: paretoFrontUpperRight, - lower_left: paretoFrontLowerLeft, - lower_right: paretoFrontLowerRight, -}; - -const PAD_LINEAR = 0.08; -const PAD_LOG = 1.18; - -function padDomain(min: number, max: number, log: boolean): [number, number] { - if (!Number.isFinite(min) || !Number.isFinite(max)) return log ? [0.001, 1] : [0, 1]; - if (min === max) { - const pad = min === 0 ? 1 : Math.abs(min) * 0.1; - return [min - pad, max + pad]; - } - if (log) { - if (min <= 0) return [Math.max(0.001, max / 1000), max * PAD_LOG]; - return [min / PAD_LOG, max * PAD_LOG]; - } - const span = max - min; - const pad = span * PAD_LINEAR; - return [min >= 0 ? Math.max(0, min - pad) : min - pad, max + pad]; -} - -/** - * Self-contained replay chart. Builds its own SVG structure (clip-path, - * grid/axis groups, zoom group with dots + rooflines) once on construction, - * then redraws everything imperatively per tick — no React re-renders for - * axes, scales, or layers. The panel only owns control-bar state. - * - * Lifecycle: - * - constructor — builds structure, renders frame 0 - * - play() / pause() — toggle the rAF loop - * - seekToFraction(t) — jump to a position (paused) - * - renderFrame(t) — synchronous deterministic render (used by exporter) - * - setSpeed(n) — change playback multiplier - * - dispose() — cancel rAF, wipe the SVG - */ -export class ReplayController { - private opts: ReplayControllerOptions; - private innerWidth: number; - private innerHeight: number; - private rootGroup: d3.Selection; - private gridGroup: d3.Selection; - private xAxisGroup: d3.Selection; - private yAxisGroup: d3.Selection; - private rooflinesGroup: d3.Selection; - private dotsGroup: d3.Selection; - private lineLabelsGroup: d3.Selection; - private dateOverlay: d3.Selection; - private configs: MutableConfig[]; - private fraction = 0; - private speed = 1; - private playing = false; - private rafId: number | null = null; - private lastTickAt = 0; - private lastBroadcastAt = 0; - - constructor(opts: ReplayControllerOptions) { - this.opts = opts; - this.innerWidth = Math.max(0, opts.width - opts.margin.left - opts.margin.right); - this.innerHeight = Math.max(0, opts.height - opts.margin.top - opts.margin.bottom); - - this.configs = opts.timeline.configs.map((c) => ({ - configId: c.configId, - hwKey: c.hwKey, - precision: c.precision, - template: c.template, - visible: false, - x: 0, - y: 0, - })); - - const svg = d3.select(opts.svg); - svg.selectAll('*').remove(); - svg.attr('width', opts.width).attr('height', opts.height); - - const chartHash = Math.random().toString(36).slice(2, 9); - const clipId = `replay-clip-${chartHash}`; - const defs = svg.append('defs'); - defs - .append('clipPath') - .attr('id', clipId) - .append('rect') - .attr('width', this.innerWidth) - .attr('height', this.innerHeight); - - // InferenceX logo watermark behind the data, matching the main charts. - createLogoWatermark( - svg, - defs, - opts.width, - opts.height, - this.innerWidth, - this.innerHeight, - opts.margin, - `replay-${chartHash}`, - ); - - this.rootGroup = svg - .append('g') - .attr('class', 'chart-root') - .attr('transform', `translate(${opts.margin.left},${opts.margin.top})`); - - this.gridGroup = this.rootGroup.append('g').attr('class', 'grid'); - this.xAxisGroup = this.rootGroup - .append('g') - .attr('class', 'x-axis') - .attr('transform', `translate(0,${this.innerHeight})`); - this.yAxisGroup = this.rootGroup.append('g').attr('class', 'y-axis'); - - svg - .append('text') - .attr('class', 'x-axis-label') - .attr('x', opts.margin.left + this.innerWidth / 2) - .attr('y', opts.height - 10) - .attr('text-anchor', 'middle') - .attr('font-size', '12px') - .text(opts.xLabel); - svg - .append('text') - .attr('class', 'y-axis-label') - .attr('transform', `translate(16,${opts.margin.top + this.innerHeight / 2}) rotate(-90)`) - .attr('text-anchor', 'middle') - .attr('font-size', '12px') - .text(opts.yLabel); - - const zoomGroup = this.rootGroup.append('g').attr('clip-path', `url(#${clipId})`); - this.rooflinesGroup = zoomGroup.append('g').attr('class', 'rooflines'); - this.dotsGroup = zoomGroup.append('g').attr('class', 'dots'); - this.lineLabelsGroup = zoomGroup.append('g').attr('class', 'line-labels'); - - // Big date overlay rendered into the SVG so it shows in MP4 frames too. - this.dateOverlay = this.rootGroup - .append('text') - .attr('class', 'replay-date-overlay') - .attr('x', this.innerWidth - 8) - .attr('y', 28) - .attr('text-anchor', 'end') - .attr('font-size', '28px') - .attr('font-weight', '700') - .attr('fill', 'var(--foreground)') - .style('opacity', 0.85) - .style('font-variant-numeric', 'tabular-nums') - .text(''); - - this.renderCurrent(); - } - - private spanMs(): number { - // Total wall-clock duration at 1× speed. ~800 ms per observed step gives - // each transition room to read; capped at 30 s so very long histories - // still finish in a reasonable time. - const n = this.opts.timeline.dates.length; - if (n <= 1) return 1500; - return Math.min(30_000, Math.max(4500, n * 800)); - } - - private stepFloatAtFraction(t: number): number { - const n = this.opts.timeline.dates.length; - if (n <= 1) return 0; - const raw = Math.max(0, Math.min(1, t)) * (n - 1); - // Cubic ease-in-out per segment: dots and rooflines settle on observed - // dates and accelerate between them, instead of cruising at constant - // speed. The integer parts of `raw` are preserved (segment boundaries are - // still aligned with observed dates) — only the fractional part is eased. - 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; - } - - setSpeed(s: number): void { - this.speed = Math.max(0.1, Math.min(8, s)); - } - - getSpeed(): number { - return this.speed; - } - - /** Wall-clock duration of a full playback at the controller's current speed. */ - getDurationMs(): number { - return this.spanMs() / this.speed; - } - - isPlaying(): boolean { - return this.playing; - } - - getFraction(): number { - return this.fraction; - } - - play(): void { - if (this.playing) return; - this.playing = true; - if (this.fraction >= 1) this.fraction = 0; - this.lastTickAt = performance.now(); - this.scheduleTick(); - } - - pause(): void { - this.playing = false; - if (this.rafId !== null) { - cancelAnimationFrame(this.rafId); - this.rafId = null; - } - } - - seekToFraction(t: number): void { - this.pause(); - this.fraction = Math.max(0, Math.min(1, t)); - this.renderCurrent(); - this.broadcast(); - } - - /** Synchronous render at a logical fraction. Used by the MP4 exporter. */ - renderFrame(t: number): void { - this.fraction = Math.max(0, Math.min(1, t)); - this.renderCurrent(); - } - - dispose(): void { - this.pause(); - d3.select(this.opts.svg).selectAll('*').remove(); - } - - private scheduleTick(): void { - this.rafId = requestAnimationFrame(this.tick); - } - - private tick = (now: number): void => { - if (!this.playing) return; - const dt = now - this.lastTickAt; - this.lastTickAt = now; - this.fraction = Math.min(1, this.fraction + (dt / this.spanMs()) * this.speed); - - this.renderCurrent(); - - if (now - this.lastBroadcastAt > 100) { - this.lastBroadcastAt = now; - this.broadcast(); - } - - if (this.fraction >= 1) { - this.playing = false; - this.broadcast(); - this.opts.onComplete?.(); - return; - } - this.scheduleTick(); - }; - - private broadcast(): void { - const idxFloat = this.stepFloatAtFraction(this.fraction); - const step = Math.round(idxFloat); - const date = - this.opts.timeline.dates[Math.max(0, Math.min(this.opts.timeline.dates.length - 1, step))] ?? - ''; - this.opts.onFrame?.(date, this.fraction, step); - } - - private renderCurrent(): void { - const { - timeline, - isHwActive, - isHideNonOptimal, - isLogScale, - getColor, - rooflineDirection, - selectedPrecisions, - hidePointLabels, - useAdvancedLabels, - showLineLabels, - chartType, - } = this.opts; - const idxFloat = this.stepFloatAtFraction(this.fraction); - - // 1. Interpolate per-config positions and compute the visible bounding box. - let xMin = Infinity; - let xMax = -Infinity; - let yMin = Infinity; - let yMax = -Infinity; - const visibleConfigs: MutableConfig[] = []; - const precisions = selectedPrecisions(); - - for (let i = 0; i < this.configs.length; i++) { - const orig = timeline.configs[i]; - const m = this.configs[i]; - const r = interpolateAtStep(orig.stepValues, idxFloat); - m.x = r.x; - m.y = r.y; - m.visible = r.visible && isHwActive(m.hwKey) && precisions.includes(m.precision); - if (m.visible) { - visibleConfigs.push(m); - if (m.x < xMin) xMin = m.x; - if (m.x > xMax) xMax = m.x; - if (m.y < yMin) yMin = m.y; - if (m.y > yMax) yMax = m.y; - } - } - - // 2. Domain + scales (recomputed every tick — that's the point). - const log = isLogScale(); - const xScale = log - ? d3 - .scaleLog() - .domain(padDomain(xMin, xMax, true)) - .range([0, this.innerWidth]) - .nice() - : d3 - .scaleLinear() - .domain(padDomain(xMin, xMax, false)) - .range([0, this.innerWidth]) - .nice(); - const yScale = log - ? d3 - .scaleLog() - .domain(padDomain(yMin, yMax, true)) - .range([this.innerHeight, 0]) - .nice() - : d3 - .scaleLinear() - .domain(padDomain(yMin, yMax, false)) - .range([this.innerHeight, 0]) - .nice(); - - // 3. Axes. - const xAxis = log - ? d3 - .axisBottom(xScale) - .ticks(6) - .tickFormat(logTickFormat(xScale as d3.ScaleLogarithmic)) - : d3 - .axisBottom(xScale) - .ticks(6) - .tickFormat((d) => formatLargeNumber(d as number)); - const yAxis = log - ? d3 - .axisLeft(yScale) - .ticks(5) - .tickFormat(logTickFormat(yScale as d3.ScaleLogarithmic)) - : d3 - .axisLeft(yScale) - .ticks(5) - .tickFormat((d) => formatLargeNumber(d as number)); - this.xAxisGroup.call(xAxis as any); - this.yAxisGroup.call(yAxis as any); - - // 4. Grid lines. - const xTicks = xScale.ticks(6); - const yTicks = yScale.ticks(5); - // No inline stroke — the global stylesheet styles `.chart-root .grid line` - // with `stroke: var(--border-alt)` so replay gridlines match the rest of - // the dashboard. Inline attrs would defeat that. - const gridX = this.gridGroup.selectAll('.grid-x').data(xTicks); - gridX.exit().remove(); - gridX - .enter() - .append('line') - .attr('class', 'grid-x') - .merge(gridX as any) - .attr('x1', (d: number) => xScale(d) ?? 0) - .attr('x2', (d: number) => xScale(d) ?? 0) - .attr('y1', 0) - .attr('y2', this.innerHeight); - const gridY = this.gridGroup.selectAll('.grid-y').data(yTicks); - gridY.exit().remove(); - gridY - .enter() - .append('line') - .attr('class', 'grid-y') - .merge(gridY as any) - .attr('x1', 0) - .attr('x2', this.innerWidth) - .attr('y1', (d: number) => yScale(d) ?? 0) - .attr('y2', (d: number) => yScale(d) ?? 0); - - // 5. Pareto + rooflines. - const byHw = new Map(); - for (const c of visibleConfigs) { - let bucket = byHw.get(c.hwKey); - if (!bucket) { - bucket = []; - byHw.set(c.hwKey, bucket); - } - bucket.push(c); - } - const paretoFn = PARETO_FN[rooflineDirection]; - interface RoofEntry { - hw: string; - pts: { x: number; y: number; src: MutableConfig }[]; - } - const rooflines: RoofEntry[] = []; - const optimalSet = new Set(); - for (const [hw, pts] of byHw) { - if (pts.length < 2) continue; - const front = paretoFn( - pts.map((p) => ({ x: p.x, y: p.y, src: p }) as unknown as InferenceData), - ) as unknown as { x: number; y: number; src: MutableConfig }[]; - front.sort((a, b) => a.x - b.x); - if (front.length >= 2) { - rooflines.push({ hw, pts: front }); - for (const p of front) optimalSet.add(p.src); - } - } - const lineGen = d3 - .line<{ x: number; y: number }>() - .x((d) => xScale(d.x) ?? 0) - .y((d) => yScale(d.y) ?? 0) - .curve(d3.curveMonotoneX); - - const roofSel = this.rooflinesGroup - .selectAll('.replay-roofline') - .data(rooflines, (d) => d.hw); - roofSel.exit().remove(); - const roofEnter = roofSel - .enter() - .append('path') - .attr('class', 'replay-roofline') - .attr('fill', 'none'); - roofEnter - .merge(roofSel as any) - .attr('stroke', (d: RoofEntry) => getColor(d.hw)) - .attr('stroke-width', 2) - .attr('d', (d: RoofEntry) => lineGen(d.pts) ?? ''); - - // 6. Dots. - const hideNonOptimal = isHideNonOptimal(); - const dotData = visibleConfigs.filter((c) => !hideNonOptimal || optimalSet.has(c)); - const dotSel = this.dotsGroup - .selectAll('.replay-dot-group') - .data(dotData, (d) => d.configId); - dotSel.exit().remove(); - const dotEnter = dotSel.enter().append('g').attr('class', 'replay-dot-group'); - dotEnter.append('circle').attr('class', 'replay-dot').attr('r', 5); - dotEnter - .append('text') - .attr('class', 'replay-dot-label') - .attr('text-anchor', 'middle') - .attr('font-size', '10px') - .attr('pointer-events', 'none') - .attr('fill', 'var(--foreground)') - .attr('dy', -8); - - const merged = dotEnter.merge(dotSel as any); - merged.attr('transform', (d: MutableConfig) => `translate(${xScale(d.x)},${yScale(d.y)})`); - merged.select('.replay-dot').attr('fill', (d: MutableConfig) => getColor(d.hwKey)); - - // 7. Labels. - const hide = hidePointLabels(); - const advanced = useAdvancedLabels(); - merged - .select('.replay-dot-label') - .style('display', hide ? 'none' : 'block') - .text((d: MutableConfig) => - hide ? '' : advanced ? getPointLabel(d.template) : String(d.template.tp), - ); - - // 8. Line labels — one label per hw roofline, placed along the line - // (interactivity charts use greedy collision avoidance, e2e/ttft uses - // endpoint labels with vertical de-overlap). Mirrors ScatterGraph. - interface LineLabel { - key: string; - label: string; - color: string; - x: number; - y: number; - visible: boolean; - } - const lineLabels: LineLabel[] = []; - if (showLineLabels() && rooflines.length > 0) { - const LABEL_H = 18; - const LABEL_W = 120; - if (chartType === 'interactivity') { - const placed: { x: number; y: number }[] = []; - const collides = (cx: number, cy: number) => - placed.some((p) => Math.abs(p.y - cy) < LABEL_H && Math.abs(p.x - cx) < LABEL_W); - const sorted = [...rooflines].toSorted( - (a, b) => (yScale(a.pts[0].y) ?? 0) - (yScale(b.pts[0].y) ?? 0), - ); - for (const entry of sorted) { - const pts = entry.pts; - const label = getDisplayLabel(getHardwareConfig(entry.hw)); - const candidates = [ - pts[Math.min(1, pts.length - 1)], - pts[Math.floor(pts.length / 2)], - pts[Math.max(0, Math.floor((pts.length * 2) / 3))], - pts.at(-1)!, - ]; - let foundPlacement = false; - for (const pt of candidates) { - const px = xScale(pt.x) ?? 0; - const py = yScale(pt.y) ?? 0; - if (!collides(px, py)) { - lineLabels.push({ - key: entry.hw, - label, - color: getColor(entry.hw), - x: px, - y: py, - visible: true, - }); - placed.push({ x: px, y: py }); - foundPlacement = true; - break; - } - } - if (!foundPlacement) { - const pt = pts[0]; - lineLabels.push({ - key: entry.hw, - label, - color: getColor(entry.hw), - x: xScale(pt.x) ?? 0, - y: yScale(pt.y) ?? 0, - visible: false, - }); - } - } - } else { - for (const entry of rooflines) { - const pt = entry.pts.at(-1)!; - lineLabels.push({ - key: entry.hw, - label: getDisplayLabel(getHardwareConfig(entry.hw)), - color: getColor(entry.hw), - x: xScale(pt.x) ?? 0, - y: yScale(pt.y) ?? 0, - visible: true, - }); - } - const yRange = yScale.range(); - const top = Math.min(yRange[0], yRange[1]) + LABEL_H; - const bottom = Math.max(yRange[0], yRange[1]) - LABEL_H; - lineLabels.sort((a, b) => a.y - b.y); - for (let pass = 0; pass < 5; pass++) { - for (let i = 1; i < lineLabels.length; i++) { - const overlap = lineLabels[i - 1].y + LABEL_H - lineLabels[i].y; - if (overlap > 0) { - const half = overlap / 2; - lineLabels[i - 1].y -= half; - lineLabels[i].y += half; - } - } - for (const l of lineLabels) { - l.y = Math.max(top, Math.min(bottom, l.y)); - } - } - } - } - - const labelSel = this.lineLabelsGroup - .selectAll('.replay-line-label') - .data(lineLabels, (d) => d.key); - labelSel.exit().remove(); - const labelEnter = labelSel - .enter() - .append('g') - .attr('class', 'replay-line-label') - .style('pointer-events', 'none'); - labelEnter.append('rect').attr('rx', 4).attr('ry', 4).attr('opacity', 0.95); - labelEnter - .append('text') - .attr('text-anchor', 'start') - .attr('dominant-baseline', 'central') - .attr('fill', 'white') - .attr('font-size', '10px') - .attr('font-weight', '600'); - const labelMerged = labelEnter.merge(labelSel as any); - labelMerged - .attr('transform', (d: LineLabel) => `translate(${d.x + 8},${d.y - 14})`) - .style('opacity', (d: LineLabel) => (d.visible ? 1 : 0)); - labelMerged.each(function (d: LineLabel) { - const g = d3.select(this); - const text = g.select('text').text(d.label); - const bbox = (text.node() as SVGTextElement).getBBox(); - const px = 5; - const py = 3; - g.select('rect') - .attr('x', bbox.x - px) - .attr('y', bbox.y - py) - .attr('width', bbox.width + px * 2) - .attr('height', bbox.height + py * 2) - .attr('fill', d.color); - }); - - // 9. Date overlay — rendered into the SVG so it shows in MP4 frames too. - const dates = timeline.dates; - if (dates.length > 0) { - const stepRound = Math.max(0, Math.min(dates.length - 1, Math.round(idxFloat))); - this.dateOverlay.text(dates[stepRound]); - } else { - this.dateOverlay.text(''); - } - } -} diff --git a/packages/app/src/components/inference/replay/ReplayLegend.tsx b/packages/app/src/components/inference/replay/ReplayLegend.tsx deleted file mode 100644 index 9b3bcbc3..00000000 --- a/packages/app/src/components/inference/replay/ReplayLegend.tsx +++ /dev/null @@ -1,61 +0,0 @@ -'use client'; - -import { cn } from '@/lib/utils'; - -export interface ReplayLegendItem { - hwKey: string; - label: string; - color: string; - active: boolean; -} - -interface ReplayLegendProps { - items: ReplayLegendItem[]; - onToggle: (hwKey: string) => void; -} - -/** - * Compact list-style legend for the replay panel. Active-first sort, fixed - * narrow width, no expand/search/precision-shape chrome — just colored - * swatch + GPU label so both the live preview and the rasterized MP4 frame - * stay tight. - */ -export default function ReplayLegend({ items, onToggle }: ReplayLegendProps) { - const sorted = [...items].toSorted((a, b) => { - if (a.active !== b.active) return a.active ? -1 : 1; - return a.label.localeCompare(b.label); - }); - - return ( -
    - {sorted.map((item) => ( -
  • - -
  • - ))} -
- ); -} diff --git a/packages/app/src/components/inference/replay/ReplayPanel.tsx b/packages/app/src/components/inference/replay/ReplayPanel.tsx index 2c3302ac..8ac99054 100644 --- a/packages/app/src/components/inference/replay/ReplayPanel.tsx +++ b/packages/app/src/components/inference/replay/ReplayPanel.tsx @@ -1,11 +1,13 @@ '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 { @@ -16,14 +18,11 @@ import { SelectValue, } from '@/components/ui/select'; import { useBenchmarkHistory } from '@/hooks/api/use-benchmark-history'; -import { useThemeColors } from '@/hooks/useThemeColors'; import { track } from '@/lib/analytics'; -import { getHardwareConfig, getModelSortIndex } from '@/lib/constants'; -import { cn, getDisplayLabel } from '@/lib/utils'; +import { cn } from '@/lib/utils'; import { buildReplayTimeline } from './buildReplayTimeline'; -import ReplayLegend, { type ReplayLegendItem } from './ReplayLegend'; -import { ReplayController, type RooflineDirection } from './ReplayController'; +import { buildFrameData, dateAtFraction, spanMs } from './replayFrameData'; interface ReplayPanelProps { parentChartId: string; @@ -33,15 +32,15 @@ interface ReplayPanelProps { } const SPEED_OPTIONS: readonly number[] = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; - -const REPLAY_HEIGHT = 480; -const REPLAY_MARGIN = { top: 20, right: 20, bottom: 56, left: 64 }; +const REPLAY_BODY_MIN_HEIGHT = 480; /** - * Lazy-loaded replay panel. The SVG is fully driven by `ReplayController` — - * React only manages the controls bar, fetch state, and the small legend. - * Filter values are read by the controller through ref-based getters every - * tick so toggles take effect immediately without rebuilding the chart. + * 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, @@ -50,208 +49,149 @@ export default function ReplayPanel({ xLabel, }: ReplayPanelProps) { const inference = useInference(); - const { - selectedModel, - selectedSequence, - selectedYAxisMetric, - selectedXAxisMetric, - selectedE2eXAxisMetric, - selectedPrecisions, - activeHwTypes, - toggleHwType, - highContrast, - logScale, - hideNonOptimal, - hidePointLabels, - useAdvancedLabels, - showLineLabels, - } = inference; + const { selectedModel, selectedSequence } = inference; const { isl = 0, osl = 0 } = sequenceToIslOsl(selectedSequence) ?? {}; const history = useBenchmarkHistory(selectedModel, isl, osl); const effectiveX = - chartDefinition.chartType === 'e2e' ? selectedE2eXAxisMetric : selectedXAxisMetric; + chartDefinition.chartType === 'e2e' + ? inference.selectedE2eXAxisMetric + : inference.selectedXAxisMetric; const timeline = useMemo(() => { if (!history.data) return null; return buildReplayTimeline( history.data, chartDefinition, - selectedYAxisMetric, + inference.selectedYAxisMetric, effectiveX ?? null, - selectedPrecisions, + inference.selectedPrecisions, ); - }, [history.data, chartDefinition, selectedYAxisMetric, effectiveX, selectedPrecisions]); - - const hwKeys = useMemo( - () => (timeline ? [...new Set(timeline.configs.map((c) => c.hwKey))] : []), - [timeline], - ); - const { resolveColor, getCssColor } = useThemeColors({ - highContrast, - identifiers: hwKeys, - activeKeys: hwKeys, - }); - const getColor = useCallback( - (hwKey: string) => getCssColor(resolveColor(hwKey)), - [getCssColor, resolveColor], - ); - - // Refs — controller reads these every tick. - const activeHwTypesRef = useRef(activeHwTypes); - const hideNonOptimalRef = useRef(hideNonOptimal); - const logScaleRef = useRef(logScale); - const selectedPrecisionsRef = useRef(selectedPrecisions); - const hidePointLabelsRef = useRef(hidePointLabels); - const useAdvancedLabelsRef = useRef(useAdvancedLabels); - const showLineLabelsRef = useRef(showLineLabels); - const getColorRef = useRef(getColor); - activeHwTypesRef.current = activeHwTypes; - hideNonOptimalRef.current = hideNonOptimal; - logScaleRef.current = logScale; - selectedPrecisionsRef.current = selectedPrecisions; - hidePointLabelsRef.current = hidePointLabels; - useAdvancedLabelsRef.current = useAdvancedLabels; - showLineLabelsRef.current = showLineLabels; - getColorRef.current = getColor; - - const svgRef = useRef(null); - const controllerRef = useRef(null); - const observerRef = useRef(null); - const [width, setWidth] = useState(0); - - const [playing, setPlaying] = useState(false); - const [fraction, setFraction] = useState(0); - const [speed, setSpeed] = useState(1); - const [currentDate, setCurrentDate] = useState(''); - const [isExporting, setIsExporting] = useState(false); - const [exportProgress, setExportProgress] = useState(null); + }, [ + history.data, + chartDefinition, + inference.selectedYAxisMetric, + effectiveX, + inference.selectedPrecisions, + ]); - // Callback ref — runs whenever the chart container element mounts/unmounts, - // including after the panel transitions out of its loading state. A plain - // useEffect with `[]` deps would have fired before the chart div existed. - const setContainerEl = useCallback((el: HTMLDivElement | null) => { - if (observerRef.current) { - observerRef.current.disconnect(); - observerRef.current = null; + // 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 (!el) { - setWidth(0); + if (!wrapper) { + setSvgOffset(null); return; } - const initial = el.getBoundingClientRect().width; - if (initial > 0) setWidth(Math.floor(initial)); - const ro = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) return; - const w = entry.contentRect.width; - if (w > 0) setWidth(Math.floor(w)); - }); - ro.observe(el); - observerRef.current = ro; + let svgEl: SVGSVGElement | null = null; + const measure = () => { + const svg = wrapper.querySelector('svg'); + if (!svg) return; + const wRect = wrapper.getBoundingClientRect(); + const sRect = svg.getBoundingClientRect(); + setSvgOffset((prev) => { + const next = { + right: Math.max(0, wRect.right - sRect.right + 10), + 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( () => () => { - observerRef.current?.disconnect(); - observerRef.current = null; + observersRef.current?.size.disconnect(); + observersRef.current?.mutation.disconnect(); + observersRef.current = null; }, [], ); - const rooflineDirection = - (chartDefinition[ - `${selectedYAxisMetric}_roofline` as keyof ChartDefinition - ] as RooflineDirection) ?? 'upper_left'; + 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); - // Reset playhead state when the timeline changes (model/sequence/metric switch). - useEffect(() => { - setFraction(0); - setCurrentDate(timeline?.dates[0] ?? ''); - setPlaying(false); - }, [timeline]); + // rAF loop — keeps a ref to the current speed so changing speed doesn't + // restart the loop. + const speedRef = useRef(speed); + speedRef.current = speed; + const playingRef = useRef(playing); + playingRef.current = playing; - // Build / rebuild the controller when timeline or width changes. Filter - // values flow through refs so mid-playback toggles never reach this effect. useEffect(() => { - if (!timeline || timeline.configs.length === 0) return; - if (!svgRef.current || width <= 0) return; - - const controller = new ReplayController({ - svg: svgRef.current, - width, - height: REPLAY_HEIGHT, - margin: REPLAY_MARGIN, - xLabel, - yLabel, - timeline, - rooflineDirection, - getColor: (hw) => getColorRef.current(hw), - isHwActive: (hw) => activeHwTypesRef.current.has(hw), - isHideNonOptimal: () => hideNonOptimalRef.current, - isLogScale: () => logScaleRef.current, - selectedPrecisions: () => selectedPrecisionsRef.current, - hidePointLabels: () => hidePointLabelsRef.current, - useAdvancedLabels: () => useAdvancedLabelsRef.current, - showLineLabels: () => showLineLabelsRef.current, - chartType: chartDefinition.chartType === 'e2e' ? 'e2e' : 'interactivity', - onFrame: (date, frac) => { - setCurrentDate(date); - setFraction(frac); - }, - onComplete: () => setPlaying(false), - }); - controllerRef.current = controller; - controller.setSpeed(speed); - controller.renderFrame(fraction); - return () => { - controller.dispose(); - controllerRef.current = null; + if (!playing || !timeline) return; + let rafId: number; + let last = performance.now(); + const totalMs = spanMs(timeline.dates.length); + const step = (now: number) => { + if (!playingRef.current) return; + const dt = now - last; + last = now; + setFraction((prev) => { + const next = Math.min(1, prev + (dt / totalMs) * speedRef.current); + if (next >= 1) { + setPlaying(false); + } + return next; + }); + rafId = requestAnimationFrame(step); }; - // We deliberately exclude `speed`, `fraction`, and `getColor` — speed/fraction - // live on the controller, getColor is read through a ref each tick. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [timeline, width, xLabel, yLabel, rooflineDirection]); + rafId = requestAnimationFrame(step); + return () => cancelAnimationFrame(rafId); + }, [playing, timeline]); + // Reset fraction when timeline rebuilds (model/sequence/metric switch). useEffect(() => { - controllerRef.current?.setSpeed(speed); - }, [speed]); + setFraction(0); + setPlaying(false); + }, [timeline]); - // Repaint after a filter toggle when paused (controller already picks up - // refs on the next tick when playing). - useEffect(() => { - const c = controllerRef.current; - if (!c) return; - if (!c.isPlaying()) c.renderFrame(c.getFraction()); - }, [ - activeHwTypes, - hideNonOptimal, - logScale, - hidePointLabels, - useAdvancedLabels, - showLineLabels, - selectedPrecisions, - ]); + const frameData = useMemo( + () => (timeline ? buildFrameData(timeline, fraction) : []), + [timeline, fraction], + ); + + const currentDate = useMemo( + () => (timeline ? dateAtFraction(timeline, fraction) : ''), + [timeline, fraction], + ); const handlePlayPause = useCallback(() => { - const c = controllerRef.current; - if (!c) return; - if (c.isPlaying()) { - c.pause(); + if (playing) { setPlaying(false); - track('inference_replay_paused', { fraction: c.getFraction() }); + track('inference_replay_paused', { fraction }); } else { - c.play(); + setFraction((f) => (f >= 1 ? 0 : f)); setPlaying(true); track('inference_replay_started', { speed }); } - }, [speed]); + }, [playing, fraction, speed]); const handleScrub = useCallback((value: number) => { - const c = controllerRef.current; - if (!c) return; - c.seekToFraction(value); setFraction(value); setPlaying(false); track('inference_replay_scrubbed', { fraction: value }); @@ -263,16 +203,13 @@ export default function ReplayPanel({ }, []); const handleReset = useCallback(() => { - const c = controllerRef.current; - if (!c) return; - c.seekToFraction(0); setFraction(0); setPlaying(false); - setCurrentDate(timeline?.dates[0] ?? ''); - }, [timeline]); + }, []); const handleExportMp4 = useCallback(async () => { - if (!timeline || !controllerRef.current) return; + if (!timeline) return; + setPlaying(false); setIsExporting(true); setExportProgress(0); track('inference_replay_export_started', { @@ -281,15 +218,22 @@ export default function ReplayPanel({ }); try { const { exportReplayMp4 } = await import('./exportMp4'); - // Output duration tracks the controller's current playback speed: 1× → ~spanMs, - // 2× → half that, 0.25× → 4×. Capped at 60 s so rare extreme settings don't - // produce hundred-megabyte files. - const durationSec = Math.max(2, Math.min(60, controllerRef.current.getDurationMs() / 1000)); + // Output duration tracks current playback speed: 1× → ~spanMs, 2× → half, + // 0.25× → 4×. Capped at 60 s so extreme settings don't produce 100+ MB + // files. + const durationSec = Math.max(2, Math.min(60, spanMs(timeline.dates.length) / speed / 1000)); await exportReplayMp4({ captureRootId: `replay-panel-${parentChartId}`, - controller: controllerRef.current, fileName: `InferenceX_${selectedModel}_${chartDefinition.chartType}_replay`, durationSec, + renderFrame: async (t) => { + // flushSync forces React to commit synchronously; two RAFs let the + // browser paint before the capture step reads back the DOM. + flushSync(() => setFraction(t)); + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + }, onProgress: (p) => setExportProgress(p), }); track('inference_replay_export_completed', { @@ -300,43 +244,21 @@ export default function ReplayPanel({ console.error('MP4 export failed', error); const message = error instanceof Error ? error.message : 'Export failed.'; alert( - `MP4 export failed: ${message}\n\nIf you're not on Chrome, try Chrome — MP4 export uses WebCodecs, which may be unavailable in other browsers.`, + `MP4 export failed: ${message}\n\nIf you're not on Chrome, try Chrome. MP4 export uses WebCodecs, which may be unavailable in other browsers.`, ); track('inference_replay_export_failed', { reason: message }); } finally { setIsExporting(false); setExportProgress(null); - controllerRef.current?.renderFrame(controllerRef.current.getFraction()); - } - }, [chartDefinition.chartType, parentChartId, selectedModel, timeline]); - - const legendItems = useMemo(() => { - if (!timeline) return []; - const seen = new Set(); - const items: ReplayLegendItem[] = []; - for (const c of timeline.configs) { - if (seen.has(c.hwKey)) continue; - seen.add(c.hwKey); - const hwConfig = getHardwareConfig(c.hwKey); - items.push({ - hwKey: c.hwKey, - label: getDisplayLabel(hwConfig), - color: getCssColor(resolveColor(c.hwKey)), - active: activeHwTypes.has(c.hwKey), - }); } - return items.toSorted( - (a, b) => - getModelSortIndex(a.hwKey) - getModelSortIndex(b.hwKey) || a.label.localeCompare(b.label), - ); - }, [timeline, activeHwTypes, resolveColor, getCssColor]); + }, [chartDefinition.chartType, parentChartId, selectedModel, speed, timeline]); if (history.isLoading || !timeline) { return (

Replay over time

@@ -351,7 +273,7 @@ export default function ReplayPanel({

Replay over time

@@ -378,20 +300,23 @@ export default function ReplayPanel({

-
-
- +
+ +
+ {currentDate}
- {legendItems.length > 0 && ( -
- toggleHwType(hw)} /> -
- )}
Promise; fileName: string; fps?: number; durationSec?: number; @@ -111,16 +114,6 @@ function expandLegendForExport(cloneRoot: HTMLElement) { const skipNoExport = (node: Node) => !((node as Element).classList && (node as Element).classList.contains('no-export')); -function waitTwoFrames(): Promise { - return new Promise((resolve) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - resolve(); - }); - }); - }); -} - /** Draw the panel canvas onto a slightly taller canvas with an InferenceX watermark bar. */ function drawWithWatermark( source: HTMLCanvasElement, @@ -169,7 +162,7 @@ interface MuxerLike { export async function exportReplayMp4(opts: ExportOptions): Promise { const { captureRootId, - controller, + renderFrame, fileName, fps = 30, durationSec = 6, @@ -189,8 +182,6 @@ export async function exportReplayMp4(opts: ExportOptions): Promise { import('@jpinsonneau/html-to-image'), ]); - controller.pause(); - // 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(); @@ -221,12 +212,10 @@ export async function exportReplayMp4(opts: ExportOptions): Promise { try { for (let i = 0; i < totalFrames; i++) { const t = totalFrames === 1 ? 1 : i / (totalFrames - 1); - controller.renderFrame(t); - await waitTwoFrames(); + await renderFrame(t); - // Per-frame clone: the controller mutates dot positions and the - // replay-roofline-layer paths in place on the live SVG, so a deep clone - // each frame captures the current state. + // 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'); 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..b9766e95 --- /dev/null +++ b/packages/app/src/components/inference/replay/replayFrameData.ts @@ -0,0 +1,54 @@ +import type { InferenceData } from '@/components/inference/types'; + +import type { ReplayTimeline } from './buildReplayTimeline'; +import { interpolateAtStep } from './interpolateAtTime'; + +/** + * Convert a logical fraction [0, 1] across a replay timeline into a snapshot of + * `InferenceData[]` at the interpolated positions. The snapshot keeps each + * config's full template (hwKey, precision, tp, …) and only swaps x/y, so the + * scatter chart renders it identically to a real benchmark snapshot. + */ +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 so the playhead settles on observed dates and + * accelerates between them, instead of cruising at constant speed. + */ +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; +} + +/** + * Total wall-clock duration of a full 1× playback. ~800 ms per observed step + * gives each transition room to read; capped at 30 s so very long histories + * still finish in a reasonable time. + */ +export function spanMs(numDates: number): number { + if (numDates <= 1) return 1500; + return Math.min(30_000, Math.max(4500, numDates * 800)); +} + +/** + * Map a fraction to the nearest observed date label for the date overlay. + */ +export function dateAtFraction(timeline: ReplayTimeline, fraction: number): string { + const dates = timeline.dates; + if (dates.length === 0) return ''; + const step = Math.round(stepFloatAtFraction(fraction, dates.length)); + return dates[Math.max(0, Math.min(dates.length - 1, step))] ?? ''; +} diff --git a/packages/app/src/components/inference/types.ts b/packages/app/src/components/inference/types.ts index 0ea63fca..cd10826a 100644 --- a/packages/app/src/components/inference/types.ts +++ b/packages/app/src/components/inference/types.ts @@ -347,6 +347,12 @@ 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; } /** * @file types.ts diff --git a/packages/app/src/components/inference/ui/ScatterGraph.tsx b/packages/app/src/components/inference/ui/ScatterGraph.tsx index a68d5aac..2aa2f054 100644 --- a/packages/app/src/components/inference/ui/ScatterGraph.tsx +++ b/packages/app/src/components/inference/ui/ScatterGraph.tsx @@ -112,6 +112,7 @@ const ScatterGraph = React.memo( showAllHardwareTypes = false, hardwareConfigOverride, overlayData, + transitionDuration = 750, }: ScatterGraphProps) => { const { activeHwTypes, @@ -1909,7 +1910,7 @@ const ScatterGraph = React.memo( layers={layers} zoom={zoomConfig} tooltip={tooltipConfig} - transitionDuration={750} + transitionDuration={transitionDuration} onRender={onRender} noDataOverlay={ filteredData.length === 0 && processedOverlayData.length === 0 ? ( From 8cde37a55a57705c43fb27e196b162c6148ab422 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Wed, 13 May 2026 00:15:57 -0500 Subject: [PATCH 06/31] feat(replay): disable nice() so axes shift continuously during playback --- .../app/src/components/inference/replay/ReplayPanel.tsx | 1 + packages/app/src/components/inference/types.ts | 7 +++++++ .../app/src/components/inference/ui/ScatterGraph.tsx | 9 +++++---- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/inference/replay/ReplayPanel.tsx b/packages/app/src/components/inference/replay/ReplayPanel.tsx index 8ac99054..83359f70 100644 --- a/packages/app/src/components/inference/replay/ReplayPanel.tsx +++ b/packages/app/src/components/inference/replay/ReplayPanel.tsx @@ -309,6 +309,7 @@ export default function ReplayPanel({ yLabel={yLabel} chartDefinition={chartDefinition} transitionDuration={0} + niceAxes={false} />
{ const { activeHwTypes, @@ -448,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 = @@ -473,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( From dac7e5a920ad7e04fe9baa6592a143f9b8b711ce Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Wed, 13 May 2026 00:17:22 -0500 Subject: [PATCH 07/31] test(replay): fix selectors after radix layout exposed by refactor --- packages/app/cypress/e2e/inference-replay.cy.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/app/cypress/e2e/inference-replay.cy.ts b/packages/app/cypress/e2e/inference-replay.cy.ts index 1dd8d19e..5d09acd7 100644 --- a/packages/app/cypress/e2e/inference-replay.cy.ts +++ b/packages/app/cypress/e2e/inference-replay.cy.ts @@ -51,7 +51,9 @@ describe('Inference Replay', () => { return; } cy.get('[data-testid="replay-scrubber"]').should('exist'); - cy.get('[data-testid="replay-speed-1x"]').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. @@ -60,11 +62,12 @@ describe('Inference Replay', () => { }); }); - it('closes the modal via the dialog close button', () => { + it('closes the modal', () => { cy.get('body').then(($body) => { if ($body.find('[data-testid="replay-panel-chart-0"]').length === 0) return; - // shadcn Dialog renders an X button inside the dialog content. - cy.get('[data-testid^="replay-dialog-"]').find('button').first().click(); + // 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'); }); }); From 1411521b92269f43a2ae0c4924da3406c04dc918 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Wed, 13 May 2026 13:05:09 -0500 Subject: [PATCH 08/31] types(replay): narrow ChartDefinition.chartType to 'e2e' | 'interactivity' --- packages/app/cypress/support/mock-data.ts | 2 +- packages/app/src/components/inference/types.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) 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/src/components/inference/types.ts b/packages/app/src/components/inference/types.ts index 5c09983d..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; From a5bbfc1011a316007004f49687fa9aae9d84ec56 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Wed, 13 May 2026 13:05:22 -0500 Subject: [PATCH 09/31] refactor(replay): replace any-cast on x-axis field with typed accessor --- .../src/components/inference/replay/buildReplayTimeline.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/inference/replay/buildReplayTimeline.ts b/packages/app/src/components/inference/replay/buildReplayTimeline.ts index c8abb459..5298df2d 100644 --- a/packages/app/src/components/inference/replay/buildReplayTimeline.ts +++ b/packages/app/src/components/inference/replay/buildReplayTimeline.ts @@ -175,7 +175,9 @@ export function buildReplayTimeline( if (yMetric === null) continue; const xVal = - xAxisField === chartDef.x ? point.x : ((point as any)[xAxisField] as number | undefined); + 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; From 8510ff72685a11da7b3f8c731ee94e0a538fe5c7 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Wed, 13 May 2026 13:05:54 -0500 Subject: [PATCH 10/31] refactor(replay): accept HTMLElement instead of DOM id in MP4 exporter --- .../src/components/inference/replay/ReplayPanel.tsx | 12 ++++++------ .../app/src/components/inference/replay/exportMp4.ts | 11 ++++++----- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/app/src/components/inference/replay/ReplayPanel.tsx b/packages/app/src/components/inference/replay/ReplayPanel.tsx index 83359f70..8a90e5ef 100644 --- a/packages/app/src/components/inference/replay/ReplayPanel.tsx +++ b/packages/app/src/components/inference/replay/ReplayPanel.tsx @@ -129,6 +129,8 @@ export default function ReplayPanel({ [], ); + const panelRef = useRef(null); + const [fraction, setFraction] = useState(0); const [playing, setPlaying] = useState(false); const [speed, setSpeed] = useState(1); @@ -222,8 +224,10 @@ export default function ReplayPanel({ // 0.25× → 4×. Capped at 60 s so extreme settings don't produce 100+ MB // files. const durationSec = Math.max(2, Math.min(60, spanMs(timeline.dates.length) / speed / 1000)); + const root = panelRef.current; + if (!root) throw new Error('Replay panel element is not mounted.'); await exportReplayMp4({ - captureRootId: `replay-panel-${parentChartId}`, + captureRoot: root, fileName: `InferenceX_${selectedModel}_${chartDefinition.chartType}_replay`, durationSec, renderFrame: async (t) => { @@ -287,11 +291,7 @@ export default function ReplayPanel({ } return ( -
+

Replay over time

diff --git a/packages/app/src/components/inference/replay/exportMp4.ts b/packages/app/src/components/inference/replay/exportMp4.ts index b585aba0..a7589f02 100644 --- a/packages/app/src/components/inference/replay/exportMp4.ts +++ b/packages/app/src/components/inference/replay/exportMp4.ts @@ -1,6 +1,6 @@ interface ExportOptions { - /** DOM id of the replay panel root to capture each frame. */ - captureRootId: string; + /** 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 @@ -161,7 +161,7 @@ interface MuxerLike { */ export async function exportReplayMp4(opts: ExportOptions): Promise { const { - captureRootId, + captureRoot: livePanel, renderFrame, fileName, fps = 30, @@ -174,8 +174,9 @@ export async function exportReplayMp4(opts: ExportOptions): Promise { throw new TypeError('WebCodecs is not available in this browser. Try Chrome.'); } - const livePanel = document.querySelector(`#${captureRootId}`); - if (!livePanel) throw new Error(`Replay panel "${captureRootId}" not found in the DOM.`); + if (!livePanel.isConnected) { + throw new Error('Replay panel element is not in the DOM.'); + } const [{ Muxer, ArrayBufferTarget }, { toCanvas }] = await Promise.all([ import('mp4-muxer'), From 2f158d417aa8f11ecfe6b00e60d4b5959b6b2f0b Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Wed, 13 May 2026 13:06:12 -0500 Subject: [PATCH 11/31] fix(replay): decouple MP4 export duration from playback speed --- .../src/components/inference/replay/ReplayPanel.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/inference/replay/ReplayPanel.tsx b/packages/app/src/components/inference/replay/ReplayPanel.tsx index 8a90e5ef..52f22a89 100644 --- a/packages/app/src/components/inference/replay/ReplayPanel.tsx +++ b/packages/app/src/components/inference/replay/ReplayPanel.tsx @@ -220,10 +220,10 @@ export default function ReplayPanel({ }); try { const { exportReplayMp4 } = await import('./exportMp4'); - // Output duration tracks current playback speed: 1× → ~spanMs, 2× → half, - // 0.25× → 4×. Capped at 60 s so extreme settings don't produce 100+ MB - // files. - const durationSec = Math.max(2, Math.min(60, spanMs(timeline.dates.length) / speed / 1000)); + // 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({ @@ -255,7 +255,7 @@ export default function ReplayPanel({ setIsExporting(false); setExportProgress(null); } - }, [chartDefinition.chartType, parentChartId, selectedModel, speed, timeline]); + }, [chartDefinition.chartType, parentChartId, selectedModel, timeline]); if (history.isLoading || !timeline) { return ( From 4d592abd58e6dbbed82a8fa9ecec3e3bd7687de1 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Wed, 13 May 2026 13:06:40 -0500 Subject: [PATCH 12/31] feat(replay): honor prefers-reduced-motion with slideshow playback --- .../inference/replay/ReplayPanel.tsx | 22 ++++++++++++++++++- .../inference/replay/useReducedMotion.ts | 16 ++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/components/inference/replay/useReducedMotion.ts diff --git a/packages/app/src/components/inference/replay/ReplayPanel.tsx b/packages/app/src/components/inference/replay/ReplayPanel.tsx index 52f22a89..da76ad6a 100644 --- a/packages/app/src/components/inference/replay/ReplayPanel.tsx +++ b/packages/app/src/components/inference/replay/ReplayPanel.tsx @@ -23,6 +23,7 @@ import { cn } from '@/lib/utils'; import { buildReplayTimeline } from './buildReplayTimeline'; import { buildFrameData, dateAtFraction, spanMs } from './replayFrameData'; +import { useReducedMotion } from './useReducedMotion'; interface ReplayPanelProps { parentChartId: string; @@ -137,6 +138,8 @@ export default function ReplayPanel({ const [isExporting, setIsExporting] = useState(false); const [exportProgress, setExportProgress] = useState(null); + const prefersReducedMotion = useReducedMotion(); + // rAF loop — keeps a ref to the current speed so changing speed doesn't // restart the loop. const speedRef = useRef(speed); @@ -146,6 +149,23 @@ export default function ReplayPanel({ 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; + setFraction((prev) => { + const cur = Math.round(prev * (n - 1)); + const nextStep = Math.min(n - 1, cur + 1); + const next = nextStep / (n - 1); + if (nextStep === n - 1) setPlaying(false); + return next; + }); + }, stepMs); + return () => window.clearInterval(intervalId); + } let rafId: number; let last = performance.now(); const totalMs = spanMs(timeline.dates.length); @@ -164,7 +184,7 @@ export default function ReplayPanel({ }; rafId = requestAnimationFrame(step); return () => cancelAnimationFrame(rafId); - }, [playing, timeline]); + }, [playing, timeline, prefersReducedMotion]); // Reset fraction when timeline rebuilds (model/sequence/metric switch). useEffect(() => { 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; +} From 1253fa0455448081ffc980c03da557e5695b6f37 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Wed, 13 May 2026 13:07:46 -0500 Subject: [PATCH 13/31] feat(replay): replace alert with inline error banner and enrich failure telemetry --- .../inference/replay/ReplayPanel.tsx | 49 +++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/inference/replay/ReplayPanel.tsx b/packages/app/src/components/inference/replay/ReplayPanel.tsx index da76ad6a..faff0406 100644 --- a/packages/app/src/components/inference/replay/ReplayPanel.tsx +++ b/packages/app/src/components/inference/replay/ReplayPanel.tsx @@ -137,6 +137,7 @@ export default function ReplayPanel({ const [speed, setSpeed] = useState(1); const [isExporting, setIsExporting] = useState(false); const [exportProgress, setExportProgress] = useState(null); + const [exportError, setExportError] = useState(null); const prefersReducedMotion = useReducedMotion(); @@ -234,10 +235,18 @@ export default function ReplayPanel({ setPlaying(false); setIsExporting(true); setExportProgress(0); + setExportError(null); + const startedAt = performance.now(); + const hasWebCodecs = typeof VideoEncoder !== 'undefined'; track('inference_replay_export_started', { model: selectedModel, chartType: chartDefinition.chartType, + hasWebCodecs, }); + // oxlint-disable-next-line prefer-const + let stage: 'init' | 'render' | 'encode' | 'flush' | 'mux' = 'init'; + // oxlint-disable-next-line prefer-const + let frameCount = 0; try { const { exportReplayMp4 } = await import('./exportMp4'); // Export duration is deterministic from timeline length, NOT playback speed @@ -246,6 +255,7 @@ export default function ReplayPanel({ 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.'); + stage = 'render'; await exportReplayMp4({ captureRoot: root, fileName: `InferenceX_${selectedModel}_${chartDefinition.chartType}_replay`, @@ -258,19 +268,34 @@ export default function ReplayPanel({ requestAnimationFrame(() => requestAnimationFrame(() => resolve())); }); }, - onProgress: (p) => setExportProgress(p), + onProgress: (p) => { + 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) { console.error('MP4 export failed', error); const message = error instanceof Error ? error.message : 'Export failed.'; - alert( - `MP4 export failed: ${message}\n\nIf you're not on Chrome, try Chrome. MP4 export uses WebCodecs, which may be unavailable in other browsers.`, + const errorName = error instanceof Error ? error.name : 'unknown'; + setExportError( + hasWebCodecs + ? message + : 'MP4 export needs WebCodecs (Chrome, Edge, or Chromium). Your browser does not support it.', ); - track('inference_replay_export_failed', { reason: message }); + track('inference_replay_export_failed', { + reason: message, + errorName, + userAgent: typeof navigator === 'undefined' ? 'unknown' : navigator.userAgent.slice(0, 200), + hasWebCodecs, + frameCount, + durationMs: Math.round(performance.now() - startedAt), + stage, + }); } finally { setIsExporting(false); setExportProgress(null); @@ -412,6 +437,22 @@ export default function ReplayPanel({ : 'Export MP4'}

+ {exportError && ( +
+ MP4 export failed: {exportError} + +
+ )}
); } From 52027f040054e40d60b1088b3a46f2013f92fd84 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Wed, 13 May 2026 13:08:12 -0500 Subject: [PATCH 14/31] fix(replay): capture encoder errors and bound flush with 30s timeout --- .../components/inference/replay/exportMp4.ts | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/inference/replay/exportMp4.ts b/packages/app/src/components/inference/replay/exportMp4.ts index a7589f02..322ed265 100644 --- a/packages/app/src/components/inference/replay/exportMp4.ts +++ b/packages/app/src/components/inference/replay/exportMp4.ts @@ -210,8 +210,18 @@ export async function exportReplayMp4(opts: ExportOptions): Promise { 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. + // oxlint-disable-next-line prefer-const + let encoderError: Error | null = null; + try { for (let i = 0; i < totalFrames; i++) { + if (encoderError !== null) { + // oxlint-disable-next-line no-throw-literal + throw encoderError; + } const t = totalFrames === 1 ? 1 : i / (totalFrames - 1); await renderFrame(t); @@ -246,10 +256,13 @@ export async function exportReplayMp4(opts: ExportOptions): Promise { video: { codec: 'avc', width: outWidth, height: outHeight }, fastStart: 'in-memory', }) as unknown as MuxerLike; + // oxlint-disable-next-line no-loop-func const newEncoder = new VideoEncoder({ + // oxlint-disable-next-line no-loop-func output: (chunk, meta) => newMuxer.addVideoChunk(chunk, meta), - error: (e) => { - throw e; + // oxlint-disable-next-line no-loop-func + error: (e: unknown) => { + encoderError = e instanceof Error ? e : new Error(String(e)); }, }); newEncoder.configure({ @@ -290,7 +303,16 @@ export async function exportReplayMp4(opts: ExportOptions): Promise { } if (!muxer || !encoder) throw new Error('Encoder was never initialized.'); - await encoder.flush(); + await Promise.race([ + encoder.flush(), + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('Encoder flush timed out after 30s.')), 30_000); + }), + ]); + if (encoderError !== null) { + // oxlint-disable-next-line no-throw-literal + throw encoderError; + } encoder.close(); muxer.finalize(); From 041d8c01b8798294bee693817922b3c98e6d4130 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Wed, 13 May 2026 13:09:29 -0500 Subject: [PATCH 15/31] chore(replay): strip restate-the-code docblocks and narration comments --- .../inference/replay/ReplayPanel.tsx | 3 -- .../inference/replay/buildReplayTimeline.ts | 35 ++-------------- .../components/inference/replay/exportMp4.ts | 40 ++++--------------- .../inference/replay/interpolateAtTime.ts | 28 +------------ .../inference/replay/replayFrameData.ts | 20 +--------- 5 files changed, 15 insertions(+), 111 deletions(-) diff --git a/packages/app/src/components/inference/replay/ReplayPanel.tsx b/packages/app/src/components/inference/replay/ReplayPanel.tsx index faff0406..2a95e828 100644 --- a/packages/app/src/components/inference/replay/ReplayPanel.tsx +++ b/packages/app/src/components/inference/replay/ReplayPanel.tsx @@ -141,8 +141,6 @@ export default function ReplayPanel({ const prefersReducedMotion = useReducedMotion(); - // rAF loop — keeps a ref to the current speed so changing speed doesn't - // restart the loop. const speedRef = useRef(speed); speedRef.current = speed; const playingRef = useRef(playing); @@ -187,7 +185,6 @@ export default function ReplayPanel({ return () => cancelAnimationFrame(rafId); }, [playing, timeline, prefersReducedMotion]); - // Reset fraction when timeline rebuilds (model/sequence/metric switch). useEffect(() => { setFraction(0); setPlaying(false); diff --git a/packages/app/src/components/inference/replay/buildReplayTimeline.ts b/packages/app/src/components/inference/replay/buildReplayTimeline.ts index 5298df2d..be076418 100644 --- a/packages/app/src/components/inference/replay/buildReplayTimeline.ts +++ b/packages/app/src/components/inference/replay/buildReplayTimeline.ts @@ -16,10 +16,7 @@ export interface ReplayConfigSeries { hwKey: string; precision: string; template: InferenceData; - /** - * One entry per `dates[i]`. `visible=false` for steps before the config's - * first observation; sticky-last carries the final observation forward. - */ + // One entry per `dates[i]`; sticky-last carries the last observation forward. stepValues: PerStepValue[]; } @@ -35,14 +32,7 @@ export interface StepDomain { y: [number, number]; } -/** - * Compute a tight bounding box at a given step from the configs whose hwKey - * passes `hwFilter`. The replay panel calls this with `activeHwTypes` so axes - * shrink to fit the currently-selected GPU(s) rather than the full timeline - * fleet. - * - * Pass `() => true` to disable filtering and get the full visible domain. - */ +// Axes shrink to fit configs that pass `hwFilter` (usually `activeHwTypes`). export function computeStepDomain( timeline: ReplayTimeline, stepIndex: number, @@ -82,11 +72,8 @@ const safeDomain = (lo: number, hi: number): [number, number] => { return lo < hi ? [lo, hi] : [hi, lo]; }; -/** - * Resolve which x-axis field a chart definition + selected metric should use. - * Mirrors the logic in `useChartData` and `processOverlayChartData` so replay - * frames sit on the same axes the static chart shows. - */ +// Mirrors useChartData + processOverlayChartData so replay frames sit on the +// same axes the static chart shows. function resolveXAxisField( chartDef: ChartDefinition, selectedYAxisMetric: string, @@ -111,18 +98,6 @@ function resolveXAxisField( return chartDef.x; } -/** - * Build the per-config history timeline for a replay session. - * - * For every (config_id) seen in `rows`, produce a sorted observation list of - * (dateMs, x, y) using the same metric resolution the live chart uses. Returns - * the list of distinct dates (ascending) and a global x/y domain spanning the - * whole history so axes can stay pinned during playback. - * - * Filtering matches the chart: rows whose precision is not in - * `selectedPrecisions` are dropped, and rows missing the requested metric on - * the `InferenceData` shape are dropped. Empty input → empty timeline. - */ export function buildReplayTimeline( rows: BenchmarkRow[], chartDef: ChartDefinition, @@ -216,8 +191,6 @@ export function buildReplayTimeline( if (dedup.length === 0) continue; - // Build per-step values: at step i, the config's value is its latest - // observation with dateMs <= dates[i]. Sticky-last carries forward. const stepValues: PerStepValue[] = []; let obsIdx = 0; let latest: { x: number; y: number } | null = null; diff --git a/packages/app/src/components/inference/replay/exportMp4.ts b/packages/app/src/components/inference/replay/exportMp4.ts index 322ed265..3db5b612 100644 --- a/packages/app/src/components/inference/replay/exportMp4.ts +++ b/packages/app/src/components/inference/replay/exportMp4.ts @@ -18,12 +18,8 @@ const CSS_VAR_RE = /var\(--([^)]+)\)/u; const WATERMARK_HEIGHT = 48; const WATERMARK_TEXT = 'InferenceX — github.com/SemiAnalysisAI/InferenceX'; -/** - * Bake `var(--*)` references inside an SVG subtree into resolved colors. - * Mutates the supplied root in place — must only be called on a clone, never - * on the live panel (otherwise the live UI would be stuck on baked colors and - * stop responding to theme switches after an export). - */ +// 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); @@ -70,12 +66,8 @@ function resolveCssVarsForExport(root: HTMLElement) { } } -/** - * Copy each live element's computed text color onto the matching clone element - * as an inline style. html-to-image can't resolve `var(--muted-foreground)` and - * similar tokens used by Tailwind text utilities, so we bake the resolved - * colors directly. Mutates only the clone tree. - */ +// 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, @@ -93,12 +85,8 @@ function bakeTextColorsFromLive(liveRoot: HTMLElement, cloneRoot: HTMLElement) { } } -/** - * Unconstrain the legend's outer scroll viewport so every item appears in the - * rasterized frame. The mini legend itself is already compact — we just need - * to drop the `max-h-[480px] overflow-y-auto` wrapper that engages scroll in - * the live preview. - */ +// 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) { @@ -144,21 +132,7 @@ interface MuxerLike { target: { buffer: ArrayBuffer }; } -/** - * Render the replay timeline to MP4 (H.264) using WebCodecs + mp4-muxer. - * - * Per-frame "screenshot mode" capture: the live panel is cloned into an - * off-screen container, no-export controls are filtered out, CSS variables - * and computed text colors are baked onto the clone, the SVG is re-cloned - * each frame from the live chart so position mutations land in the export, - * and the final canvas is stamped with the InferenceX watermark bar. - * - * Crucially the LIVE panel is never modified — the user-visible UI keeps its - * normal interactive look while the encode loop runs against the clone. - * - * Falls back with a clear error when WebCodecs is unavailable (mainly Firefox - * without the experimental flag). - */ +// Per-frame: caller advances replay → clone live panel → bake colors → toCanvas → encode. export async function exportReplayMp4(opts: ExportOptions): Promise { const { captureRoot: livePanel, diff --git a/packages/app/src/components/inference/replay/interpolateAtTime.ts b/packages/app/src/components/inference/replay/interpolateAtTime.ts index c7bf4b82..6d1cd0cf 100644 --- a/packages/app/src/components/inference/replay/interpolateAtTime.ts +++ b/packages/app/src/components/inference/replay/interpolateAtTime.ts @@ -1,11 +1,3 @@ -/** - * Per-step value for a single config. Precomputed in `buildReplayTimeline` so - * the rAF loop can interpolate without re-scanning calendar history each tick. - * - * - `visible: false` → config has no observation by this step - * - `visible: true` → config has an observation by this step (sticky-last - * carries the value forward through later empty steps) - */ export interface PerStepValue { visible: boolean; x: number; @@ -18,23 +10,8 @@ export interface InterpolationResult { y: number; } -/** - * Resolve a config's (x, y, visible) at a logical step index by linearly - * interpolating between the bracketing per-step values. Step-indexed playback - * gives every observed date equal screen time and collapses out empty calendar - * gaps — the visual emphasis lands on actual benchmark events, not calendar - * months. - * - * Visibility transitions: - * - both invisible: stays invisible. - * - both visible: lerp x/y by `idxFloat - floor`. - * - invisible → visible (config appears in this segment): pop in at the - * destination position from the start of the segment so the new dot is - * immediately on the frontier instead of dragging across from (0,0). - * - visible → invisible (would only occur if a config disappears from the - * dataset, which the upstream sticky-last logic prevents): stays at the - * last visible value. - */ +// 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, @@ -49,7 +26,6 @@ export function interpolateAtStep( 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 }; diff --git a/packages/app/src/components/inference/replay/replayFrameData.ts b/packages/app/src/components/inference/replay/replayFrameData.ts index b9766e95..625ce951 100644 --- a/packages/app/src/components/inference/replay/replayFrameData.ts +++ b/packages/app/src/components/inference/replay/replayFrameData.ts @@ -3,12 +3,6 @@ import type { InferenceData } from '@/components/inference/types'; import type { ReplayTimeline } from './buildReplayTimeline'; import { interpolateAtStep } from './interpolateAtTime'; -/** - * Convert a logical fraction [0, 1] across a replay timeline into a snapshot of - * `InferenceData[]` at the interpolated positions. The snapshot keeps each - * config's full template (hwKey, precision, tp, …) and only swaps x/y, so the - * scatter chart renders it identically to a real benchmark snapshot. - */ export function buildFrameData(timeline: ReplayTimeline, fraction: number): InferenceData[] { const idxFloat = stepFloatAtFraction(fraction, timeline.dates.length); const out: InferenceData[] = []; @@ -20,10 +14,7 @@ export function buildFrameData(timeline: ReplayTimeline, fraction: number): Infe return out; } -/** - * Cubic ease-in-out per segment so the playhead settles on observed dates and - * accelerates between them, instead of cruising at constant speed. - */ +// 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); @@ -33,19 +24,12 @@ export function stepFloatAtFraction(fraction: number, n: number): number { return idxLow + eased; } -/** - * Total wall-clock duration of a full 1× playback. ~800 ms per observed step - * gives each transition room to read; capped at 30 s so very long histories - * still finish in a reasonable time. - */ +// ~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)); } -/** - * Map a fraction to the nearest observed date label for the date overlay. - */ export function dateAtFraction(timeline: ReplayTimeline, fraction: number): string { const dates = timeline.dates; if (dates.length === 0) return ''; From 3c99145e2218aa3c5b74ad0aeb4fabe18bafac72 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Wed, 13 May 2026 13:09:42 -0500 Subject: [PATCH 16/31] fix(replay): pause replay rAF on tab visibilitychange to avoid playhead jump --- .../inference/replay/ReplayPanel.tsx | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/inference/replay/ReplayPanel.tsx b/packages/app/src/components/inference/replay/ReplayPanel.tsx index 2a95e828..d8fdedb2 100644 --- a/packages/app/src/components/inference/replay/ReplayPanel.tsx +++ b/packages/app/src/components/inference/replay/ReplayPanel.tsx @@ -165,7 +165,7 @@ export default function ReplayPanel({ }, stepMs); return () => window.clearInterval(intervalId); } - let rafId: number; + let rafId = 0; let last = performance.now(); const totalMs = spanMs(timeline.dates.length); const step = (now: number) => { @@ -181,8 +181,28 @@ export default function ReplayPanel({ }); 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 () => cancelAnimationFrame(rafId); + return () => { + if (rafId !== 0) cancelAnimationFrame(rafId); + document.removeEventListener('visibilitychange', onVisibility); + }; }, [playing, timeline, prefersReducedMotion]); useEffect(() => { From d19963e22c4ef92f75419235642ea05a923fcaa7 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Wed, 13 May 2026 13:10:01 -0500 Subject: [PATCH 17/31] feat(replay): keyboard nav for scrubber + aria-valuetext announces current date --- .../inference/replay/ReplayPanel.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/app/src/components/inference/replay/ReplayPanel.tsx b/packages/app/src/components/inference/replay/ReplayPanel.tsx index d8fdedb2..a0a1cbfd 100644 --- a/packages/app/src/components/inference/replay/ReplayPanel.tsx +++ b/packages/app/src/components/inference/replay/ReplayPanel.tsx @@ -237,6 +237,43 @@ export default function ReplayPanel({ track('inference_replay_scrubbed', { fraction: value }); }, []); + 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 }); @@ -415,8 +452,10 @@ export default function ReplayPanel({ value={Math.round(fraction * 1000)} step={1} onChange={(e) => 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" /> From 063e46afe782c8f3f37cd11fe87d1cd2c3f86951 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Wed, 13 May 2026 13:10:32 -0500 Subject: [PATCH 18/31] feat(replay): pre-flight WebCodecs detection and disable Export with tooltip --- .../inference/replay/ReplayPanel.tsx | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/inference/replay/ReplayPanel.tsx b/packages/app/src/components/inference/replay/ReplayPanel.tsx index a0a1cbfd..3a542d48 100644 --- a/packages/app/src/components/inference/replay/ReplayPanel.tsx +++ b/packages/app/src/components/inference/replay/ReplayPanel.tsx @@ -141,6 +141,19 @@ export default function ReplayPanel({ 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); @@ -291,7 +304,6 @@ export default function ReplayPanel({ setExportProgress(0); setExportError(null); const startedAt = performance.now(); - const hasWebCodecs = typeof VideoEncoder !== 'undefined'; track('inference_replay_export_started', { model: selectedModel, chartType: chartDefinition.chartType, @@ -354,7 +366,7 @@ export default function ReplayPanel({ setIsExporting(false); setExportProgress(null); } - }, [chartDefinition.chartType, parentChartId, selectedModel, timeline]); + }, [chartDefinition.chartType, parentChartId, selectedModel, timeline, hasWebCodecs]); if (history.isLoading || !timeline) { return ( @@ -481,9 +493,14 @@ export default function ReplayPanel({ size="sm" variant="default" onClick={handleExportMp4} - disabled={isExporting} + disabled={isExporting || !hasWebCodecs} data-testid="replay-export-mp4" className="gap-1" + title={ + hasWebCodecs + ? undefined + : 'MP4 export requires a Chromium-based browser (Chrome, Edge).' + } >
{exportError && (
void; + /** Aborting before completion throws an AbortError without writing the file. */ + signal?: AbortSignal; } const CSS_VAR_RE = /var\(--([^)]+)\)/u; @@ -142,8 +144,17 @@ export async function exportReplayMp4(opts: ExportOptions): Promise { durationSec = 6, bitrate = 6_000_000, onProgress, + signal, } = opts; + 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.'); } @@ -192,6 +203,7 @@ export async function exportReplayMp4(opts: ExportOptions): Promise { try { for (let i = 0; i < totalFrames; i++) { + throwIfAborted(); if (encoderError !== null) { // oxlint-disable-next-line no-throw-literal throw encoderError; From 1a95668918c280dcee59431c0c1add60b4d7d9c0 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Wed, 13 May 2026 13:11:36 -0500 Subject: [PATCH 20/31] test(replay): add unit suite for replayFrameData pure helpers --- .../replay/__tests__/replayFrameData.test.ts | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 packages/app/src/components/inference/replay/__tests__/replayFrameData.test.ts 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..65518027 --- /dev/null +++ b/packages/app/src/components/inference/replay/__tests__/replayFrameData.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; + +import type { InferenceData } from '@/components/inference/types'; + +import type { ReplayTimeline } from '../buildReplayTimeline'; +import { buildFrameData, dateAtFraction, 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 nearest observed date 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('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([]); + }); +}); From e783b542d38eca407201df62b7a157cc3825b551 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Wed, 13 May 2026 13:11:43 -0500 Subject: [PATCH 21/31] test(replay): assert animation progresses and parent-chart toggles re-render --- .../app/cypress/e2e/inference-replay.cy.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/packages/app/cypress/e2e/inference-replay.cy.ts b/packages/app/cypress/e2e/inference-replay.cy.ts index 5d09acd7..0b921d95 100644 --- a/packages/app/cypress/e2e/inference-replay.cy.ts +++ b/packages/app/cypress/e2e/inference-replay.cy.ts @@ -62,6 +62,66 @@ describe('Inference Replay', () => { }); }); + 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') + .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') + .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; From 267913ca7e5d070e3caa0746717b351b0291749d Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Wed, 13 May 2026 19:24:05 -0500 Subject: [PATCH 22/31] chore(replay): drop unused InterpolationResult alias in favor of PerStepValue --- .../src/components/inference/replay/interpolateAtTime.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/app/src/components/inference/replay/interpolateAtTime.ts b/packages/app/src/components/inference/replay/interpolateAtTime.ts index 6d1cd0cf..b1d4c740 100644 --- a/packages/app/src/components/inference/replay/interpolateAtTime.ts +++ b/packages/app/src/components/inference/replay/interpolateAtTime.ts @@ -4,18 +4,12 @@ export interface PerStepValue { y: number; } -export interface InterpolationResult { - 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, -): InterpolationResult { +): PerStepValue { const n = stepValues.length; if (n === 0) return { visible: false, x: 0, y: 0 }; From ba5cbb20285ecb612cb37559cab001e16e695367 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Wed, 13 May 2026 19:24:18 -0500 Subject: [PATCH 23/31] feat(replay): switch dialog launcher to imperative-handle ref API --- .../inference/replay/ReplayLauncher.tsx | 67 ++++++++++--------- .../components/inference/ui/ChartDisplay.tsx | 15 ++--- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/packages/app/src/components/inference/replay/ReplayLauncher.tsx b/packages/app/src/components/inference/replay/ReplayLauncher.tsx index e0a6de88..99d0bacd 100644 --- a/packages/app/src/components/inference/replay/ReplayLauncher.tsx +++ b/packages/app/src/components/inference/replay/ReplayLauncher.tsx @@ -1,6 +1,7 @@ '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'; @@ -20,40 +21,40 @@ interface ReplayLauncherProps { chartDefinition: ChartDefinition; yLabel: string; xLabel: string; - open: boolean; - onOpenChange: (open: boolean) => void; +} + +export interface ReplayLauncherHandle { + open: () => void; } /** - * Controlled dialog that mounts the replay panel lazily. Keeps mp4-muxer, - * html-to-image, and the replay controller out of the main inference bundle - * until the parent opens this dialog (typically from the export menu's MP4 - * entry). + * 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. */ -export default function ReplayLauncher({ - parentChartId, - chartDefinition, - yLabel, - xLabel, - open, - onOpenChange, -}: ReplayLauncherProps) { - return ( - - - Replay over time - {open && ( - - )} - - - ); -} +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/ui/ChartDisplay.tsx b/packages/app/src/components/inference/ui/ChartDisplay.tsx index c04cf386..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,7 +48,7 @@ import ComparisonChangelog from './ComparisonChangelog'; import CustomCosts from './CustomCosts'; import CustomPowers from './CustomPowers'; import GPUGraph from './GPUGraph'; -import ReplayLauncher from '../replay/ReplayLauncher'; +import ReplayLauncher, { type ReplayLauncherHandle } from '../replay/ReplayLauncher'; import TrendChart from './TrendChart'; const ModelArchitectureDiagram = dynamic(() => import('./ModelArchitectureDiagram'), { @@ -159,7 +159,7 @@ export default function ChartDisplay() { } = useComparisonChangelogs(selectedGPUs, selectedDateRange, dateRangeAvailableDates); const [viewModes, setViewModes] = useState>({}); - const [replayOpen, setReplayOpen] = useState>({}); + const replayHandlesRef = useRef>({}); const getViewMode = (index: number): InferenceViewMode => viewModes[index] ?? 'chart'; const handleViewModeChange = (index: number, value: InferenceViewMode) => { setViewModes((prev) => ({ ...prev, [index]: value })); @@ -351,9 +351,7 @@ export default function ChartDisplay() { setIsLegendExpanded={setIsLegendExpanded} exportFileName={`InferenceX_${selectedModel}_${graph.chartDefinition.chartType}`} onExportMp4={ - replayAvailable - ? () => setReplayOpen((prev) => ({ ...prev, [graphIndex]: true })) - : undefined + replayAvailable ? () => replayHandlesRef.current[graphIndex]?.open() : undefined } onExportCsv={() => { const visibleData = graph.data.filter((d) => @@ -551,6 +549,9 @@ export default function ChartDisplay() { })()} {replayAvailable && ( { + replayHandlesRef.current[graphIndex] = handle; + }} parentChartId={`chart-${graphIndex}`} chartDefinition={graph.chartDefinition} yLabel={`${ @@ -559,8 +560,6 @@ export default function ChartDisplay() { ] }`} xLabel={graph.chartDefinition.x_label} - open={Boolean(replayOpen[graphIndex])} - onOpenChange={(o) => setReplayOpen((prev) => ({ ...prev, [graphIndex]: o }))} /> )} From 8ffa63583c03e88b9d2d2f2852d90bdb84f7d6a2 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Wed, 13 May 2026 19:24:28 -0500 Subject: [PATCH 24/31] feat(replay): throttle rAF commits via fractionRef + floor dateAtFraction --- .../replay/__tests__/replayFrameData.test.ts | 88 ++++++++++++++++++- .../inference/replay/replayFrameData.ts | 16 +++- 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/inference/replay/__tests__/replayFrameData.test.ts b/packages/app/src/components/inference/replay/__tests__/replayFrameData.test.ts index 65518027..a1327098 100644 --- a/packages/app/src/components/inference/replay/__tests__/replayFrameData.test.ts +++ b/packages/app/src/components/inference/replay/__tests__/replayFrameData.test.ts @@ -3,7 +3,14 @@ import { describe, expect, it } from 'vitest'; import type { InferenceData } from '@/components/inference/types'; import type { ReplayTimeline } from '../buildReplayTimeline'; -import { buildFrameData, dateAtFraction, spanMs, stepFloatAtFraction } from '../replayFrameData'; +import { + FRACTION_COMMIT_QUANTUM, + buildFrameData, + dateAtFraction, + shouldCommitFraction, + spanMs, + stepFloatAtFraction, +} from '../replayFrameData'; const baseTemplate = { hwKey: 'b200', @@ -113,7 +120,7 @@ describe('dateAtFraction', () => { expect(dateAtFraction(t, 1)).toBe('2025-09-03'); }); - it('returns the nearest observed date for intermediate fractions', () => { + it('returns the date the playhead is currently within for intermediate fractions', () => { const t = makeTimeline(); expect(dateAtFraction(t, 0.5)).toBe('2025-09-02'); }); @@ -124,6 +131,83 @@ describe('dateAtFraction', () => { }); }); +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(); diff --git a/packages/app/src/components/inference/replay/replayFrameData.ts b/packages/app/src/components/inference/replay/replayFrameData.ts index 625ce951..41680892 100644 --- a/packages/app/src/components/inference/replay/replayFrameData.ts +++ b/packages/app/src/components/inference/replay/replayFrameData.ts @@ -30,9 +30,23 @@ export function spanMs(numDates: number): number { 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.round(stepFloatAtFraction(fraction, dates.length)); + const step = Math.floor(stepFloatAtFraction(fraction, dates.length)); return dates[Math.max(0, Math.min(dates.length - 1, step))] ?? ''; } From 4d19aa504a35b58658719b843d23f940e1651477 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Wed, 13 May 2026 19:24:45 -0500 Subject: [PATCH 25/31] feat(replay): typed Mp4ExportError pipeline with stage attribution, encoder cleanup, and letterbox-centered frames --- .../replay/__tests__/exportMp4Errors.test.ts | 74 +++++++ .../components/inference/replay/exportMp4.ts | 202 ++++++++++++++---- 2 files changed, 234 insertions(+), 42 deletions(-) create mode 100644 packages/app/src/components/inference/replay/__tests__/exportMp4Errors.test.ts 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/exportMp4.ts b/packages/app/src/components/inference/replay/exportMp4.ts index ac5923b2..eee8e284 100644 --- a/packages/app/src/components/inference/replay/exportMp4.ts +++ b/packages/app/src/components/inference/replay/exportMp4.ts @@ -1,3 +1,44 @@ +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; @@ -12,6 +53,8 @@ interface ExportOptions { 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; } @@ -128,12 +171,6 @@ function drawWithWatermark( return out; } -interface MuxerLike { - addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata): void; - finalize(): void; - target: { buffer: ArrayBuffer }; -} - // Per-frame: caller advances replay → clone live panel → bake colors → toCanvas → encode. export async function exportReplayMp4(opts: ExportOptions): Promise { const { @@ -144,9 +181,17 @@ export async function exportReplayMp4(opts: ExportOptions): Promise { 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'); @@ -191,24 +236,42 @@ export async function exportReplayMp4(opts: ExportOptions): Promise { let outWidth = 0; let outHeight = 0; - let muxer: MuxerLike | null = null; + 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. - // oxlint-disable-next-line prefer-const - let encoderError: Error | null = null; + // 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 (encoderError !== null) { - // oxlint-disable-next-line no-throw-literal - throw encoderError; + 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 @@ -235,20 +298,36 @@ export async function exportReplayMp4(opts: ExportOptions): Promise { // 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) { - outWidth = Math.max(2, Math.floor(watermarked.width / 2) * 2); - outHeight = Math.max(2, Math.floor(watermarked.height / 2) * 2); + // 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', - }) as unknown as MuxerLike; + }); // oxlint-disable-next-line no-loop-func const newEncoder = new VideoEncoder({ // oxlint-disable-next-line no-loop-func - output: (chunk, meta) => newMuxer.addVideoChunk(chunk, meta), + 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) => { - encoderError = e instanceof Error ? e : new Error(String(e)); + 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({ @@ -269,50 +348,89 @@ export async function exportReplayMp4(opts: ExportOptions): Promise { if (!fctx) throw new Error('Could not allocate frame canvas'); fctx.fillStyle = bgColor; fctx.fillRect(0, 0, outWidth, outHeight); - fctx.drawImage( - watermarked, - 0, - 0, - Math.min(watermarked.width, outWidth), - Math.min(watermarked.height, outHeight), - 0, - 0, - Math.min(watermarked.width, outWidth), - Math.min(watermarked.height, 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); const frame = new VideoFrame(fit, { timestamp: Math.round((i / fps) * 1_000_000) }); + advanceStage('encode'); encoder!.encode(frame, { keyFrame: i % fps === 0 }); frame.close(); onProgress?.(i / (totalFrames - 1)); } - if (!muxer || !encoder) throw new Error('Encoder was never initialized.'); + 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 (encoderError !== null) { - // oxlint-disable-next-line no-throw-literal - throw encoderError; + 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(); - URL.revokeObjectURL(url); + try { + const link = document.createElement('a'); + link.href = url; + link.download = `${fileName}-${Date.now()}.mp4`; + document.body.append(link); + link.click(); + link.remove(); + } finally { + URL.revokeObjectURL(url); + } 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(); } } From caa1bc54fd4c8dd05b64ee857dc189343e2788d3 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Wed, 13 May 2026 19:24:55 -0500 Subject: [PATCH 26/31] feat(replay): fix commitFraction ref invariant, humanize export banner, assert dialog visibility --- .../app/cypress/e2e/inference-replay.cy.ts | 5 +- .../inference/replay/ReplayPanel.tsx | 107 ++++++++++++------ 2 files changed, 79 insertions(+), 33 deletions(-) diff --git a/packages/app/cypress/e2e/inference-replay.cy.ts b/packages/app/cypress/e2e/inference-replay.cy.ts index 0b921d95..bf6978df 100644 --- a/packages/app/cypress/e2e/inference-replay.cy.ts +++ b/packages/app/cypress/e2e/inference-replay.cy.ts @@ -27,8 +27,11 @@ describe('Inference Replay', () => { 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'); - // Either the loading message, the "not enough history" message, or the controls. 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; diff --git a/packages/app/src/components/inference/replay/ReplayPanel.tsx b/packages/app/src/components/inference/replay/ReplayPanel.tsx index 1ac6092e..17140e29 100644 --- a/packages/app/src/components/inference/replay/ReplayPanel.tsx +++ b/packages/app/src/components/inference/replay/ReplayPanel.tsx @@ -22,9 +22,22 @@ import { track } from '@/lib/analytics'; import { cn } from '@/lib/utils'; import { buildReplayTimeline } from './buildReplayTimeline'; -import { buildFrameData, dateAtFraction, spanMs } from './replayFrameData'; +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; @@ -160,6 +173,20 @@ export default function ReplayPanel({ 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 @@ -169,13 +196,11 @@ export default function ReplayPanel({ const n = timeline.dates.length; const intervalId = window.setInterval(() => { if (!playingRef.current) return; - setFraction((prev) => { - const cur = Math.round(prev * (n - 1)); - const nextStep = Math.min(n - 1, cur + 1); - const next = nextStep / (n - 1); - if (nextStep === n - 1) setPlaying(false); - return next; - }); + 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); } @@ -186,13 +211,9 @@ export default function ReplayPanel({ if (!playingRef.current) return; const dt = now - last; last = now; - setFraction((prev) => { - const next = Math.min(1, prev + (dt / totalMs) * speedRef.current); - if (next >= 1) { - setPlaying(false); - } - return next; - }); + 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 @@ -220,6 +241,7 @@ export default function ReplayPanel({ }, [playing, timeline, prefersReducedMotion]); useEffect(() => { + fractionRef.current = 0; setFraction(0); setPlaying(false); }, [timeline]); @@ -239,17 +261,20 @@ export default function ReplayPanel({ setPlaying(false); track('inference_replay_paused', { fraction }); } else { - setFraction((f) => (f >= 1 ? 0 : f)); + if (fractionRef.current >= 1) commitFraction(0, { force: true }); setPlaying(true); track('inference_replay_started', { speed }); } - }, [playing, fraction, speed]); + }, [playing, fraction, speed, commitFraction]); - const handleScrub = useCallback((value: number) => { - setFraction(value); - setPlaying(false); - track('inference_replay_scrubbed', { fraction: value }); - }, []); + const handleScrub = useCallback( + (value: number) => { + commitFraction(value, { force: true }); + setPlaying(false); + track('inference_replay_scrubbed', { fraction: value }); + }, + [commitFraction], + ); const handleScrubKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -294,9 +319,9 @@ export default function ReplayPanel({ }, []); const handleReset = useCallback(() => { - setFraction(0); + commitFraction(0, { force: true }); setPlaying(false); - }, []); + }, [commitFraction]); const handleCancelExport = useCallback(() => { abortRef.current?.abort(); @@ -316,19 +341,21 @@ export default function ReplayPanel({ chartType: chartDefinition.chartType, hasWebCodecs, }); - // oxlint-disable-next-line prefer-const - let stage: 'init' | 'render' | 'encode' | 'flush' | 'mux' = 'init'; - // oxlint-disable-next-line prefer-const + 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 { exportReplayMp4 } = await import('./exportMp4'); + 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.'); - stage = 'render'; await exportReplayMp4({ captureRoot: root, fileName: `InferenceX_${selectedModel}_${chartDefinition.chartType}_replay`, @@ -337,12 +364,16 @@ export default function ReplayPanel({ renderFrame: async (t) => { // flushSync forces React to commit synchronously; two RAFs let the // browser paint before the capture step reads back the DOM. - flushSync(() => setFraction(t)); + 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); }, @@ -366,19 +397,31 @@ export default function ReplayPanel({ 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 + ? `${message}${stageLabel ? ` (${stageLabel})` : ''}` : 'MP4 export needs WebCodecs (Chrome, Edge, or Chromium). Your browser does not support it.', ); track('inference_replay_export_failed', { - reason: message, + 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); From 0c567bafb240a2b435bac90c7b72f66464af57bd Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Thu, 14 May 2026 10:31:52 -0500 Subject: [PATCH 27/31] fix(replay): defer blob URL revoke so mp4 download lands with correct name --- .../components/inference/replay/exportMp4.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/app/src/components/inference/replay/exportMp4.ts b/packages/app/src/components/inference/replay/exportMp4.ts index eee8e284..2c65e809 100644 --- a/packages/app/src/components/inference/replay/exportMp4.ts +++ b/packages/app/src/components/inference/replay/exportMp4.ts @@ -394,16 +394,16 @@ export async function exportReplayMp4(opts: ExportOptions): Promise { const blob = new Blob([muxer.target.buffer], { type: 'video/mp4' }); const url = URL.createObjectURL(blob); - try { - const link = document.createElement('a'); - link.href = url; - link.download = `${fileName}-${Date.now()}.mp4`; - document.body.append(link); - link.click(); - link.remove(); - } finally { - URL.revokeObjectURL(url); - } + 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; From ff77e12e51295e664a82035ea33cb084bcebd454 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Thu, 14 May 2026 12:52:25 -0500 Subject: [PATCH 28/31] fix(replay): set VideoFrame duration so mp4-muxer accepts encoded chunks --- packages/app/src/components/inference/replay/exportMp4.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/inference/replay/exportMp4.ts b/packages/app/src/components/inference/replay/exportMp4.ts index 2c65e809..8d5fe31c 100644 --- a/packages/app/src/components/inference/replay/exportMp4.ts +++ b/packages/app/src/components/inference/replay/exportMp4.ts @@ -361,7 +361,12 @@ export async function exportReplayMp4(opts: ExportOptions): Promise { const dstY = Math.floor((outHeight - drawH) / 2); fctx.drawImage(watermarked, srcX, srcY, drawW, drawH, dstX, dstY, drawW, drawH); - const frame = new VideoFrame(fit, { timestamp: Math.round((i / fps) * 1_000_000) }); + // 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(); From 73b89c00609834ab009f33a5ef36b1fc8806ec26 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Thu, 14 May 2026 13:15:19 -0500 Subject: [PATCH 29/31] fix(replay): collapse .no-export boxes and clear date overlay of legend --- .../components/inference/replay/ReplayPanel.tsx | 15 ++++++++++++++- .../src/components/inference/replay/exportMp4.ts | 8 ++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/inference/replay/ReplayPanel.tsx b/packages/app/src/components/inference/replay/ReplayPanel.tsx index 17140e29..6d3f5692 100644 --- a/packages/app/src/components/inference/replay/ReplayPanel.tsx +++ b/packages/app/src/components/inference/replay/ReplayPanel.tsx @@ -114,9 +114,22 @@ export default function ReplayPanel({ 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, wRect.right - sRect.right + 10), + right: Math.max(0, rightAnchor), top: sRect.top - wRect.top + 24, }; if (prev && prev.right === next.right && prev.top === next.top) return prev; diff --git a/packages/app/src/components/inference/replay/exportMp4.ts b/packages/app/src/components/inference/replay/exportMp4.ts index 8d5fe31c..89c40916 100644 --- a/packages/app/src/components/inference/replay/exportMp4.ts +++ b/packages/app/src/components/inference/replay/exportMp4.ts @@ -285,6 +285,14 @@ export async function exportReplayMp4(opts: ExportOptions): Promise { 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'; + } + const captured = await toCanvas(clone, { pixelRatio: 1, cacheBust: false, From cbdc8b4800acd66d6f795995c7ddde37eb0250c7 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Thu, 14 May 2026 13:54:44 -0500 Subject: [PATCH 30/31] fix(replay): strip dangling legend divider when bottom controls collapse --- .../components/inference/replay/exportMp4.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/app/src/components/inference/replay/exportMp4.ts b/packages/app/src/components/inference/replay/exportMp4.ts index 89c40916..676c30ff 100644 --- a/packages/app/src/components/inference/replay/exportMp4.ts +++ b/packages/app/src/components/inference/replay/exportMp4.ts @@ -293,6 +293,26 @@ export async function exportReplayMp4(opts: ExportOptions): Promise { 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, From 7f58bbac00c3a23fab8a93f6c7d4e0e125a801d9 Mon Sep 17 00:00:00 2001 From: adibarra <93070681+adibarra@users.noreply.github.com> Date: Thu, 14 May 2026 16:38:13 -0500 Subject: [PATCH 31/31] test(replay): match the actual roofline-path class name --- packages/app/cypress/e2e/inference-replay.cy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/cypress/e2e/inference-replay.cy.ts b/packages/app/cypress/e2e/inference-replay.cy.ts index bf6978df..69e31c4d 100644 --- a/packages/app/cypress/e2e/inference-replay.cy.ts +++ b/packages/app/cypress/e2e/inference-replay.cy.ts @@ -99,7 +99,7 @@ describe('Inference Replay', () => { 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') + cy.get('[data-testid="replay-panel-chart-0"] svg path.roofline-path') .first() .invoke('attr', 'd') .then((beforeD) => { @@ -115,7 +115,7 @@ describe('Inference Replay', () => { win.dispatchEvent(new win.PopStateEvent('popstate')); }); cy.wait(400); - cy.get('[data-testid="replay-panel-chart-0"] svg path.roofline') + cy.get('[data-testid="replay-panel-chart-0"] svg path.roofline-path') .first() .invoke('attr', 'd') .should((afterD) => {