Live demo
Code example
import { TimelineEngine, TimelineProvider, Timeline, useTimeline, useTimelinePlayheadTime, CanvasRenderer, fromSeconds, toSeconds,} from '@techsquidtv/canvas-timeline';import { formatMediabunnyTime } from '@techsquidtv/canvas-timeline-mediabunny-adapter';import { useMediabunnyFrameTime, useMediabunnyTimelineMedia,} from '@techsquidtv/canvas-timeline-mediabunny-adapter/react';import { useCallback, useEffect, useMemo, useRef, useState } from 'react';import { demoMarkers, demoTracks, sampleDurationSeconds, sampleMediaSource, sampleSourceId,} from './timeline-demo-data';import type { DemoMetrics } from './demo-instrumentation';import '@techsquidtv/canvas-timeline/styles.css';
// Timeline layer selectors tell the adapter which active clips should drive preview outputs.const playbackRates = [0.5, 1, 2] as const;const previewLayerSelectors = { visuals: { trackKind: 'visual', sourceId: sampleSourceId }, audio: { trackKind: 'audio', sourceId: sampleSourceId },} as const;const sources = [sampleMediaSource] as const;
function formatRenderedFrame(seconds: number | null) { return seconds === null ? 'Pending' : formatMediabunnyTime(seconds);}
function TimelineLayers() { const { state } = useTimeline();
return ( <> <Timeline.PlayheadArea /> <Timeline.PlayheadGrabber /> <Timeline.TrackList className="timeline-track-list-overlay"> {state.tracks.map((track) => ( <Timeline.Track key={track.id} trackId={track.id} /> ))} </Timeline.TrackList> <Timeline.ClipInteractionLayer /> <Timeline.RangeSelector /> </> );}
function MediaSyncSurface({ metrics }: { metrics?: DemoMetrics }) { const playheadTime = useTimelinePlayheadTime(); const mediaLoadStartedAtRef = useRef(performance.now()); const decodeMetricReportedRef = useRef(false); const [playbackError, setPlaybackError] = useState<string | null>(null);
// The source id joins app-owned media descriptors to timeline clips without storing media in timeline state. const media = useMediabunnyTimelineMedia({ frameRate: 30, sources, layers: previewLayerSelectors, onError: (error) => { metrics?.onMediaLoadFailed?.({ demoId: 'media-sync', adapter: 'mediabunny', mediaType: 'video_audio', }); setPlaybackError(error.message); }, }); const lastFrameTime = useMediabunnyFrameTime(media.adapter); const { ready, status, sourceStateById, playing, playbackRate, play, pause, setPlaybackRate } = media; const sourceState = sourceStateById.get(sampleSourceId); const sourceReady = sourceState?.status === 'ready';
useEffect(() => { if (!sourceReady || decodeMetricReportedRef.current) { return; }
decodeMetricReportedRef.current = true; metrics?.onMediaDecodeTime?.( { demoId: 'media-sync', adapter: 'mediabunny', mediaType: 'video_audio', }, performance.now() - mediaLoadStartedAtRef.current ); }, [metrics, sourceReady]);
// Transport controls const handlePlayPause = useCallback(async () => { if (playing) { pause(); setPlaybackError(null); } else { const result = await play(); setPlaybackError(result.ok ? null : result.message); if (!result.ok) { metrics?.onMediaLoadFailed?.({ demoId: 'media-sync', adapter: 'mediabunny', mediaType: 'video_audio', }); } } }, [metrics, pause, play, playing]); const mediaDuration = sourceState?.metadata?.durationSeconds ?? null;
return ( <div className="media-sync-demo"> <div className="media-sync-preview"> <div className="media-sync-monitor"> <canvas ref={media.canvasRef} className="media-sync-canvas" width={1280} height={720} /> <button type="button" className="media-sync-button media-sync-play-button" onClick={handlePlayPause} disabled={!ready} > {playing ? 'Pause' : 'Play'} </button> </div> <section className="media-sync-panel" aria-label="Media sync controls"> <h3>Mediabunny Adapter Sync</h3> <p className="media-sync-status">{playbackError ?? status}</p> <dl className="media-sync-readout"> <dt>Timeline position</dt> <dd>{formatMediabunnyTime(toSeconds(playheadTime))}</dd> <dt>Nearest frame</dt> <dd>{formatRenderedFrame(lastFrameTime)}</dd> <dt>Source duration</dt> <dd>{mediaDuration === null ? 'Loading' : formatMediabunnyTime(mediaDuration)}</dd> </dl> <div className="media-sync-controls"> {playbackRates.map((rate) => ( <button key={rate} type="button" className={`media-sync-button${ playbackRate === rate ? ' media-sync-button-active' : '' }`} onClick={() => void setPlaybackRate(rate)} disabled={!ready} > {rate}x </button> ))} </div> </section> </div>
<div className="timeline-shell"> <div className="timeline-stage"> <Timeline.Root className="timeline-fill"> <CanvasRenderer ruler={{ format: 'timecode', frameRate: 30 }} /> <TimelineLayers /> </Timeline.Root> </div> <div className="timeline-scrollbar-row"> <Timeline.ViewportScrollbar> <Timeline.ViewportScrollbarThumb> <Timeline.ViewportScrollbarHandle side="start" /> <Timeline.ViewportScrollbarHandle side="end" /> </Timeline.ViewportScrollbarThumb> </Timeline.ViewportScrollbar> </div> </div> </div> );}
// Demo entrypointexport function MediaTimelineSync({ metrics }: { metrics?: DemoMetrics }) { const engine = useMemo( () => new TimelineEngine({ duration: fromSeconds(sampleDurationSeconds), playheadTime: fromSeconds(0), zoomScale: 12, tracks: demoTracks, markers: demoMarkers, }), [] );
return ( <TimelineProvider engine={engine}> <MediaSyncSurface metrics={metrics} /> </TimelineProvider> );}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 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 { type Marker, type Track, fromSeconds } from '@techsquidtv/canvas-timeline';import { getDemoClipColor } from './demo-clip-colors';
export const sampleSourceId = 'big-buck-bunny-preview';
const sampleMediaUrl = '/demo-media/big-buck-bunny-preview.webm';export const sampleMediaSource = { sourceId: sampleSourceId, input: { kind: 'url', url: sampleMediaUrl },} as const;export const sampleDurationSeconds = 66.06;
export const demoTracks: Track<'visual' | 'audio'>[] = [ { id: 'video-preview', kind: 'visual', name: 'Preview Video', locked: false, muted: false, visible: true, selected: false, targeted: true, height: 48, clips: [ { id: 'bunny-video', sourceId: sampleSourceId, timelineStart: fromSeconds(0), timelineEnd: fromSeconds(sampleDurationSeconds), sourceStart: fromSeconds(0), selected: false, color: getDemoClipColor(0), label: 'Big Buck Bunny preview video', }, ], }, { id: 'audio-preview', kind: 'audio', name: 'Preview Audio', locked: false, muted: false, visible: true, selected: false, height: 48, clips: [ { id: 'bunny-audio', sourceId: sampleSourceId, timelineStart: fromSeconds(0), timelineEnd: fromSeconds(sampleDurationSeconds), sourceStart: fromSeconds(0), selected: false, color: getDemoClipColor(1), label: 'Big Buck Bunny preview audio', }, ], },];
export const demoMarkers: Marker[] = [];