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 { useMediabunnyTimelineMedia } from '@techsquidtv/canvas-timeline-mediabunny-adapter/react';import { useCallback, useEffect, useMemo, useRef, useState } from 'react';import { demoMarkers, demoTracks, sampleDurationSeconds, sampleMediaUrls, 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 }, audio: { trackKind: 'audio', sourceId: sampleSourceId },} as const;
// Display helpersfunction formatRenderedFrame(seconds: number | null) { return seconds === null ? 'Pending' : formatMediabunnyTime(seconds);}
// 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 /> </> );}
// Mediabunny preview and timeline syncfunction MediaSyncSurface({ metrics }: { metrics?: DemoMetrics }) { const playheadTime = useTimelinePlayheadTime(); const canvasRef = useRef<HTMLCanvasElement>(null); const mediaLoadStartedAtRef = useRef(performance.now()); const decodeMetricReportedRef = useRef(false); const [playbackError, setPlaybackError] = useState<string | null>(null);
// Adapter setup const sources = useMemo(() => sampleMediaUrls.map((url) => ({ id: sampleSourceId, url })), []); const { ready, status, durationBySourceId, lastFrameTime, playing, playbackRate, play, pause, setPlaybackRate, } = useMediabunnyTimelineMedia({ canvasRef, sources, layers: previewLayerSelectors, onError: (message) => { metrics?.onMediaLoadFailed?.({ demoId: 'media-sync', adapter: 'mediabunny', mediaType: 'video_audio', }); setPlaybackError(message); }, });
useEffect(() => { if (!ready || decodeMetricReportedRef.current) { return; }
decodeMetricReportedRef.current = true; metrics?.onMediaDecodeTime?.( { demoId: 'media-sync', adapter: 'mediabunny', mediaType: 'video_audio', }, performance.now() - mediaLoadStartedAtRef.current ); }, [metrics, ready]);
// 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 = durationBySourceId.get(sampleSourceId) ?? null;
return ( <div className="media-sync-demo"> <div className="media-sync-preview"> <div className="media-sync-monitor"> <canvas ref={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={() => 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 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 '../lib/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;}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 sampleMediaUrls = [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[] = [];