Live demo
Code example
import { TimelineEngine, TimelineProvider, Timeline, useTimeline, useTimelinePlayheadTime, CanvasRenderer, fromSeconds, toSeconds,} from '@techsquidtv/canvas-timeline';import { useHTMLTimelineMedia } from '@techsquidtv/canvas-timeline-html-media-adapter/react';import { useCallback, useMemo, useRef, useState } from 'react';import { demoMarkers, demoTracks, sampleDurationSeconds, sampleMediaUrl, sampleSourceId,} from './timeline-demo-data';import type { DemoMetrics } from './demo-instrumentation';import '@techsquidtv/canvas-timeline/styles.css';
// Demo configurationconst playbackRates = [0.5, 1, 2] as const;const previewLayerSelectors = { visuals: { trackKind: 'visual', sourceId: sampleSourceId },} as const;const sources = [{ sourceId: sampleSourceId, input: sampleMediaUrl }] as const;
interface MediaReadout { status: string; sourceDuration: number | null; sourceTime: number | null;}
function formatMediaSeconds(seconds: number | null, pendingLabel = 'Pending') { return seconds === null || !Number.isFinite(seconds) ? pendingLabel : `${seconds.toFixed(2)}s`;}
// Timeline chromefunction 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 /> </> );}
// HTML media preview and timeline syncfunction HTMLMediaSyncSurface({ metrics }: { metrics?: DemoMetrics }) { const playheadTime = useTimelinePlayheadTime(); const mediaLoadStartedAtRef = useRef(performance.now()); const decodeMetricReportedRef = useRef(false); const [playbackError, setPlaybackError] = useState<string | null>(null); const [mediaReadout, setMediaReadout] = useState<MediaReadout>({ status: 'Loading media metadata.', sourceDuration: null, sourceTime: null, });
// Adapter setup const { element, mediaRef, playing, playbackRate, play, pause, setPlaybackRate, ready } = useHTMLTimelineMedia({ sources, layers: previewLayerSelectors, onError: (error) => { metrics?.onMediaLoadFailed?.({ demoId: 'html-media-sync', adapter: 'html-media', mediaType: 'video', }); setPlaybackError(error.message); }, });
// Native media element readout const updateMediaReadout = useCallback( (status?: string) => { const video = element; if (video === null) { return; }
setMediaReadout((currentReadout) => ({ status: status ?? currentReadout.status, sourceDuration: Number.isFinite(video.duration) ? video.duration : null, sourceTime: video.currentTime, })); }, [element] );
const recordDecodeTime = useCallback(() => { if (decodeMetricReportedRef.current) { return; }
decodeMetricReportedRef.current = true; metrics?.onMediaDecodeTime?.( { demoId: 'html-media-sync', adapter: 'html-media', mediaType: 'video', }, performance.now() - mediaLoadStartedAtRef.current ); }, [metrics]);
const recordMediaLoadFailed = useCallback(() => { metrics?.onMediaLoadFailed?.({ demoId: 'html-media-sync', adapter: 'html-media', mediaType: 'video', }); }, [metrics]);
// Transport controls const handlePlayPause = useCallback(async () => { if (playing) { pause(); setPlaybackError(null); updateMediaReadout('Paused on timeline playhead.'); } else { const result = await play(); setPlaybackError(result.ok ? null : result.message); if (result.ok) { updateMediaReadout('Playing from native media element.'); } else { recordMediaLoadFailed(); } } }, [pause, play, playing, recordMediaLoadFailed, updateMediaReadout]);
const readyStatus = ready ? mediaReadout.status : 'Connecting HTML media element.';
return ( <div className="media-sync-demo"> <div className="media-sync-preview"> <div className="media-sync-monitor"> <video ref={mediaRef} className="media-sync-video" preload="metadata" playsInline aria-label="HTML media preview" onLoadedMetadata={() => { recordDecodeTime(); updateMediaReadout('Ready'); }} onError={recordMediaLoadFailed} onDurationChange={() => updateMediaReadout()} onTimeUpdate={() => updateMediaReadout()} onSeeking={() => updateMediaReadout('Seeking source media.')} onWaiting={() => updateMediaReadout('Buffering media.')} onPlaying={() => updateMediaReadout('Playing from native media element.')} onPause={() => updateMediaReadout('Paused on timeline playhead.')} /> <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="HTML media sync controls"> <h3>HTML Media Adapter Sync</h3> <p className="media-sync-status">{playbackError ?? readyStatus}</p> <dl className="media-sync-readout"> <dt>Timeline position</dt> <dd>{formatMediaSeconds(toSeconds(playheadTime))}</dd> <dt>Element time</dt> <dd>{formatMediaSeconds(mediaReadout.sourceTime)}</dd> <dt>Source duration</dt> <dd>{formatMediaSeconds(mediaReadout.sourceDuration, 'Loading')}</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 /> <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 HTMLMediaTimelineSync({ metrics }: { metrics?: DemoMetrics }) { const engine = useMemo( () => new TimelineEngine({ duration: fromSeconds(sampleDurationSeconds), playheadTime: fromSeconds(0), zoomScale: 12, tracks: demoTracks, markers: demoMarkers, }), [] );
return ( <TimelineProvider engine={engine}> <HTMLMediaSyncSurface 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';
export const sampleMediaUrl = '/demo-media/big-buck-bunny-preview.webm';export const sampleDurationSeconds = 66.06;
export const demoTracks: Track<'visual'>[] = [ { id: 'video-preview', kind: 'visual', name: 'HTML Media', 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 HTML media', }, ], },];
export const demoMarkers: Marker[] = [];