Live demo
Code example
import { opacityKeyframeProperty } from './demo-keyframe-properties';import { KeyframeInspector } from './KeyframeInspector';import type { DemoMetrics } from './demo-instrumentation';import { findClipContainingTime, findOpacityKeyframeAtTime, opacityKeyframeValuePadding, toggleOpacityKeyframeAtTime,} from './keyframe-opacity-utils';import { demoMarkers, demoTracks, opacityClipId, sampleDurationSeconds, sampleMediaUrl, sampleSourceId,} from './timeline-demo-data';import './timeline-editor.css';import { TimelineEngine, type TimelineReadonly, type Track, Timeline, TimelineProvider, useTimeline, useTimelinePlayheadTime, CanvasRenderer, fromSeconds,} from '@techsquidtv/canvas-timeline';import { useHTMLTimelineMedia } from '@techsquidtv/canvas-timeline-html-media-adapter/react';import '@techsquidtv/canvas-timeline/styles.css';import { Diamond } from 'lucide-react';import { useCallback, useMemo, useState, useEffect, useRef } from 'react';import type { ComponentProps } from 'react';import { Separator as ResizableHandle, Panel as ResizablePanel, Group as ResizablePanelGroup,} from 'react-resizable-panels';const trackHeight = 64;const keyframeSize = 6;const keyframeValuePadding = opacityKeyframeValuePadding;const previewLayerSelectors = { visuals: { trackKind: 'visual', sourceId: sampleSourceId },} as const;const sources = [{ sourceId: sampleSourceId, input: sampleMediaUrl }] as const;
function TrackKeyframeButton({ track, label, locked,}: { track: TimelineReadonly<Track> | null; label: string; locked: boolean;}) { const { engine } = useTimeline(); const playheadTime = useTimelinePlayheadTime(); const clip = track ? findClipContainingTime(track, playheadTime) : null; const existingKeyframe = clip ? findOpacityKeyframeAtTime(clip, playheadTime) : null; const evaluatedOpacity = clip ? (engine.keyframes.getClipPropertyValueAtTime(clip.id, 'opacity', playheadTime) ?? clip.opacity ?? 1) : 1; const disabled = locked || !clip;
const handleToggle = useCallback(() => { if (!clip || disabled) { return; } toggleOpacityKeyframeAtTime(engine, clip.id, playheadTime, evaluatedOpacity); }, [clip, disabled, engine, evaluatedOpacity, playheadTime]);
return ( <button type="button" className="timeline-editor-track-header-button timeline-editor-keyframe-button" onClick={handleToggle} disabled={disabled} title={ existingKeyframe ? `Remove opacity keyframe from ${label}` : `Add opacity keyframe to ${label}` } aria-label={ existingKeyframe ? `Remove opacity keyframe from ${label}` : `Add opacity keyframe to ${label}` } aria-pressed={Boolean(existingKeyframe)} > <Diamond aria-hidden="true" /> </button> );}
function TrackHeaderColumn() { const { state } = useTimeline();
return ( <Timeline.TrackHeaderList className="timeline-editor-track-headers"> {state.tracks.map((track) => ( <Timeline.TrackHeader key={track.id} trackId={track.id} geometry={{ trackHeight }}> {(header) => ( <div className="timeline-editor-track-header-content timeline-editor-keyframe-track-header-content"> <TrackKeyframeButton track={header.track} label={header.label} locked={header.locked} /> <span className="timeline-editor-track-header-label">{header.label}</span> <Timeline.TrackHeaderResizeHandle trackId={track.id} /> </div> )} </Timeline.TrackHeader> ))} </Timeline.TrackHeaderList> );}
function TimelineLayers({ onClipDoubleClick, onKeyframeDelete, onKeyframeDoubleClick,}: { onClipDoubleClick: ComponentProps<typeof Timeline.ClipInteractionLayer>['onClipDoubleClick']; onKeyframeDelete: ComponentProps<typeof Timeline.KeyframeInteractionLayer>['onKeyframeDelete']; onKeyframeDoubleClick: ComponentProps< typeof Timeline.KeyframeInteractionLayer >['onKeyframeDoubleClick'];}) { 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 trackHeight={trackHeight} onClipDoubleClick={onClipDoubleClick} /> <Timeline.KeyframeInteractionLayer property="opacity" selectedClipOnly trackHeight={trackHeight} keyframeSize={keyframeSize} keyframeValuePadding={keyframeValuePadding} onKeyframeDelete={onKeyframeDelete} onKeyframeDoubleClick={onKeyframeDoubleClick} /> <Timeline.KeyframeTangentInteractionLayer property="opacity" selectedClipOnly selectedKeyframeOnly trackHeight={trackHeight} keyframeSize={keyframeSize} tangentHandleSize={7} keyframeValuePadding={keyframeValuePadding} /> <Timeline.RangeSelector /> </> );}
function KeyframeOpacitySurface({ metrics }: { metrics?: DemoMetrics }) { const { engine } = useTimeline(); const videoRef = useRef<HTMLVideoElement | null>(null); const [playbackError, setPlaybackError] = useState<string | null>(null); const { mediaRef, playing, play, pause, ready } = useHTMLTimelineMedia({ sources, layers: previewLayerSelectors, onError: (error) => { metrics?.onMediaLoadFailed?.({ demoId: 'keyframe-opacity', adapter: 'html-media', mediaType: 'video', }); setPlaybackError(error.message); }, });
const setVideoRef = useCallback( (element: HTMLVideoElement | null) => { videoRef.current = element; mediaRef(element); }, [mediaRef] ); const handlePlayPause = useCallback(async () => { if (playing) { pause(); setPlaybackError(null); return; }
const result = await play(); setPlaybackError(result.ok ? null : result.message); }, [pause, play, playing]);
useEffect(() => { const update = () => { if (videoRef.current) { videoRef.current.style.opacity = String( engine.keyframes.getClipPropertyValueAtTime(opacityClipId, 'opacity') ?? 1 ); } }; update(); const unsubscribeTime = engine.on('playhead:scrub', update); const unsubscribeRender = engine.on('render', update); return () => { unsubscribeTime(); unsubscribeRender(); }; }, [engine, mediaRef]); const handleClipDoubleClick = useCallback< NonNullable<ComponentProps<typeof Timeline.ClipInteractionLayer>['onClipDoubleClick']> >((hit, details) => { const value = details.engine.keyframes.getClipPropertyValueAtTime(hit.clip.id, 'opacity', details.time) ?? 1; toggleOpacityKeyframeAtTime(details.engine, hit.clip.id, details.time, value); }, []); const handleKeyframeDoubleClick = useCallback< NonNullable<ComponentProps<typeof Timeline.KeyframeInteractionLayer>['onKeyframeDoubleClick']> >( (entry) => { engine.keyframes.selectKeyframes([{ clipId: entry.clip.id, keyframeId: entry.keyframe.id }]); engine.updatePlayhead(entry.keyframe.time); }, [engine] ); const handleKeyframeDelete = useCallback(() => { engine.commitEdit({ type: 'keyframes', edits: engine.keyframes.getSelectedKeyframes().map((ref) => ({ type: 'remove', ...ref })), }); }, [engine]);
return ( <div className="media-sync-demo keyframe-opacity-demo"> <div className="media-sync-preview keyframe-opacity-preview"> <div className="media-sync-monitor keyframe-opacity-monitor"> <video ref={setVideoRef} className="media-sync-video keyframe-opacity-video" preload="metadata" playsInline muted aria-label="Opacity keyframe preview" /> <button type="button" className="media-sync-button media-sync-play-button" onClick={handlePlayPause} disabled={!ready} > {playing ? 'Pause' : 'Play'} </button> </div> <KeyframeInspector /> {playbackError && <p role="alert">{playbackError}</p>} </div>
<div className="timeline-shell timeline-editor-controls-shell keyframe-opacity-timeline-shell"> <ResizablePanelGroup className="timeline-editor-body-with-headers" orientation="horizontal" resizeTargetMinimumSize={{ coarse: 28, fine: 8 }} > <ResizablePanel defaultSize="7.75rem" groupResizeBehavior="preserve-pixel-size" maxSize="16rem" minSize="7.75rem" > <div className="timeline-editor-header-panel"> <div className="timeline-stage timeline-editor-header-stage"> <TrackHeaderColumn /> </div> </div> </ResizablePanel>
<ResizableHandle aria-label="Resize track header column" className="timeline-editor-column-resize-handle" />
<ResizablePanel minSize="0"> <div className="timeline-editor-timeline-panel"> <div className="timeline-editor-stage-row"> <div className="timeline-stage timeline-editor-timeline-stage"> <Timeline.Root className="timeline-fill timeline-editor-root-with-headers"> <CanvasRenderer showClipLabels={false} keyframeProperty={opacityKeyframeProperty.id} /> <TimelineLayers onClipDoubleClick={handleClipDoubleClick} onKeyframeDelete={handleKeyframeDelete} onKeyframeDoubleClick={handleKeyframeDoubleClick} /> </Timeline.Root> </div> <div className="timeline-editor-vertical-scrollbar-column"> <Timeline.VerticalScrollbar className="timeline-editor-vertical-scrollbar"> <Timeline.VerticalScrollbarThumb className="timeline-editor-vertical-scrollbar-thumb"> <Timeline.VerticalScrollbarHandle side="start" /> <Timeline.VerticalScrollbarHandle side="end" /> </Timeline.VerticalScrollbarThumb> </Timeline.VerticalScrollbar> </div> </div> <div className="timeline-scrollbar-row timeline-editor-scrollbar-row"> <Timeline.ViewportScrollbar> <Timeline.ViewportScrollbarThumb> <Timeline.ViewportScrollbarHandle side="start" /> <Timeline.ViewportScrollbarHandle side="end" /> </Timeline.ViewportScrollbarThumb> </Timeline.ViewportScrollbar> </div> </div> </ResizablePanel> </ResizablePanelGroup> </div> </div> );}
export function KeyframeOpacityTimeline({ metrics }: { metrics?: DemoMetrics }) { const engine = useMemo( () => new TimelineEngine({ duration: fromSeconds(sampleDurationSeconds), playheadTime: fromSeconds(0), zoomScale: 32, zoomConstraints: { frameRate: 30 }, tracks: demoTracks, markers: demoMarkers, keyframeProperties: [opacityKeyframeProperty], }), [] );
return ( <TimelineProvider engine={engine}> <KeyframeOpacitySurface metrics={metrics} /> </TimelineProvider> );}import { useTimelineEngine, useTimelineKeyframes, useTimelineKeyframeGeometry, useTimelineTrack, type TimelineKeyframe, type TimelineEditPreview, type TimelineKeyframeClipboard, type TimelineKeyframeEditCommand, type TimelineKeyframeSide, type TimelineReadonly, addRational, formatRationalTimecode, fromSeconds, parseTimecodeToRationalTime, toSeconds,} from '@techsquidtv/canvas-timeline';import { useEffect, useRef, useState } from 'react';import { opacityClipId } from './timeline-demo-data';import { createCurvePresetCommand, getCurvePresetId, keyframeCurvePresets,} from './keyframe-opacity-utils';
/** App-owned inspector composed from public headless APIs. */export function KeyframeInspector() { const engine = useTimelineEngine(); const state = useTimelineKeyframes({ clipId: opacityClipId, property: 'opacity' }); const curveTrack = useTimelineTrack('opacity-video-track'); const live = useTimelineKeyframeGeometry({ clipId: opacityClipId, property: 'opacity' }); const selected = live.keyframeRects.find((entry) => entry.keyframe.selected)?.keyframe; const keys = state.keyframes; const index = keys.findIndex((key) => key.id === selected?.id); const [message, setMessage] = useState(''); const expanded = curveTrack.height > 64; const clipboard = useRef<TimelineKeyframeClipboard | null>(null); const valueEdit = useRef<{ keyframeId: string | undefined; time: TimelineKeyframe['time']; preview: TimelineEditPreview | null; } | null>(null); const [sliderValue, setSliderValue] = useState<number | null>(null); const value = sliderValue ?? selected?.value ?? engine.keyframes.getClipPropertyValueAtTime(opacityClipId, 'opacity') ?? 1; useEffect( () => () => { if (valueEdit.current?.preview && valueEdit.current.preview === engine.getEditPreview()) { engine.cancelEdit(); } }, [engine] );
const commit = (command: TimelineKeyframeEditCommand) => { const result = engine.commitEdit(command); setMessage( result.committed ? '' : (result.preview.message ?? 'This edit could not be applied.') ); return result; }; const select = (key: TimelineReadonly<TimelineKeyframe> | undefined) => { if (!key) { return; } engine.keyframes.selectKeyframes([{ clipId: opacityClipId, keyframeId: key.id }]); engine.updatePlayhead(key.time); }; const finishValue = (cancelled = false) => { if (!valueEdit.current) { return; } const preview = valueEdit.current.preview; if (preview && preview === engine.getEditPreview()) { if (!cancelled && preview.valid && preview.command.type === 'keyframes') { commit(preview.command); } engine.cancelEdit(); } valueEdit.current = null; setSliderValue(null); }; const editValue = (next: number, preview = false) => { if (!Number.isFinite(next)) { setMessage('Enter a finite opacity value.'); return; } if (valueEdit.current && valueEdit.current.preview !== engine.getEditPreview()) { setSliderValue(null); setMessage('The value gesture was replaced by another edit.'); return; } const target = valueEdit.current ?? { keyframeId: selected?.id, time: selected?.time ?? engine.getState().playheadTime, preview: null, }; const command: TimelineKeyframeEditCommand = { type: 'keyframes', edits: [ target.keyframeId ? { type: 'update', clipId: opacityClipId, keyframeId: target.keyframeId, value: next } : { type: 'set', clipId: opacityClipId, property: 'opacity', time: target.time, value: next, selected: true, }, ], }; if (preview) { valueEdit.current = target; setSliderValue(next); target.preview = engine.previewEdit(command); } else { commit(command); } }; const deleteSelection = () => commit({ type: 'keyframes', edits: state.selectedKeyframes.map((ref) => ({ type: 'remove', ...ref })), }); const paste = (duplicate: boolean) => { const copied = duplicate ? engine.keyframes.copyKeyframes() : clipboard.current; if (!copied?.entries.length) { setMessage('Select and copy some keyframes first.'); return; } const time = duplicate ? addRational( keys.find((key) => key.selected)?.time ?? engine.getState().playheadTime, fromSeconds(1 / 30) ) : engine.getState().playheadTime; commit(engine.keyframes.createPasteCommand(copied, time)); }; const renderCurve = (side: TimelineKeyframeSide) => { const left = keys[side === 'incoming' ? index - 1 : index]; const right = keys[side === 'incoming' ? index : index + 1]; const preset = left && right ? getCurvePresetId(left, right) : 'none'; return ( <label className="keyframe-editor-field"> {side === 'incoming' ? 'Incoming curve' : 'Outgoing curve'} <select aria-label={`${side} curve`} disabled={!left || !right} value={preset} onChange={(event) => { if (left && right) { commit( createCurvePresetCommand(opacityClipId, left, right, event.currentTarget.value) ); } }} > <option value="none" disabled> No segment </option> <option value="custom" disabled> Custom </option> {keyframeCurvePresets.map((item) => ( <option key={item.id} value={item.id}> {item.label} </option> ))} </select> </label> ); }; return ( <section className="media-sync-panel keyframe-opacity-panel" aria-label="Keyframe inspector"> <h3>Opacity</h3> <p className="media-sync-status"> {state.selectedKeyframes.length ? `${state.selectedKeyframes.length} selected` : 'Select a key or set one at the playhead'} </p> <div className="keyframe-editor-fields"> <label className="keyframe-editor-field"> Timecode <input aria-label="Keyframe timecode" key={`${selected?.id}:${selected?.time.v}`} disabled={!selected} defaultValue={selected ? formatRationalTimecode(selected.time, { frameRate: 30 }) : ''} onBlur={(event) => { if (!selected) { return; } const time = parseTimecodeToRationalTime(event.currentTarget.value, { frameRate: 30, }); if (!time) { setMessage('Enter a valid timecode.'); return; } if (toSeconds(time) !== toSeconds(selected.time)) { commit({ type: 'keyframes', edits: [{ type: 'update', clipId: opacityClipId, keyframeId: selected.id, time }], }); } }} onKeyDown={(event) => { if (event.key === 'Enter') { event.currentTarget.blur(); } }} /> </label> <label className="keyframe-editor-field"> Value (%) <input aria-label="Keyframe opacity percent" type="number" min={0} max={100} step={0.1} key={`${selected?.id}:${selected?.value}`} defaultValue={Math.round(value * 10000) / 100} onBlur={(event) => { const next = Number(event.currentTarget.value) / 100; if (next !== value) { editValue(next); } }} onKeyDown={(event) => { if (event.key === 'Enter') { event.currentTarget.blur(); } }} /> </label> </div> <input aria-label="Opacity" type="range" min={0} max={1} step={0.001} value={value} onChange={(event) => editValue(Number(event.currentTarget.value), true)} onPointerUp={() => finishValue()} onPointerCancel={() => finishValue(true)} onBlur={() => finishValue()} onKeyUp={(event) => { if (event.key.startsWith('Arrow')) { finishValue(); } }} onKeyDown={(event) => { if (event.key === 'Escape') { event.preventDefault(); finishValue(true); } }} /> <div className="media-sync-controls"> <button className="media-sync-button" disabled={index <= 0} onClick={() => select(keys[index - 1])} > Previous </button> <button className="media-sync-button" disabled={index < 0 || index >= keys.length - 1} onClick={() => select(keys[index + 1])} > Next </button> <button className="media-sync-button" onClick={() => { const time = engine.getState().playheadTime; const result = commit({ type: 'keyframes', edits: [ { type: 'set', clipId: opacityClipId, property: 'opacity', time, value: engine.keyframes.getClipPropertyValueAtTime(opacityClipId, 'opacity', time) ?? 1, selected: true, }, ], }); if (result.committed) { select( engine.keyframes .getClipKeyframes(opacityClipId, 'opacity') .find((key) => toSeconds(key.time) === toSeconds(time)) ); } }} > Set key </button> <button className="media-sync-button" disabled={!state.selectedKeyframes.length} onClick={deleteSelection} > Delete </button> </div> <div className="keyframe-editor-fields"> {renderCurve('incoming')} {renderCurve('outgoing')} </div> <div className="media-sync-controls"> <button className="media-sync-button" disabled={!selected} aria-pressed={selected?.tangentMode === 'linked'} onClick={() => { if (selected) { commit({ type: 'keyframes', edits: [ { type: 'update', clipId: opacityClipId, keyframeId: selected.id, tangentMode: selected.tangentMode === 'linked' ? 'broken' : 'linked', }, ], }); } }} > {selected?.tangentMode === 'linked' ? 'Linked tangents' : 'Broken tangents'} </button> <button className="media-sync-button" disabled={!selected} onClick={() => { if (selected) { commit({ type: 'keyframes', edits: [ { type: 'sides', clipId: opacityClipId, keyframeId: selected.id, incoming: { handle: null }, outgoing: { handle: null }, }, ], }); } }} > Reset tangents </button> </div> <div className="media-sync-controls"> <button className="media-sync-button" disabled={!state.selectedKeyframes.length} onClick={() => { clipboard.current = engine.keyframes.copyKeyframes(); setMessage('Keyframes copied. Paste places them at the playhead.'); }} > Copy </button> <button className="media-sync-button" onClick={() => paste(false)}> Paste </button> <button className="media-sync-button" disabled={!state.selectedKeyframes.length} onClick={() => paste(true)} > Duplicate </button> <button className="media-sync-button" onClick={() => engine.undo()}> Undo </button> <button className="media-sync-button" onClick={() => engine.redo()}> Redo </button> </div> <button className="media-sync-button" aria-expanded={expanded} onClick={() => { curveTrack.setTrackHeight(expanded ? 64 : 240); }} > {expanded ? 'Collapse curve lane' : 'Expand curve lane'} </button> <p className="media-sync-status"> Shift-drag empty space to select. Shift locks a drag axis; Alt makes fine adjustments; Ctrl/⌘ bypasses snapping. Escape cancels. Arrow keys nudge; [ and ] visit keys. </p> <p className="media-sync-status" role="status"> {message} </p> </section> );}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 { type TimelineReadonly, type Clip, type TimelineEngine, type TimelineKeyframe, type TimelineKeyframeEditCommand, type Track, compareRational, toSeconds, type RationalTime,} from '@techsquidtv/canvas-timeline';export const opacityKeyframeValuePadding = 10;
export function findClipContainingTime( track: TimelineReadonly<Track>, time: RationalTime): TimelineReadonly<Clip> | null { const seconds = toSeconds(time); return ( track.clips.find( (clip) => toSeconds(clip.timelineStart) <= seconds && toSeconds(clip.timelineEnd) >= seconds ) ?? null );}
export function findOpacityKeyframeAtTime( clip: TimelineReadonly<Clip>, time: RationalTime): TimelineKeyframe | null { return ( (clip.keyframes ?? []).find( (keyframe) => keyframe.property === 'opacity' && compareRational(keyframe.time, time) === 0 ) ?? null );}
export function toggleOpacityKeyframeAtTime( engine: TimelineEngine, clipId: string, time: RationalTime, value: number): boolean { const found = engine.geometry.getClip(clipId); if (!found || found.track.locked) { return false; }
const existing = findOpacityKeyframeAtTime(found.clip, time); if (existing) { return engine.keyframes.removeClipKeyframe(clipId, existing.id); }
// Insertion subdivides the existing curve before setting the requested value. return Boolean( engine.keyframes.setClipKeyframe({ clipId, property: 'opacity', time, value, }) );}
/** App-owned easing presets expressed as progress within a segment. */export const keyframeCurvePresets = [ { id: 'linear', label: 'Linear', x1: 0, y1: 0, x2: 1, y2: 1 }, { id: 'hold', label: 'Hold', x1: 0, y1: 0, x2: 1, y2: 1 }, { id: 'ease-in', label: 'Ease in', x1: 0.42, y1: 0, x2: 1, y2: 1 }, { id: 'ease-out', label: 'Ease out', x1: 0, y1: 0, x2: 0.58, y2: 1 }, { id: 'ease-in-out', label: 'Ease in/out', x1: 0.42, y1: 0, x2: 0.58, y2: 1 },] as const;
/** Recognizes the actual two-sided curve, including custom tangent edits. */export function getCurvePresetId( left: TimelineReadonly<TimelineKeyframe>, right: TimelineReadonly<TimelineKeyframe>): string { if (left.outgoing?.interpolation === 'hold') { return 'hold'; } if (left.outgoing?.interpolation !== 'bezier' && right.incoming?.interpolation !== 'bezier') { return 'linear'; } const outgoing = left.outgoing?.handle ?? { x: 0.42, y: left.value }; const incoming = right.incoming?.handle ?? { x: 0.58, y: right.value }; const delta = right.value - left.value; return ( keyframeCurvePresets.find( (preset) => preset.id !== 'linear' && preset.id !== 'hold' && Math.abs(outgoing.x - preset.x1) < 1e-6 && Math.abs(incoming.x - preset.x2) < 1e-6 && Math.abs(outgoing.y - (left.value + delta * preset.y1)) < 1e-6 && Math.abs(incoming.y - (left.value + delta * preset.y2)) < 1e-6 )?.id ?? 'custom' );}
/** Applies both ends of a segment in one undoable command. */export function createCurvePresetCommand( clipId: string, left: TimelineReadonly<TimelineKeyframe>, right: TimelineReadonly<TimelineKeyframe>, id: string): TimelineKeyframeEditCommand { const preset = keyframeCurvePresets.find((candidate) => candidate.id === id); if (!preset) { return { type: 'keyframes', edits: [] }; } const interpolation = id === 'hold' ? 'hold' : id === 'linear' ? 'linear' : 'bezier'; const delta = right.value - left.value; return { type: 'keyframes', edits: [ { type: 'sides', clipId, keyframeId: left.id, outgoing: { interpolation, handle: { x: preset.x1, y: left.value + delta * preset.y1 } }, }, { type: 'sides', clipId, keyframeId: right.id, incoming: { interpolation: interpolation === 'hold' ? 'linear' : interpolation, handle: { x: preset.x2, y: left.value + delta * preset.y2 }, }, }, ], };}import { type Marker, type Track, fromSeconds } from '@techsquidtv/canvas-timeline';import { getDemoClipColor } from './demo-clip-colors';
export const sampleSourceId = 'big-buck-bunny-keyframe-preview';
export const sampleMediaUrl = '/demo-media/big-buck-bunny-preview.webm';export const sampleDurationSeconds = 24;export const opacityClipId = 'opacity-demo-clip';
export const demoTracks: Track<'visual'>[] = [ { id: 'opacity-video-track', kind: 'visual', name: 'Opacity', locked: false, muted: false, visible: true, selected: true, targeted: true, height: 64, clips: [ { id: opacityClipId, sourceId: sampleSourceId, timelineStart: fromSeconds(0), timelineEnd: fromSeconds(sampleDurationSeconds), sourceStart: fromSeconds(0), selected: true, color: getDemoClipColor(1), label: 'Opacity keyframes', keyframes: [ { id: 'opacity-kf-0', property: 'opacity', time: fromSeconds(0), value: 1, }, { id: 'opacity-kf-1', property: 'opacity', time: fromSeconds(5), value: 0.28, }, { id: 'opacity-kf-2', property: 'opacity', time: fromSeconds(10), value: 0.82, outgoing: { interpolation: 'bezier', handle: { x: 0.16, y: 0.42 } }, }, { id: 'opacity-kf-3', property: 'opacity', time: fromSeconds(15), value: 0.42, incoming: { interpolation: 'bezier', handle: { x: 0.3, y: 0.42 } }, }, { id: 'opacity-kf-4', property: 'opacity', time: fromSeconds(sampleDurationSeconds), value: 1, }, ], }, ], },];
export const demoMarkers: Marker[] = [ { id: 'fade-down', time: fromSeconds(5), label: 'Fade', }, { id: 'hold-step', time: fromSeconds(10), label: 'Peak', },];.timeline-editor-body-with-headers { --demo-editor-scrollbar-gutter-size: 1.5rem; --demo-editor-scrollbar-track-size: 0.625rem; --demo-editor-scrollbar-gutter-padding: calc( (var(--demo-editor-scrollbar-gutter-size) - var(--demo-editor-scrollbar-track-size)) / 2 ); --demo-editor-scrollbar-gutter-padding-inline: calc( (var(--demo-editor-scrollbar-gutter-size) - var(--demo-editor-scrollbar-track-size) - 2px) / 2 ); --demo-editor-scrollbar-surface: var(--background);
width: 100%; min-width: 0; min-height: 0; overflow: hidden;}
.timeline-editor-header-panel,.timeline-editor-timeline-panel { display: grid; min-width: 0; min-height: 0; height: 100%; grid-template-rows: minmax(0, 1fr) auto; overflow: hidden;}
.timeline-editor-header-stage { min-height: 0; overflow: hidden;}
.timeline-editor-timeline-stage { min-height: 0;}
.timeline-editor-stage-row { display: grid; min-width: 0; min-height: 0; grid-template-columns: minmax(0, 1fr) auto;}
.timeline-editor-vertical-scrollbar-column { box-sizing: border-box; display: flex; width: var(--demo-editor-scrollbar-gutter-size); min-height: 0; align-items: stretch; justify-content: center; background: var(--demo-editor-scrollbar-surface); padding-block: var(--demo-editor-scrollbar-gutter-padding); padding-inline: var(--demo-editor-scrollbar-gutter-padding-inline);}
.timeline-editor-vertical-scrollbar { width: var(--demo-editor-scrollbar-track-size); min-height: 0;}
.timeline-editor-track-headers { width: 100%; height: 100%;}
.timeline-editor-root-with-headers .timeline-track-list-overlay { inset: 0;}
.timeline-editor-track-header-content { display: grid; width: 100%; min-width: 0; align-items: center; gap: 0.3rem;}
.timeline-editor-track-header-button { display: inline-grid; width: 1.45rem; height: 1.45rem; place-items: center; padding: 0; border: 1px solid color-mix(in oklch, var(--foreground) 10%, transparent); border-radius: calc(var(--radius) - 0.125rem); background: color-mix(in oklch, var(--background) 80%, transparent); color: var(--muted-foreground); cursor: pointer; transition: border-color 150ms ease, color 150ms ease, background-color 150ms ease, opacity 150ms ease;}
.timeline-editor-track-header-button svg { width: 0.85rem; height: 0.85rem;}
.timeline-editor-track-header-button:hover,.timeline-editor-track-header-button[aria-pressed='true'] { border-color: color-mix(in oklch, var(--primary) 55%, transparent); background: color-mix(in oklch, var(--primary) 12%, var(--background)); color: var(--foreground);}
.timeline-editor-track-header-button:disabled,.timeline-editor-track-header-button:disabled:hover { cursor: not-allowed; opacity: 0.52;}
.timeline-editor-track-header-label { min-width: 0; justify-self: start; overflow: hidden; color: var(--foreground); font-size: 0.7rem; text-align: left; text-overflow: ellipsis; white-space: nowrap;}
.timeline-editor-scrollbar-row { display: grid; min-height: var(--demo-editor-scrollbar-gutter-size); grid-template-columns: minmax(0, 1fr) var(--demo-editor-scrollbar-gutter-size); padding: 0; background: var(--demo-editor-scrollbar-surface);}
.timeline-editor-scrollbar-row .timeline-viewport-scrollbar { width: auto; min-width: 0; height: var(--demo-editor-scrollbar-track-size); align-self: center; margin-inline: var(--demo-editor-scrollbar-gutter-padding);}
.timeline-editor-scrollbar-row .timeline-viewport-scrollbar-handle { width: var(--demo-editor-scrollbar-track-size);}
.timeline-editor-column-resize-handle { position: relative; width: 0.8rem; flex: 0 0 0.8rem; background: var(--muted); cursor: col-resize; outline: none;}
.timeline-editor-column-resize-handle::before { position: absolute; inset-block: 0; left: 50%; width: 2px; background: var(--border); content: ''; transform: translateX(-50%); transition: background-color 150ms ease, box-shadow 150ms ease;}
.timeline-editor-column-resize-handle:hover::before,.timeline-editor-column-resize-handle:focus-visible::before,.timeline-editor-column-resize-handle[data-resize-handle-active]::before { background: var(--primary); box-shadow: 0 0 0 2px color-mix(in oklch, var(--primary) 28%, transparent);}
.media-sync-demo { display: grid; gap: 1rem; margin-top: 1rem;}
.media-sync-preview { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(16rem, 0.55fr); align-items: stretch; gap: 1rem;}
.media-sync-monitor,.media-sync-panel { overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); color: var(--foreground);}
.media-sync-monitor { position: relative; display: grid; min-height: 18rem; background: var(--muted);}
.media-sync-canvas,.media-sync-video { width: 100%; height: 100%; min-height: 18rem; object-fit: contain; background: var(--muted);}
.media-sync-panel { display: grid; align-content: start; gap: 0.8rem; padding: 1rem;}
.media-sync-panel h3 { margin: 0; font-size: 0.95rem;}
.media-sync-status { margin: 0; color: var(--muted-foreground); font-size: 0.85rem; line-height: 1.45;}
.media-sync-readout { display: grid; grid-template-columns: auto 1fr; gap: 0.35rem 0.7rem; margin: 0; font-size: 0.8rem;}
.media-sync-readout dt { color: var(--muted-foreground);}
.media-sync-readout dd { margin: 0; color: var(--foreground); font-family: var(--font-mono);}
.media-sync-controls { display: flex; flex-wrap: wrap; gap: 0.5rem;}
.media-sync-button { min-height: 2.15rem; padding: 0.4rem 0.75rem; border: 1px solid var(--input); border-radius: calc(var(--radius) - 0.25rem); background: var(--background); color: var(--foreground); cursor: pointer; font: inherit; font-size: 0.82rem; transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease;}
.media-sync-button:hover { border-color: var(--foreground); background: var(--accent); color: var(--accent-foreground);}
.media-sync-button:disabled { cursor: not-allowed; opacity: 0.45;}
.media-sync-button-active,.media-sync-button-active:hover { border-color: var(--foreground); background: var(--foreground); color: var(--background);}
.media-sync-play-button { position: absolute; bottom: 1rem; left: 1rem; z-index: 2; min-width: 4.8rem; background: color-mix(in oklch, var(--background) 84%, transparent); box-shadow: 0 0.75rem 1.75rem color-mix(in oklch, var(--foreground) 24%, transparent); backdrop-filter: blur(10px);}
.keyframe-opacity-demo { min-width: 0;}
.keyframe-opacity-preview { min-width: 0;}
.keyframe-opacity-monitor { background: linear-gradient( 45deg, color-mix(in oklch, var(--foreground) 8%, transparent) 25%, transparent 25% ), linear-gradient( -45deg, color-mix(in oklch, var(--foreground) 8%, transparent) 25%, transparent 25% ), linear-gradient( 45deg, transparent 75%, color-mix(in oklch, var(--foreground) 8%, transparent) 75% ), linear-gradient( -45deg, transparent 75%, color-mix(in oklch, var(--foreground) 8%, transparent) 75% ), var(--card); background-position: 0 0, 0 8px, 8px -8px, -8px 0; background-size: 16px 16px;}
.keyframe-opacity-video { background: transparent;}
.keyframe-opacity-panel { min-width: 0;}
.keyframe-opacity-readout { font-variant-numeric: tabular-nums;}
.keyframe-opacity-actions { align-items: center;}
.keyframe-opacity-curve-controls { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.35rem;}
.keyframe-opacity-curve-button { min-width: 0; min-height: 1.8rem; padding-inline: 0.45rem; font-size: 0.74rem;}
.keyframe-opacity-curve-button[aria-pressed='true'],.keyframe-opacity-curve-button[aria-pressed='true']:hover { border-color: var(--foreground); background: var(--foreground); color: var(--background);}
.keyframe-opacity-button { display: inline-flex; align-items: center; gap: 0.35rem;}
.keyframe-opacity-button svg { width: 0.85rem; height: 0.85rem;}
.keyframe-opacity-slider { width: 100%; height: 4px; appearance: none; border-radius: 9999px; background: var(--border); outline: none;}
.keyframe-opacity-slider::-webkit-slider-thumb { width: 14px; height: 14px; appearance: none; border: 2px solid var(--card); border-radius: 9999px; background: var(--primary); cursor: pointer;}
.keyframe-opacity-slider::-moz-range-thumb { width: 14px; height: 14px; border: 2px solid var(--card); border-radius: 9999px; background: var(--primary); cursor: pointer;}
.keyframe-opacity-status { min-height: 1.2em;}
.keyframe-opacity-timeline-shell { --demo-editor-shell-height: 20rem; width: 100%; max-width: 100%; height: var(--demo-editor-shell-height); min-height: 0;}
.keyframe-opacity-timeline-shell .timeline-stage { min-height: 0;}
.timeline-editor-keyframe-track-header-content { grid-template-columns: auto minmax(0, 1fr) auto;}
.timeline-editor-keyframe-button[aria-pressed='true'] svg { fill: currentcolor;}
@media (max-width: 720px) { .keyframe-opacity-preview { grid-template-columns: 1fr; }}
@media (max-width: 820px) { .media-sync-preview { grid-template-columns: minmax(0, 1fr); }}
.keyframe-editor-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.65rem;}.keyframe-editor-field { display: grid; gap: 0.3rem; color: var(--muted-foreground); font-size: 0.8rem;}.keyframe-editor-field input,.keyframe-editor-field select { width: 100%; min-width: 0; padding: 0.4rem; border: 1px solid var(--input); border-radius: calc(var(--radius) - 0.25rem); color: var(--foreground); background: var(--background); font: inherit;}.keyframe-editor-field input:focus-visible,.keyframe-editor-field select:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px;}