Live demo
Code example
import { TimelineEngine, type Track, type TimelineReadonly, TimelineProvider, Timeline, useTimeline, useTimelineVisibleClips, CanvasRenderer, type CanvasRendererStats, fromSeconds,} from '@techsquidtv/canvas-timeline';import { useCallback, useMemo, useRef, useState } from 'react';import { generateStressTestData, type BenchmarkConfig, initialNumTracks, initialClipsPerTrack, initialDurationSeconds, initialKeyframesPerClip,} from './timeline-demo-data';import { BenchmarkControls, type BenchmarkDisplayOptions,} from './timeline-benchmark-controls';import { opacityKeyframeProperty } from './demo-keyframe-properties';import type { DemoMetrics } from './demo-instrumentation';import { RulerDOM, DOMClip } from './DOMTimelineComponents';import '@techsquidtv/canvas-timeline/styles.css';
function TimelineLayers({ displayOptions }: { displayOptions: BenchmarkDisplayOptions }) { const { state } = useTimeline();
const isDom = displayOptions.rendererType === 'dom'; const tracks = state.tracks;
return ( <> {isDom && <RulerDOM ruler={{ format: 'seconds' }} showLabels={true} />} <Timeline.PlayheadArea /> <Timeline.PlayheadGrabber /> <Timeline.TrackList className="timeline-track-list-overlay"> {isDom ? ( <DOMTrackRows tracks={tracks} /> ) : ( tracks.map((track) => <Timeline.Track key={track.id} trackId={track.id} />) )} </Timeline.TrackList> <Timeline.ClipInteractionLayer /> <Timeline.RangeSelector /> </> );}
function DOMTrackRows({ tracks }: { tracks: readonly TimelineReadonly<Track>[] }) { const visibleClips = useTimelineVisibleClips();
return ( <> {tracks.map((track) => ( <Timeline.Track key={track.id} trackId={track.id} className="timeline-dom-track"> {visibleClips .filter((clip) => clip.track.id === track.id) .map((clip) => ( <DOMClip key={clip.clip.id} clip={clip} showLabels={true} /> ))} </Timeline.Track> ))} </> );}
function InnerTimeline({ displayOptions, onRenderStats,}: { displayOptions: BenchmarkDisplayOptions; onRenderStats?: (stats: CanvasRendererStats) => void;}) { return ( <div className="timeline-stage"> <Timeline.Root className="timeline-fill"> {displayOptions.rendererType === 'canvas' && ( <CanvasRenderer showClipLabels={true} showRulerLabels={true} onRenderStats={onRenderStats} /> )} <TimelineLayers displayOptions={displayOptions} /> </Timeline.Root> </div> );}
export function TimelineStressTest({ metrics }: { metrics?: DemoMetrics }) { const [config, setConfig] = useState<BenchmarkConfig>({ numTracks: initialNumTracks, clipsPerTrack: initialClipsPerTrack, durationSeconds: initialDurationSeconds, keyframesPerClip: initialKeyframesPerClip, }); const [displayOptions, setDisplayOptions] = useState<BenchmarkDisplayOptions>({ rendererType: 'canvas', }); const [collectRenderStats, setCollectRenderStats] = useState(false); const renderStatsRef = useRef<CanvasRendererStats[]>([]);
const handleRenderStats = useCallback((stats: CanvasRendererStats) => { renderStatsRef.current.push(stats); if (renderStatsRef.current.length > 5000) { renderStatsRef.current.splice(0, renderStatsRef.current.length - 5000); } }, []);
const { tracks, markers } = useMemo(() => { return generateStressTestData( config.numTracks, config.clipsPerTrack, config.durationSeconds, config.keyframesPerClip ); }, [config.numTracks, config.clipsPerTrack, config.durationSeconds, config.keyframesPerClip]);
const engine = useMemo(() => { return new TimelineEngine({ duration: fromSeconds(config.durationSeconds), playheadTime: fromSeconds(0), zoomScale: 50, tracks, markers, keyframeProperties: [opacityKeyframeProperty], }); }, [tracks, markers, config.durationSeconds]);
const totalClips = useMemo(() => { return tracks.reduce((sum, track) => sum + track.clips.length, 0); }, [tracks]);
// Re-key the timeline container on engine changes to force clean mount/unmount of renderer & worker const engineKey = `${config.numTracks}-${config.clipsPerTrack}-${config.durationSeconds}-${config.keyframesPerClip}`;
return ( <div className="timeline-stress-layout"> {/* Benchmark controls panel */} <TimelineProvider engine={engine} key={engineKey}> <BenchmarkControls config={config} onApplyConfig={setConfig} totalClips={totalClips} displayOptions={displayOptions} onDisplayOptionsChange={setDisplayOptions} onCollectRenderStatsChange={setCollectRenderStats} renderStatsRef={renderStatsRef} metrics={metrics} />
{/* Timeline shell */} <div className="timeline-shell"> <InnerTimeline displayOptions={displayOptions} onRenderStats={collectRenderStats ? handleRenderStats : undefined} />
{/* Bottom Scrollbar row */} <div className="timeline-scrollbar-row"> <Timeline.ViewportScrollbar> <Timeline.ViewportScrollbarThumb> <Timeline.ViewportScrollbarHandle side="start" /> <Timeline.ViewportScrollbarHandle side="end" /> </Timeline.ViewportScrollbarThumb> </Timeline.ViewportScrollbar> </div> </div> </TimelineProvider> </div> );}const demoClipColors = [ 'oklch(0.62 0.16 250)', 'oklch(0.68 0.14 145)', 'oklch(0.72 0.16 70)', 'oklch(0.65 0.17 25)', 'oklch(0.58 0.18 305)', 'oklch(0.64 0.12 195)',] as const;
export function getDemoClipColor(index: number) { return demoClipColors[index % demoClipColors.length];}import { createTimelineScalarKeyframeProperty } from '@techsquidtv/canvas-timeline';
export const opacityKeyframeProperty = createTimelineScalarKeyframeProperty({ id: 'opacity', label: 'Opacity', min: 0, max: 1, defaultValue: 1, formatValue: (value) => `${Math.round(value * 100)}%`, getBaseValue: (clip) => clip.opacity ?? 1,});import type { MediaMetricEvent, TimelineMetricContext, TimelineMetricOperation, WorkerRenderMetric,} from './metrics-common';
export interface DemoMetrics { onTimelineFpsSample?: (context: TimelineMetricContext, fps: number) => void; onTimelineFrameTimes?: ( context: TimelineMetricContext, frameTimes: readonly number[], operation: TimelineMetricOperation ) => void; onTimelineInteractionLatencies?: ( context: TimelineMetricContext, operation: TimelineMetricOperation, latencies: readonly number[] ) => void; onTimelineWorkerRenderStats?: ( context: TimelineMetricContext, renderStats: readonly WorkerRenderMetric[] ) => void; onMediaLoadFailed?: (event: MediaMetricEvent) => void; onMediaDecodeTime?: (event: MediaMetricEvent, durationMs: number) => void;}type MetricAttributeValue = string | number | boolean;export type MetricAttributes = Record<string, MetricAttributeValue>;
type RendererType = 'canvas' | 'dom';export type TimelineMetricOperation = | 'scrub' | 'zoom' | 'pan' | 'drag' | 'trim' | 'drag-rejected' | 'commit' | 'undo';
export interface NormalizedRouteMetric { route: string; section: string;}
export interface TimelineMetricContext { demoId: string; renderer: RendererType; trackCount: number; clipCount: number; keyframeCount?: number; benchmarkScope?: 'engine' | 'interactive';}
export interface WorkerRenderMetric { reason: string; durationMs: number;}
export interface MediaMetricEvent { demoId: string; adapter: 'html-media' | 'mediabunny'; mediaType: 'video' | 'audio' | 'video_audio';}
export function normalizeMetricRoute(pathname: string): NormalizedRouteMetric { const segments = pathname.split('/').filter(Boolean); const [section] = segments;
if (!section) { return { route: '/', section: 'home' }; }
if (section === 'packages') { if (segments[1] === 'react' && segments[2] === 'registry') { return { route: segments.length > 3 ? '/packages/react/registry/:slug' : '/packages/react/registry', section: 'registry', }; }
if (segments[2] === 'api') { return { route: segments.length > 3 ? '/packages/:package/api/:symbol' : '/packages/:package/api', section: 'packages-api', }; }
return { route: segments.length > 1 ? '/packages/:package' : '/packages', section: 'packages', }; }
if (section === 'docs') { return { route: segments.length > 1 ? '/docs/:slug' : '/docs', section: 'docs', }; }
if (section === 'demos') { return { route: segments.length > 1 ? '/demos/:slug' : '/demos', section: 'demos', }; }
if (section === 'blog') { return { route: segments.length > 1 ? '/blog/:slug' : '/blog', section: 'blog', }; }
return { route: `/${section}`, section, };}
function bucketCount(value: number): string { if (value <= 0) { return '0'; } if (value <= 10) { return '1-10'; } if (value <= 50) { return '11-50'; } if (value <= 100) { return '51-100'; } if (value <= 500) { return '101-500'; } if (value <= 1000) { return '501-1000'; } return '1001+';}
export function sampleMetricValues(values: readonly number[], maxSamples = 60): number[] { if (values.length <= maxSamples) { return [...values]; }
const step = values.length / maxSamples; return Array.from({ length: maxSamples }, (_, index) => values[Math.floor(index * step)] ?? 0);}
export function sampleMetricItems<T>(values: readonly T[], maxSamples = 60): T[] { if (values.length <= maxSamples) { return [...values]; }
const step = values.length / maxSamples; return Array.from({ length: maxSamples }, (_, index) => values[Math.floor(index * step)]).filter( (value): value is T => value !== undefined );}
export function timelineMetricAttributes(context: TimelineMetricContext): MetricAttributes { return { demo_id: context.demoId, renderer: context.renderer, track_count_bucket: bucketCount(context.trackCount), clip_count_bucket: bucketCount(context.clipCount), ...(context.keyframeCount === undefined ? {} : { keyframe_count_bucket: bucketCount(context.keyframeCount) }), ...(context.benchmarkScope === undefined ? {} : { benchmark_scope: context.benchmarkScope }), };}import { TimelineEngine, type TimelineEditPreview, type CanvasRendererStats, compareRational, fromSeconds, round, toSeconds,} from '@techsquidtv/canvas-timeline';import type { TimelineMetricOperation } from './metrics-common';
export type BenchmarkType = 'scrub' | 'zoom' | 'drag' | 'commit';
export const benchmarkTitles: Record<BenchmarkType, string> = { scrub: 'Scrub FPS', zoom: 'Zoom FPS', drag: 'Drag Preview', commit: 'Commit/Undo',};
interface BenchmarkMetric { label: string; value: number | string;}
interface InteractionSamples { operation: TimelineMetricOperation; values: number[];}
export type BenchmarkResult = | { type: BenchmarkType; status: 'unavailable'; note: string } | { type: BenchmarkType; status: 'complete'; grade: string | null; note: string; metrics: BenchmarkMetric[]; frameTimes: number[]; interactions: InteractionSamples[]; workerStats: CanvasRendererStats[]; };
interface BenchmarkOptions { engine: TimelineEngine; type: BenchmarkType; collectWorkerStats: boolean; getWorkerStats: () => CanvasRendererStats[]; onMeasurementStart: () => void; onMeasurementEnd: () => void; onResult: (result: BenchmarkResult) => void;}
const percentile = (values: number[]) => { const sorted = [...values].sort((a, b) => a - b); return sorted[Math.floor((sorted.length - 1) * 0.95)] ?? 0;};
function timingMetrics(label: string, values: number[]): BenchmarkMetric[] { const total = values.reduce((sum, value) => sum + value, 0); return [ { label: `${label} Avg`, value: `${round(values.length > 0 ? total / values.length : 0, 2)}ms`, }, { label: `${label} P95`, value: `${round(percentile(values), 2)}ms` }, { label: `${label} Max`, value: `${round(values.length > 0 ? Math.max(...values) : 0, 2)}ms` }, ];}
function gradeForFrames(avgFps: number, minFps: number) { if (avgFps >= 58 && minFps >= 45) { return 'S+'; } if (avgFps >= 55 && minFps >= 40) { return 'A'; } if (avgFps >= 45 && minFps >= 30) { return 'B'; } if (avgFps >= 30 && minFps >= 20) { return 'C'; } return 'D';}
/** Uses separate history with two retained documents, including at maximum data density. */export function createCommitBenchmarkEngine(engine: TimelineEngine) { const state = engine.getState(); return new TimelineEngine({ tracks: state.tracks, markers: state.markers, clipGroups: state.clipGroups, duration: state.duration, zoomScale: state.zoomScale, keyframeProperties: engine.listKeyframeProperties(), history: { maxEntries: 2, maxBytes: Number.MAX_SAFE_INTEGER }, });}
/** Runs all workloads through one warmup, measurement, completion, and cancellation lifecycle. */export function runTimelineBenchmark(options: BenchmarkOptions): () => void { const { engine, type } = options; const isFrameBenchmark = type === 'scrub' || type === 'zoom'; const originalPlayhead = engine.playheadTime; const originalZoom = engine.zoomScale; const originalScroll = engine.scrollLeft; const frameTimes: number[] = []; const primaryTimes: number[] = []; const secondaryTimes: number[] = []; const eventCounts = { render: 0, settled: 0, zoom: 0, scroll: 0 }; const unsubscribers: Array<() => void> = []; let measuring = false; let closed = false; let frameId = 0; let finishTimeout = 0; let measuredStartedAt: number | null = null; let lastFrameAt: number | null = null; let warmupStep = 0; let measuredStep = 0; let editEngine: TimelineEngine | null = null; const warmupSteps = type === 'commit' ? 5 : 30; const measuredSteps = type === 'commit' ? 40 : 240;
const cleanup = () => { if (closed) { return; } closed = true; measuring = false; cancelAnimationFrame(frameId); window.clearTimeout(finishTimeout); unsubscribers.forEach((unsubscribe) => unsubscribe()); options.onMeasurementEnd(); if (type === 'scrub') { engine.updatePlayhead(originalPlayhead); } if (type === 'zoom') { engine.setZoomScale(originalZoom); engine.setScrollLeft(originalScroll); } if (type === 'drag') { engine.cancelEdit(); } editEngine = null; };
const unavailable = (note: string) => { cleanup(); options.onResult({ type, status: 'unavailable', note }); };
if (type === 'zoom') { const events = { render: 'render', settled: 'state:settled', zoom: 'zoom:change', scroll: 'scroll:change', } as const; for (const key of Object.keys(events) as Array<keyof typeof events>) { unsubscribers.push( engine.on(events[key], () => { if (measuring) { eventCounts[key]++; } }) ); } }
const duration = toSeconds(engine.getState().duration ?? fromSeconds(120)); const viewportWidth = engine.getState().viewportWidth ?? 1000; const minZoom = Math.max( duration > 0 ? viewportWidth / duration : originalZoom, originalZoom * 0.65 ); const maxZoom = Math.max(minZoom, originalZoom * 1.6);
const recordPreview = (preview: TimelineEditPreview, elapsed: number) => { if (!measuring) { return; } (preview.valid ? primaryTimes : secondaryTimes).push(elapsed); };
const performStep = (step: number) => { if (type === 'scrub' || type === 'zoom') { const startedAt = performance.now(); if (type === 'scrub') { engine.updatePlayhead(fromSeconds(((step % 60) / 60) * Math.min(30, duration))); } else { const phase = (step / 60) * Math.PI * 2; engine.setZoomScale((minZoom + maxZoom) / 2 + (Math.sin(phase) * (maxZoom - minZoom)) / 2); } if (measuring) { primaryTimes.push(performance.now() - startedAt); } return; } const target = type === 'commit' ? (editEngine ??= createCommitBenchmarkEngine(engine)) : engine; const clip = target.tracks[0]?.clips[0]; if (!clip) { throw new Error('No clips available. Add clips and regenerate the timeline.'); } const originalStart = clip.timelineStart; const originalStartSec = toSeconds(originalStart); if (type === 'drag') { const startedAt = performance.now(); const preview = target.previewEdit({ type: 'move', clipId: step % 2 === 0 ? clip.id : 'benchmark-missing-clip', startTime: fromSeconds( Math.max(0, originalStartSec + Math.sin((step / measuredSteps) * Math.PI * 4) * 5) ), }); recordPreview(preview, performance.now() - startedAt); return; } const commitStartedAt = performance.now(); const result = target.commitEdit({ type: 'move', clipId: clip.id, snap: false, startTime: fromSeconds(originalStartSec + 1 + (step % 2)), }); const commitMs = performance.now() - commitStartedAt; if (!result.committed || !target.canUndo) { throw new Error('The edit did not create an undoable commit. No results recorded.'); } const undoStartedAt = performance.now(); target.undo(); const undoMs = performance.now() - undoStartedAt; const restored = target.geometry.getClip(clip.id)?.clip; if (!restored || compareRational(restored.timelineStart, originalStart) !== 0) { throw new Error('Undo did not restore the clip. No results recorded.'); } if (measuring) { primaryTimes.push(commitMs); secondaryTimes.push(undoMs); } };
const finish = () => { if (closed) { return; } const workerStats = options.collectWorkerStats && isFrameBenchmark ? [...options.getWorkerStats()] : []; const metrics: BenchmarkMetric[] = []; let grade: string | null = null; const interactions: InteractionSamples[] = []; let note: string; if (isFrameBenchmark) { const totalMs = frameTimes.reduce((sum, value) => sum + value, 0); const avgFps = totalMs > 0 ? (frameTimes.length * 1000) / totalMs : 0; const minFps = frameTimes.length > 0 ? 1000 / Math.max(...frameTimes) : 0; grade = gradeForFrames(avgFps, minFps); metrics.push( { label: 'Average FPS', value: round(avgFps, 1) }, { label: 'Minimum FPS', value: round(minFps, 1) }, { label: 'P95 Frame', value: `${round(percentile(frameTimes), 1)}ms` }, { label: 'Total Frames', value: frameTimes.length }, ...timingMetrics(type === 'scrub' ? 'Playhead' : 'setZoom', primaryTimes) ); if (type === 'zoom') { metrics.push({ label: 'Engine Events', value: `r${eventCounts.render} / s${eventCounts.settled} / z${eventCounts.zoom} / x${eventCounts.scroll}`, }); } if (options.collectWorkerStats) { metrics.push( { label: 'Worker Draws', value: workerStats.length }, ...timingMetrics( 'Worker', workerStats.map((stats) => stats.drawDurationMs) ) ); } else { metrics.push({ label: 'Worker Stats', value: 'N/A (DOM Mode)' }); } interactions.push({ operation: type === 'zoom' ? 'zoom' : 'scrub', values: primaryTimes }); note = `${type === 'zoom' ? 'Zoom exercises the displayed timeline and renderer.' : 'Scrub measures playhead movement against the displayed timeline.'} Timings exclude 30 warmup frames, followed by four measured seconds.`; } else if (type === 'drag') { metrics.push( { label: 'Accepted Previews', value: primaryTimes.length }, ...timingMetrics('Accepted', primaryTimes), { label: 'Rejected Previews', value: secondaryTimes.length }, ...timingMetrics('Rejected', secondaryTimes) ); interactions.push( { operation: 'drag', values: primaryTimes }, { operation: 'drag-rejected', values: secondaryTimes } ); note = 'Times previewEdit calls and live subscribers. Rejections use a missing clip ID. Pointer handling and paint latency are outside these timings.'; } else { metrics.push( { label: 'Commit/Undo Pairs', value: primaryTimes.length }, ...timingMetrics('Commit', primaryTimes), ...timingMetrics('Undo', secondaryTimes) ); interactions.push( { operation: 'commit', values: primaryTimes }, { operation: 'undo', values: secondaryTimes } ); note = 'Engine-only timings on an isolated copy, retaining two snapshots without a byte limit. Each undo is verified. The displayed document and its history are preserved.'; } cleanup(); options.onResult({ type, status: 'complete', grade, note, metrics, frameTimes, interactions, workerStats, }); };
const tick = () => { if (closed) { return; } try { if (warmupStep < warmupSteps) { performStep(warmupStep++); } else { const now = performance.now(); if (measuredStartedAt === null) { measuredStartedAt = now; options.onMeasurementStart(); measuring = true; } if (lastFrameAt !== null && isFrameBenchmark) { frameTimes.push(now - lastFrameAt); } lastFrameAt = now; performStep(measuredStep++); if (isFrameBenchmark ? now - measuredStartedAt >= 4000 : measuredStep >= measuredSteps) { measuring = false; if (isFrameBenchmark && options.collectWorkerStats) { finishTimeout = window.setTimeout(finish, 100); } else { finish(); } return; } } frameId = requestAnimationFrame(tick); } catch (error) { unavailable( error instanceof Error ? error.message : 'Benchmark failed. No results recorded.' ); } };
if (type !== 'commit') { engine.pause(); } frameId = requestAnimationFrame(tick); return cleanup;}import { useTimeline, type CanvasRendererStats } from '@techsquidtv/canvas-timeline';import { type Dispatch, type FormEvent, type MutableRefObject, type SetStateAction, useCallback, useEffect, useRef, useState,} from 'react';import type { DemoMetrics } from './demo-instrumentation';import type { TimelineMetricContext } from './metrics-common';import type { BenchmarkConfig } from './timeline-demo-data';import { benchmarkTitles, runTimelineBenchmark, type BenchmarkResult, type BenchmarkType,} from './timeline-benchmarks';
export interface BenchmarkDisplayOptions { rendererType: 'canvas' | 'dom';}
interface BenchmarkControlsProps { config: BenchmarkConfig; onApplyConfig: (newConfig: BenchmarkConfig) => void; totalClips: number; displayOptions: BenchmarkDisplayOptions; onDisplayOptionsChange: Dispatch<SetStateAction<BenchmarkDisplayOptions>>; onCollectRenderStatsChange: (collect: boolean) => void; renderStatsRef: MutableRefObject<CanvasRendererStats[]>; metrics?: DemoMetrics;}
export function BenchmarkControls({ config, onApplyConfig, totalClips, displayOptions, onDisplayOptionsChange, onCollectRenderStatsChange, renderStatsRef, metrics,}: BenchmarkControlsProps) { const { engine } = useTimeline();
// Local draft state for controls so slider dragging is smooth const [draftTracks, setDraftTracks] = useState(config.numTracks); const [draftClips, setDraftClips] = useState(config.clipsPerTrack); const [draftDuration, setDraftDuration] = useState(config.durationSeconds); const [draftKeyframes, setDraftKeyframes] = useState(config.keyframesPerClip);
// FPS tracking const [fps, setFps] = useState(60); const fpsRef = useRef<{ frames: number; lastTime: number }>({ frames: 0, lastTime: 0 });
// Benchmark scrubbing state const [isBenchmarking, setIsBenchmarking] = useState(false); const [benchmarkResult, setBenchmarkResult] = useState<BenchmarkResult | null>(null); const cleanupRef = useRef<(() => void) | null>(null); const lastFpsMetricAtRef = useRef(0);
const createMetricContext = useCallback( (): TimelineMetricContext => ({ demoId: 'stress-test', renderer: displayOptions.rendererType, trackCount: config.numTracks, clipCount: totalClips, keyframeCount: totalClips * config.keyframesPerClip, }), [config.numTracks, config.keyframesPerClip, displayOptions.rendererType, totalClips] );
useEffect(() => { return () => { cleanupRef.current?.(); onCollectRenderStatsChange(false); }; }, [onCollectRenderStatsChange]);
// Monitor FPS in the background useEffect(() => { let animId: number; fpsRef.current.lastTime = performance.now();
const tick = () => { const now = performance.now(); fpsRef.current.frames++;
if (now - fpsRef.current.lastTime >= 500) { const computedFps = Math.round( (fpsRef.current.frames * 1000) / (now - fpsRef.current.lastTime) ); setFps(computedFps); if (now - lastFpsMetricAtRef.current >= 5000) { metrics?.onTimelineFpsSample?.(createMetricContext(), computedFps); lastFpsMetricAtRef.current = now; } fpsRef.current.frames = 0; fpsRef.current.lastTime = now; } animId = requestAnimationFrame(tick); };
animId = requestAnimationFrame(tick); return () => cancelAnimationFrame(animId); }, [createMetricContext, metrics]);
const runBenchmark = (type: BenchmarkType) => { if (cleanupRef.current !== null) { return; } setIsBenchmarking(true); setBenchmarkResult(null); renderStatsRef.current = []; const context: TimelineMetricContext = { ...createMetricContext(), benchmarkScope: type === 'commit' ? 'engine' : 'interactive', }; cleanupRef.current = runTimelineBenchmark({ engine, type, collectWorkerStats: displayOptions.rendererType === 'canvas', getWorkerStats: () => renderStatsRef.current, onMeasurementStart: () => { renderStatsRef.current = []; onCollectRenderStatsChange( displayOptions.rendererType === 'canvas' && (type === 'scrub' || type === 'zoom') ); }, onMeasurementEnd: () => onCollectRenderStatsChange(false), onResult: (result) => { cleanupRef.current = null; setIsBenchmarking(false); setBenchmarkResult(result); if (result.status !== 'complete') { return; } if (result.frameTimes.length > 0 && (type === 'scrub' || type === 'zoom')) { metrics?.onTimelineFrameTimes?.(context, result.frameTimes, type); } for (const sample of result.interactions) { metrics?.onTimelineInteractionLatencies?.(context, sample.operation, sample.values); } if (result.workerStats.length > 0) { metrics?.onTimelineWorkerRenderStats?.( context, result.workerStats.map((entry) => ({ reason: entry.reason, durationMs: entry.drawDurationMs, })) ); } }, }); };
const handleApply = (e: FormEvent) => { e.preventDefault(); cleanupRef.current?.(); cleanupRef.current = null; setIsBenchmarking(false); setBenchmarkResult(null); onApplyConfig({ numTracks: draftTracks, clipsPerTrack: draftClips, durationSeconds: draftDuration, keyframesPerClip: draftKeyframes, }); };
return ( <div className="timeline-benchmark-panel"> {/* Configuration Form */} <form onSubmit={handleApply} className="timeline-benchmark-form"> <h3 className="timeline-benchmark-section-title">Configure Stress Test</h3>
<div className="timeline-benchmark-control-group"> <label htmlFor="input-tracks"> <span>Tracks:</span> <span className="value-badge">{draftTracks}</span> </label> <input id="input-tracks" type="range" min="1" max="250" value={draftTracks} onChange={(e) => setDraftTracks(parseInt(e.target.value, 10))} className="timeline-control-slider" /> </div>
<div className="timeline-benchmark-control-group"> <label htmlFor="input-clips"> <span>Clips per Track:</span> <span className="value-badge">{draftClips}</span> </label> <input id="input-clips" type="range" min="0" max="50" value={draftClips} onChange={(e) => setDraftClips(parseInt(e.target.value, 10))} className="timeline-control-slider" /> </div>
<div className="timeline-benchmark-control-group"> <label htmlFor="input-duration"> <span>Duration:</span> <span className="value-badge">{draftDuration}s</span> </label> <input id="input-duration" type="range" min="10" max="1800" step="10" value={draftDuration} onChange={(e) => setDraftDuration(parseInt(e.target.value, 10))} className="timeline-control-slider" /> </div>
<div className="timeline-benchmark-control-group"> <label htmlFor="input-keyframes"> <span>Keyframes per Clip (data):</span> <span className="value-badge">{draftKeyframes}</span> </label> <input id="input-keyframes" type="range" min="0" max="16" value={draftKeyframes} onChange={(e) => setDraftKeyframes(parseInt(e.target.value, 10))} className="timeline-control-slider" /> </div>
<button type="submit" className="timeline-benchmark-submit-btn"> Apply & Regenerate </button> </form> <p className="benchmark-note"> Keyframe density adds edit, serialization, and history work. Keyframe curves are hidden in both renderers. </p>
<div className="timeline-benchmark-diagnostics"> <h3 className="timeline-benchmark-section-title">Diagnostics</h3> <div className="timeline-benchmark-control-group timeline-benchmark-control-group-spaced"> <label htmlFor="select-renderer" className="timeline-benchmark-renderer-label"> Renderer: </label> <select id="select-renderer" className="timeline-control-select" disabled={isBenchmarking} value={displayOptions.rendererType} onChange={(event) => { setBenchmarkResult(null); onDisplayOptionsChange((current) => ({ ...current, rendererType: event.target.value as 'canvas' | 'dom', })); }} > <option value="canvas">Canvas (Worker)</option> <option value="dom">React DOM (Main Thread)</option> </select> </div> </div>
{/* Performance HUD */} <div className="timeline-performance-hud"> <h3 className="timeline-benchmark-section-title">Performance Monitor</h3>
<div className="hud-grid"> <div className="hud-card"> <span className="hud-label">FPS</span> <span className={`hud-value ${fps >= 55 ? 'value-good' : fps >= 30 ? 'value-warning' : 'value-bad'}`} > {fps} </span> </div> <div className="hud-card"> <span className="hud-label">Tracks</span> <span className="hud-value value-neutral">{config.numTracks}</span> </div> <div className="hud-card"> <span className="hud-label">Total Clips</span> <span className="hud-value value-neutral">{totalClips}</span> </div> </div>
{/* Benchmark controls */} <div className="benchmark-action-area"> {(['scrub', 'zoom', 'drag', 'commit'] as const).map((type) => ( <button key={type} type="button" className={`benchmark-btn ${isBenchmarking ? 'is-running' : ''}`} onClick={() => runBenchmark(type)} disabled={isBenchmarking} > {isBenchmarking ? 'Running...' : `Run ${benchmarkTitles[type]}`} </button> ))} </div>
{/* Benchmark Results */} {benchmarkResult && ( <div className="benchmark-results-card" role="status"> <div className="results-header"> <span className="results-title">{benchmarkTitles[benchmarkResult.type]} Results</span> {benchmarkResult.status === 'complete' && benchmarkResult.grade !== null && ( <span className={`results-grade grade-${benchmarkResult.grade.charAt(0)}`}> {benchmarkResult.grade} </span> )} </div> {benchmarkResult.status === 'complete' && ( <div className="results-metrics"> {benchmarkResult.metrics.map((metric) => ( <div className="metric-row" key={metric.label}> <span>{metric.label}:</span> <strong>{metric.value}</strong> </div> ))} </div> )} <p className="benchmark-note">{benchmarkResult.note}</p> </div> )} </div> </div> );}import { useTimelineRulerTicks, useTimelineViewport, type TimelineRulerFormatOptions, type VisibleTimelineClip,} from '@techsquidtv/canvas-timeline';
interface RulerDOMProps { ruler: TimelineRulerFormatOptions; showLabels?: boolean;}
/** * Renders the timeline ruler using absolute-positioned React DOM nodes. */export function RulerDOM({ ruler, showLabels = true }: RulerDOMProps) { const { viewportWidth } = useTimelineViewport(); const ticks = useTimelineRulerTicks({ ...ruler, includeLabels: showLabels, minimumMajorTickSpacing: ruler.format === 'timecode' ? 96 : undefined, }); const labelInset = ruler.format === 'timecode' ? 48 : 24;
return ( <div className="timeline-dom-ruler"> {ticks.map((tick) => { const labelX = Math.min( Math.max(tick.x, labelInset), Math.max(labelInset, viewportWidth - labelInset) );
return ( <div key={tick.frame ?? tick.seconds} className={`timeline-dom-ruler-tick timeline-dom-ruler-tick-${tick.kind}`} style={{ position: 'absolute', left: `${tick.x}px`, }} > {tick.label && ( <span style={{ transform: `translateX(${labelX - tick.x}px)` }}>{tick.label}</span> )} <div className="timeline-dom-ruler-tick-mark" /> </div> ); })} </div> );}
interface DOMClipProps { clip: VisibleTimelineClip; showLabels?: boolean;}
/** * Renders a single clip as an absolute-positioned DOM element. */export function DOMClip({ clip, showLabels = true }: DOMClipProps) { return ( <div className={`timeline-dom-clip ${clip.clip.selected ? 'is-selected' : ''}`} style={{ position: 'absolute', left: `${clip.visibleRect.x}px`, width: `${clip.visibleRect.width}px`, background: clip.clip.color, }} > {showLabels && <span className="timeline-dom-clip-label">{clip.clip.label}</span>} </div> );}import { type Track, type Clip, type Marker, type TimelineKeyframe, fromSeconds,} from '@techsquidtv/canvas-timeline';import { opacityKeyframeProperty } from './demo-keyframe-properties';import { getDemoClipColor } from './demo-clip-colors';
export interface BenchmarkConfig { numTracks: number; clipsPerTrack: number; durationSeconds: number; keyframesPerClip: number;}
export function generateStressTestData( numTracks: number, clipsPerTrack: number, durationSeconds: number, keyframesPerClip: number) { const tracks: Track<'visual' | 'audio'>[] = []; const segmentWidth = durationSeconds / Math.max(1, clipsPerTrack); const clipDuration = segmentWidth * 0.7; // clip takes 70% of segment const gapDuration = segmentWidth * 0.3; // gap takes 30% of segment
for (let t = 0; t < numTracks; t++) { const trackId = `track-${t}`; const kind = t % 2 === 0 ? 'visual' : 'audio'; const clips: Clip[] = [];
for (let c = 0; c < clipsPerTrack; c++) { // Add slight track stagger to make the timeline look realistic and dense const stagger = (t % 4) * (gapDuration / 4); const startSec = c * segmentWidth + stagger; const endSec = Math.min(startSec + clipDuration, durationSeconds);
if (startSec < durationSeconds && startSec < endSec) { const keyframes: TimelineKeyframe[] | undefined = keyframesPerClip > 0 ? createDemoKeyframes(keyframesPerClip, startSec, endSec, `clip-${t}-${c}`) : undefined; clips.push({ id: `clip-${t}-${c}`, sourceId: `source-${t}-${c}`, timelineStart: fromSeconds(startSec), timelineEnd: fromSeconds(endSec), sourceStart: fromSeconds(0), selected: false, color: getDemoClipColor(t * clipsPerTrack + c), label: `Trk ${t + 1} Clp ${c + 1}`, ...(keyframes === undefined ? {} : { keyframes }), }); } }
tracks.push({ id: trackId, kind, name: `${kind.toUpperCase()} Track ${t + 1}`, locked: false, muted: false, visible: true, selected: false, height: 40, clips, }); }
// Generate some markers along the timeline const markers: Marker[] = []; const numMarkers = Math.min(20, Math.floor(durationSeconds / 15)); for (let m = 0; m < numMarkers; m++) { const timeSec = (m + 1) * (durationSeconds / (numMarkers + 1)); markers.push({ id: `marker-${m}`, time: fromSeconds(timeSec), label: `M${m + 1}`, }); }
return { tracks, markers };}
// Default initial stateexport const initialNumTracks = 15;export const initialClipsPerTrack = 8;export const initialDurationSeconds = 120;export const initialKeyframesPerClip = 0;
function createDemoKeyframes( count: number, startSec: number, endSec: number, idPrefix: string): TimelineKeyframe[] { const keyframes: TimelineKeyframe[] = []; const span = Math.max(endSec - startSec, 0.001); for (let k = 0; k < count; k++) { const timeSec = startSec + (span * (k + 1)) / (count + 1); keyframes.push({ id: `${idPrefix}-kf-${k}`, property: opacityKeyframeProperty.id, time: fromSeconds(timeSec), value: 0.25 + 0.75 * ((k % 4) / 3), }); } return keyframes;}