Choose the preview boundary
Canvas Timeline keeps clips lightweight. A clip stores a stable sourceId,
while your application owns files, URLs, proxies, offline state, relinks, and
export media. A media adapter receives the app-resolved preview source for each
ID and synchronizes it with timeline playback.
Choose the HTML media adapter when one native <video> or <audio> element can
represent the preview. Choose the Mediabunny adapter when you need decoded
canvas frames, separate visual/audio scheduling, local files, custom inputs, or
track selection.
Both packages keep their imperative roots framework-free. React hooks live in
the explicit ./react entry points:
import { useHTMLTimelineMedia } from '@techsquidtv/canvas-timeline-html-media-adapter/react';import { useMediabunnyTimelineMedia } from '@techsquidtv/canvas-timeline-mediabunny-adapter/react';The shared TimelineMediaSyncAdapter, source, timing, lifecycle, operation, and
error contracts live in @techsquidtv/canvas-timeline-core. Use
useTimelineMediaSync from the React package when implementing a different
decoder, renderer, or external clock.
Define one resolved source
The common source has two required fields:
const sources = [ { sourceId: 'camera-a', input: '/media/camera-a.mp4', },];sourceId joins the descriptor to timeline clips. input is the media your
application selected for this preview session. Ordinary inline URL descriptors
do not need useMemo; hooks reconcile definitions and preserve adapters and
loaded media when values have not changed.
Equivalent React source props also preserve pending and completed imperative
replaceSource() calls. A semantic change to the supplied registry still
reconciles the complete registry and supersedes pending replacements.
Use fallbacks only for ordered, content-equivalent ways to load the same
choice—for example, an HLS endpoint followed by a direct file, or a CDN URL
followed by an offline cache entry:
import { HLS_FORMATS } from 'mediabunny';
const sources = [ { sourceId: 'camera-a', input: { kind: 'url', url: manifestUrl, formats: HLS_FORMATS, urlSourceOptions: { maxCacheSize: 32 * 1024 * 1024 }, }, fallbacks: [directFile], },];Mediabunny accepts URL strings, URL, Request, Blob, and File directly.
Use its object descriptors only for explicit formats and UrlSource options,
an existing Mediabunny input, or an input factory. Keep Request, Blob,
File, factory, selector, and custom option identities stable when their
identity represents a distinct resource or executable policy.
Keep proxy policy in the media library
A proxy is not a decoder fallback. It is a separate media-library choice with its own purpose, lifecycle, quality, and timestamp mapping. Resolve the choice in app state and replace the adapter source without changing timeline clips:
const asset = { sourceId: 'camera-a', original: { input: originalFile }, proxies: { editing: { input: proxyUrl, timing: { sourceTimeSeconds: cameraTimestamp, mediaTimeSeconds: 0, }, }, },};
const selected = useProxy ? asset.proxies.editing : asset.original;
await media.adapter.replaceSource({ sourceId: asset.sourceId, ...selected,});The optional timing anchor says that sourceTimeSeconds and
mediaTimeSeconds identify the same frame or sample. Omit it when logical
source timestamps already equal media timestamps. This keeps epoch/live source
time, proxies, transcodes, and trimmed cache files aligned.
Mediabunny preserves negative decode timestamps in firstTimestampSeconds and
maps them into sourceFirstTimestampSeconds. Presentation starts at the later of
zero and the first timestamp; durationSeconds excludes negative preroll.
endTimestampSeconds is an end timestamp, including for Matroska files whose
container duration is measured relative to their first packet.
Use setSources(nextSources) when replacing the complete registry. Use
replaceSource(source) for one app-level media choice, and retrySource(id) to
retry the preferred input and its fallbacks. Replacement and retry return a
discriminated result with ok, sourceId, and either a configured/ready
state or a machine-readable failure reason.
Understand readiness and lazy loading
HTML and Mediabunny have intentionally different readiness boundaries:
useHTMLTimelineMedia().readymeans its returned callback ref connected a native media element. ReadsourceStateByIdto determine whether a source isidle,loading,recovering,ready, orfailed.useMediabunnyTimelineMedia().readymeans at least one source is registered and can be resolved. Sources startidleand load only when their clips are active or explicitly preloaded.
Mediabunny exposes lifecycle control for larger projects:
const result = await media.adapter.preloadSource(nextSourceId);
if (!result.ok) { console.error(result.reason, result.error);}
media.adapter.unloadSource(previousSourceId);preloadSource warms a registered source without changing proxy/original
policy. unloadSource releases its decoder and audio resources but keeps the
definition registered; the source returns to idle and reloads when needed.
Module-loader failures use the same discriminated operation result and failed
source state as input failures. A later retrySource() call invokes the lazy
module loader again.
Adapter disposal is terminal. Work already in flight releases staged resources
without publishing late state. New loading, decoding, rendering, or mutating
calls after dispose() throw or reject, while read-only state and idempotent
teardown calls remain safe. React hooks enforce this ownership boundary when
Strict Mode or option changes recreate an adapter.
sourceStateById snapshots are referentially new when observable state changes.
Each state includes the selected input index, ordered attempts, and terminal
error. Mediabunny ready states also expose source-domain and media-domain
timestamps, duration, dimensions, rotation, detected frame rate, and audio
sample rate.
Select tracks and configure audio
Mediabunny uses the primary video and audio tracks by default. Supply
selectTracks(context) when the editor needs an alternate camera angle,
language, commentary mix, or another explicit track. The context contains all
available tracks plus the logical source and selected preferred/fallback input.
The adapter creates an audio graph only after loading a source with a decodable
audio track. A supplied AudioContext and destination remain caller-owned;
disposal closes only a context created by the adapter.
Use setVolume() and setMuted() for imperative runtime changes. In React,
updating audio.volume or audio.muted changes the master gain without
disposing the adapter or reloading sources.
Browser audio activation is separate from visual transport. The high-level
sync hook starts activation without awaiting it, so suspended audio cannot
prevent video playback. Observe adapter.audioStatus for suspended,
running, or degraded state. Imperative integrations can call
requestClockActivation() directly. An activation requested before lazy media
loading is retained until a selected decodable audio track creates the graph;
video-only media never resumes a caller’s suspended context.
The Mediabunny adapter itself implements TimelineMediaSyncAdapter, so pass the
adapter directly to useTimelineMediaSync(). Its transport clock is independent
of individual loaded source controllers: lazy source transitions, unloads, and
runtime input recovery do not reset timeline time.
When synchronization reaches a source whose decoder is recovering, it waits for
the fallback controller to commit. If recovery exhausts the source’s fallback
chain, synchronization reports sync-failed and the high-level transport
pauses; superseded recovery work cannot publish an error.
With Mediabunny 1.56.0 or later, background URL and Blob reads use the same recovery
path even when no read promise is pending. A supplied
urlSourceOptions.handleUnhandledError also receives the original error as an
observer. For supplied inputs and input factories, configure error callbacks on
the underlying Mediabunny source when constructing it.
Terminal failures remain observable on later ticks, seeks, and preload requests
until retrySource(), replaceSource(), an unload, or a changed source definition
explicitly resets that source.
Apply playback ranges and errors
High-level adapter hooks accept the same range policy as timeline playback:
const media = useMediabunnyTimelineMedia({ sources, layers, playbackOptions: { respectInOut: true, loop: true, }, onError: (error) => reportPreviewFailure(error.reason, error.message),});External clock ticks pass through Core range enforcement. When looping is
enabled, the high-level sync hook seeks and restarts the adapter clock at the
range start. At the lower-level useTimelineMediaPlayback boundary, looping is
enabled by supplying the loop(timelineTime, activeLayers) realignment callback
instead of a boolean; this prevents a clock from looping without a way to seek.
onError receives TimelineMediaError. Use its stable reason for program
flow and message for logs or UI. Rendering and scheduling failures report
sync-failed and pause both clocks, including terminal asynchronous iterator
recovery failures. Recoverable iterator failures first block the next
synchronization while the fallback controller loads.
Rejected loop realignment reports loop-failed through the same callback and
also pauses both clocks.
Play commands await startup synchronization before returning a discriminated
TimelineMediaPlayResult, and media-aware playback-rate commands likewise
resolve only after the external clock and active layers are synchronized. This
lets callers handle failures without parsing error text or receiving a stale
success result. Paused preview seeks share the same adapter-operation ordering
as startup, looping, ticks, pause cleanup, and rate changes. This matters when a
lazy source load or decoded-frame request finishes after the user has already
scrubbed or started playback at a newer position.
If React replaces an adapter instance while it owns playback, the synchronization
hook cancels pending startup, stops the previous clock, pauses the timeline, and
primes the replacement at the current position. This keeps native element
replacement and decoder-controller recreation from leaving the timeline driven
by a detached or frozen clock. The packaged hooks supply adapterIdentity
automatically. Custom useTimelineMediaSync() integrations should pass their
underlying controller as adapterIdentity; inline callback facades may omit it
and do not need memoization.
Continue reading
- Media adapter migration for removed APIs and breaking-change examples.
- HTML media adapter package for the native single-element path.
- Mediabunny adapter package for decoded canvas and Web Audio previews.
- HTML media sync demo and Mediabunny media sync demo for complete source-backed examples.
Compose media-aware keyboard transport
Pass your media transport to the existing keyboard scope so Space starts the external clock through the same path as the play button:
<Timeline.KeyboardScope commandHandlers={{ togglePlayback: () => (media.playing ? media.pause() : media.play()) }}> {/* Timeline surface */}</Timeline.KeyboardScope>Here media is the result of useTimelineMediaSync, useHTMLTimelineMedia, or useMediabunnyTimelineMedia. The scope waits for returned promises before notifying onCommandResult. Adapter errors still reach the media hook’s onError; unexpected thrown or rejected handler errors reach the scope’s onCommandError.