Miraiclip SDK
API reference

@miraiclip/renderer

Rendering and playback for Miraiclip: WebCodecs media pipeline, WebGL compositor, frame-accurate playback.

npm install @miraiclip/renderer
Version0.8.1
Runs inBrowser (WebCodecs + WebGL)
Exports143
GuidesPreview & playback, Browser export, Stills

Functions

accumulatePeaks

Fold one decoded chunk into peaks (pure; exported for tests and custom decoders).

function accumulatePeaks(peaks: Float32Array, bucketUs: Us, chunk: AudioChunk): void

analyzeHtmlAnimations

The animation timeline of (already parameter-substituted) markup laid out at widthPx×heightPx, or undefined when it can't be analysed (no DOM, time read through --t, SMIL…) — callers then treat every frame as new. Cached per markup; analysis costs a few ms once per template.

function analyzeHtmlAnimations(markup: string, widthPx: number, heightPx: number): HtmlAnimationTimeline | undefined

assertDecodable

Per-asset capability check — reject one asset, not the whole renderer.

function assertDecodable(assetId: string, config: VideoDecoderConfig): Promise<void>

assignVideoLanes

Decoder lanes per asset — interval colouring over each clip's render span (its bounds plus any transition extension), padded by gapUs. Clips that never come within gapUs of each other share lane 0 (the asset's shared pipeline, where sequential cuts keep decoder continuity); overlapping or adjacent ones get the lowest free lane. The number of decoders an asset ever needs is therefore its peak overlap — a montage of 12 back-to-back transitioned cuts needs 2–3 lanes, not 12 (which is what one dedicated pipeline per transition clip cost, and what exhausted mobile memory).

function assignVideoLanes(doc: ProjectDocument, gapUs: Us): Map<string, number>

buildAnimationTimeline

Build the timeline from animation descriptors (pure — see analyzeHtmlAnimations for the DOM side).

function buildAnimationTimeline(descriptors: readonly AnimationDescriptor[]): HtmlAnimationTimeline | undefined

clipFades

The clip's fade envelope (0..1) at a timeline position: a linear ramp up over fadeInUs from the clip start and down over fadeOutUs to its end. Fades longer than the clip are scaled down proportionally so they meet.

function clipFades(clip: VideoClip | AudioClip): {
    inUs: Us;
    outUs: Us;
}

collectHtmlRasters

Rasterize every html clip of doc on THIS thread (requires a DOM) into transferable bitmaps for a worker export. Keys match what the worker-side compositor computes, so rasterizeHtml there resolves without a DOM. Deduplicated: clips sharing (template, params, size) share one raster. Animated clips are skipped — they change every frame, so the worker asks for each frame's raster on demand instead (setHtmlRasterSource).

function collectHtmlRasters(doc: Pick<ProjectDocument, "clips" | "assets" | "settings">, options?: {
    outputSize?: {
        width?: number;
        height?: number;
    };
}): Promise<{
    rasters: Record<string, ImageBitmap>;
    transfer: Transferable[];
}>

Guides: HTML clips

compositionEnd

End of the last clip — the default export range's end.

function compositionEnd(doc: ProjectDocument): Us

Guides: Timeline

computePlacement

Convert a clip's normalized transform to pixel-space placement.

function computePlacement(clip: Clip, settings: ProjectSettings): Placement

computeWaveformPeaks

Compute peaks for a whole asset. Resolves with all-zero peaks for silent media.

function computeWaveformPeaks(source: AudioTrackSource, options: WaveformPeaksOptions): Promise<WaveformPeaks>

Guides: Timeline, Waveform

createMediabunnyDemuxerFactory

A demuxer factory with options (see MediabunnyDemuxerOptions).

function createMediabunnyDemuxerFactory(options?: MediabunnyDemuxerOptions): (assetId: string, src: unknown) => Promise<VideoTrackDemuxer>

createMediabunnySink

Probe-then-build: rejects up front with a clear error when this browser cannot encode the chosen format (e.g. H.264 encode missing), instead of failing frames deep into an export.

function createMediabunnySink(options: CreateMediabunnySinkOptions): Promise<ExportSink>

createMonitoredWebAudioOutput

A WebAudio output whose channels all pass through one master GainNode, so the monitoring volume is a single gain change. The audio engine connects each channel to context.destination; shadowing that property on this context instance routes them into the master instead.

function createMonitoredWebAudioOutput(gain: number): MonitoredAudioOutput

createPixiBackend

Create the PixiJS scene backend bound to a canvas.

function createPixiBackend(options: PixiBackendOptions): Promise<SceneBackend>

Guides: Preview & playback

createPlayer

The playback controller: wires media pipeline, compositor, audio engine and the master clock into one transport. The clock is derived from the audio output's own time, so scheduled audio and the frames chasing the clock cannot drift apart. The playhead is pushed into the core each frame via setPlayhead (ephemeral — never in undo history).

function createPlayer(project: Project, options: CreatePlayerOptions): Player

Guides: Preview & playback, Custom clip kinds, Custom effects, Production readiness

createPreviewController

The preview half of an editor, without any UI framework: owns the canvas's renderer and player, a fallback clock, and the monitoring volume. Framework adapters subscribe to it (getState / subscribe) and forward DOM events.

Safe under React StrictMode: an attach → detach → attach on the same canvas builds one renderer, never two on one WebGL context.

function createPreviewController(project: Project, options?: PreviewControllerOptions): PreviewController

Guides: Preview & playback, Quickstart, Roadmap

createVideoSupport

Bridges the MediaManager into the Compositor: creates video scene nodes that pull cached frames on every tick and keep decode-ahead primed. Pipelines stay owned by the MediaManager (shared per asset).

function createVideoSupport(project: Project, manager: MediaManager, options?: VideoSupportOptions): VideoSupport

createWebAudioOutput

Create the WebAudio output (and its master clock source).

function createWebAudioOutput(context?: AudioContext): AudioOutput

createWebCodecsDecoderFactory

A decoder factory with options — e.g. { maxOutputDimensionPx: 1920 } for proxy preview of 4K sources.

function createWebCodecsDecoderFactory(options?: WebCodecsDecoderOptions): FrameDecoderFactory

exportComposition

function exportComposition(options: ExportCompositionOptions): Promise<Uint8Array>

exportFrameWindow

Output frame n of an export of [rangeStartUs, rangeEndUs): its timeline span and the position it is rendered at. Frames follow the core frame rule (frameToUs from

function exportFrameWindow(n: number, rangeStartUs: Us, rangeEndUs: Us, fps: number | FrameRate): {
    startUs: Us;
    endUs: Us;
    sampleUs: Us;
}

Guides: Roadmap

exportProject

Offline export: renders the composition frame-by-frame into an OffscreenCanvas via the same compositor preview uses, and encodes as fast as decode+encode allow. Decode is capped at 2× the output's longest side (visually lossless for compositing; pass createDecoder: createWebCodecsDecoder for uncapped). Time moves strictly forward, so the streaming decoders never re-seek.

function exportProject(project: Project, options: ExportProjectOptions): Promise<Uint8Array>

Guides: HTML clips, Text & typography, Browser, Server (Node)

exportProjectInWorker

Run exportProject in a dedicated worker so the main thread stays free. Same total CPU — the work is identical — but the page keeps rendering and responding while the export runs, and the export stops competing with the UI for its thread.

function exportProjectInWorker(project: Project, options: ExportProjectInWorkerOptions): Promise<Uint8Array>

Guides: Export UI, HTML clips, Browser, Web worker

exportViaWorker

Drive an export inside worker (a Worker running the export worker entry).

function exportViaWorker(worker: Worker, project: Project, options: ExportViaWorkerOptions): Promise<Uint8Array>

Guides: Export UI, HTML clips, Browser, Web worker

fadeGainAt

function fadeGainAt(clip: VideoClip | AudioClip, timelineUs: Us): number

getEffectRenderer

function getEffectRenderer(kind: string): EffectRendererFactory | undefined

Guides: Custom effects

getTransitionRenderer

function getTransitionRenderer(kind: string): TransitionRenderer | undefined

Guides: Custom transitions

hasHardwareVideoEncoder

Whether this browser has a HARDWARE encoder for the format's video codec at the given output size. Used to pick the capture route: with a hardware encoder, zero-copy canvas capture drains fast and stays flat; with a SOFTWARE encoder (VP9/WebM on macOS, for example) the GPU-backed captured frames outlive the slow encode and Chrome's shared-image retention balloons — the same failure class as the SwiftShader capture leak, reported from the field as a machine-exhausting WebM High export on a real GPU. Probe errs toward false (CPU capture), the safe side.

function hasHardwareVideoEncoder(format: ExportFormat, width: number, height: number): Promise<boolean>

Guides: Browser

htmlAnimationTiming

Animated clips: the animation time (seconds) at timeline position timeUs, and its length.

function htmlAnimationTiming(clip: HtmlClip, timeUs: number): {
    timeS: number;
    durationS: number;
}

htmlRasterKey

The raster inputs that require a re-raster when they change. Animated clips pass the frame's timeS (and the animation length): one key per frame, matching rasterizeHtml's lookup of provided rasters.

function htmlRasterKey(clip: HtmlClip, widthPx: number, heightPx: number, density?: number, timing?: {
    timeS: number;
    durationS: number;
}): string

isPcmAudioChunk

function isPcmAudioChunk(value: unknown): value is PcmAudioChunk

isRecoverableDecoderError

Errors that mean "this decoder instance is gone", not "this media can't be decoded": reclamation (QuotaExceededError, WebCodecs spec) and calls on a closed/unconfigured codec (InvalidStateError — Safari words it "VideoDecoder is not configured", Chromium "Cannot call 'decode' on a closed codec").

function isRecoverableDecoderError(error: Error): boolean

isSoftwareWebGL

True when the canvas's WebGL context reports a software rasterizer.

function isSoftwareWebGL(canvas: HTMLCanvasElement | OffscreenCanvas): boolean

isWebCodecsSupported

True when this environment can run the WebCodecs decode pipeline.

function isWebCodecsSupported(): boolean

Guides: Browser, Compatibility

loadFontAssets

Load every font asset in the document that hasn't been loaded yet. Resolves when all newly seen fonts have finished loading (or failed — a bad font URL logs loudly and falls back, it never wedges playback or an export). Returns true when at least one NEW font finished loading — the caller should re-render text (glyph metrics changed).

function loadFontAssets(doc: ProjectDocument, env?: FontEnv): Promise<boolean>

Guides: Text & typography

mixCompositionAudio

Render the composition's audio mix for a range into a single AudioBuffer, faster than realtime. Returns null when nothing contributes audio (the export then has no audio track). Browser-only (OfflineAudioContext); covered by the export e2e — the planning and mapping halves are unit-tested.

function mixCompositionAudio(options: MixCompositionAudioOptions): Promise<AudioBuffer | null>

openMediabunnyAudio

Open an asset's primary audio track; resolves null when it has none.

function openMediabunnyAudio(assetId: string, src: unknown): Promise<AudioTrackSource | null>

Guides: Timeline, Waveform

openMediabunnyDemuxer

Open the primary video track of a URL or Blob/File source (16 MiB read cache).

function openMediabunnyDemuxer(assetId: string, src: unknown): Promise<VideoTrackDemuxer>

Guides: Asset import

peaksForRange

Downsample peaks to width columns (max per column) for drawing a clip's visible range.

function peaksForRange(waveform: WaveformPeaks, fromMediaUs: Us, toMediaUs: Us, width: number): Float32Array

Guides: Timeline

planAudioJobs

The pure planning half: which clips contribute audio to the range, at what gain, and where in their source to start reading. Headless-testable.

function planAudioJobs(doc: ProjectDocument, range: ExportRange): AudioMixJob[]

probeCompositionAudio

Whether the composition contributes ANY audio over range — decided by probing each distinct contributing asset until one actually opens an audio track (planning alone counts every video clip as audible even when its asset carries no audio stream). Shared by exportProject and the worker export driver, which must decide this on the MAIN thread.

function probeCompositionAudio(doc: ProjectDocument, range: ExportRange, openAudio: AudioSourceFactory): Promise<boolean>

provideHtmlRasters

Install pre-rendered rasters (replaces — and closes — any previous set).

function provideHtmlRasters(rasters: Record<string, ImageBitmap>): void

rasterizeHtml

Rasterize markup to a WebGL-safe texture source. With a DOM, that is a laundered canvas (see header). Without one (workers), a pre-rendered bitmap installed via provideHtmlRasters is served instead — exportViaWorker pre-rasterizes on the main thread and transfers the bitmaps — and a miss throws a clear error.

function rasterizeHtml(options: RasterizeHtmlOptions): Promise<HTMLCanvasElement | ImageBitmap>

Guides: HTML clips

registerEffectRenderer

Register how a custom effect kind draws: a factory building a Pixi filter from validated params, updated in place on effect/update. Pair it with core's registerEffectKind(kind, paramsSchema) so commands validate. A kind without a renderer applies no visual (the stack skips it).

Custom renderers are functions, so they cannot cross a process or thread boundary: server export and worker export support built-in kinds only — the same rule as custom clip-kind factories.

function registerEffectRenderer(kind: string, factory: EffectRendererFactory): void

Guides: Custom effects, Roadmap

registerTransitionRenderer

Register how a custom transition kind draws. Pair it with core's registerTransitionKind(kind, paramsSchema) so commands validate. A kind without a renderer draws as a hard cut at the boundary.

function registerTransitionRenderer(kind: string, renderer: TransitionRenderer): void

Guides: Custom transitions, Roadmap

renderEffectThumbnails

Render effect thumbnails as data URLs, keyed by kind. Kinds that fail to render are skipped.

function renderEffectThumbnails(options?: EffectThumbnailOptions): Promise<Map<string, string>>

Guides: Effects, Custom effects

renderProjectStill

Render ONE composition frame to an image, through the exact pipeline exports use (same compositor, same decode path, same fonts) — so a still is what that frame will look like in the exported file, by construction. Thumbnails, poster frames, and agent previews all want this.

function renderProjectStill(project: Project, options: RenderProjectStillOptions): Promise<Blob>

Guides: HTML clips, Text & typography, Stills, Custom clip kinds

setHtmlRasterSource

Install (or clear, with undefined) the on-demand raster source for DOM-less threads.

function setHtmlRasterSource(source: HtmlRasterSource | undefined): void

Guides: HTML clips

substituteParams

{{name}} → params.name, HTML-escaped (params are data, never markup).

function substituteParams(template: string, params: Record<string, HtmlParamValue>): string

Guides: HTML clips

toMediaUs

Timeline position → position on the clip's source media.

function toMediaUs(clip: VideoClip, timelineUs: Us): Us

zIndexFor

function zIndexFor(trackIndex: number, clipIndexInTrack: number): number

Classes

AudioEngine

Schedules decoded audio against the output clock. The mapping is anchored once per start/seek: timeline time t plays at output time anchorOutputUs + (t - anchorTimelineUs) / rate. Because the master clock IS the output clock, video (which chases the clock) and audio (scheduled on it) cannot drift apart.

new AudioEngine(project: Project, output: AudioOutput, openAudio: AudioSourceFactory, options?: AudioEngineOptions)
MemberTypeDescription
start(timelineUs: Us, rate?: number) => voidBegin (or re-anchor after a seek/rate change) playback at timelineUs.
pump(timelineUs: Us) => voidExtend the scheduling window as the clock advances. Call periodically.
stop() => voidStop all scheduled audio (pause or pre-seek).
dispose() => void

Compositor

Mirrors the project document into a scene backend and renders it as a pure function of time. Subscribes to the core's patch events, so any dispatched command — including undo/redo and remote patches — updates the scene granularly: only clips named in the patches are re-synced.

new Compositor(project: Project, backend: SceneBackend, options?: CompositorOptions)
MemberTypeDescription
nodeCountnumberMounted scene nodes (with residency: only clips near the last render time).
updateResidency(timeUs: Us) => voidMount nodes for clips near timeUs and unmount far ones. renderAt does this itself; call it directly before preparing decode for a time that hasn't been rendered yet (seeks, export frames). Cheap when the time moved less than 200ms since the last call.
renderAt(timeUs: Us) => voidRender the composition at a timeline position. Cheap when nothing changed.
getClipBounds(clipId: string, timeUs?: Us) => ClipBounds | nullWhere a clip is drawn at timeUs (default: the last rendered time), in composition pixels — or null when the clip isn't on screen then, has no visual node, or has nothing drawn yet. Keyframed position/scale/rotation are evaluated at that time; transition offsets (slides) are not applied, since an interaction layer edits the clip's own transform.
hitTest(xPx: number, yPx: number, timeUs?: Us, filter?: (clip: Clip) => boolean) => string | nullThe topmost clip whose drawn content contains the composition-pixel point at timeUs (default: the last rendered time), or null. Clips with a static opacity of 0 are skipped (nothing to click on). Pass filter to restrict candidates — e.g. exclude clips on locked tracks.
whenReady() => Promise<void>Resolves when every node's async content (image textures, html rasters) is ready. Exports and stills await this so no frame bakes in a missing texture; live playback doesn't wait (content pops in when loaded).
renderExactAt(timeUs: number) => Promise<void>Render the frame at timeUs with every node's content current — for time-varying async content (animated html clips) the first render asks for the frame, and this waits for it and renders again. What exports and stills draw each frame with; live playback uses renderAt.
hasPendingContent() => booleanTrue when some node is still loading content for the last render.
resync() => voidRe-sync every node from the document and re-render at the current time. For out-of-band render-input changes the patch stream can't see — e.g. a font asset finishing its load (text metrics changed under every node).
destroy() => void

ExportAbortedError

Extends Error.

new ExportAbortedError()

Guides: Export UI, Browser, Web worker

FrameCache

Holds decoded frames around the playhead. Owns every frame it stores: frames are closed when evicted, replaced, or cleared.

new FrameCache(options?: FrameCacheOptions)
MemberTypeDescription
frameCapacitynumberHow many frames this cache will hold right now: the frame cap, reduced to the pool cap while it holds decoder buffers. Decode-ahead sizes itself by this (see VideoPipeline.effectiveAheadUs).
setByteBudget(bytes: number) => voidChange the byte budget (the MediaManager splits one global budget across its live pipelines). Takes effect at the next evict.
sizenumber
byteSizenumber
byteBudgetnumber
put(frame: VideoFrameHandle) => voidStore a frame. A frame already cached at the same timestamp is replaced (and closed).
frameAt(us: Us) => VideoFrameHandle | undefinedThe frame covering us ([timestamp, timestamp+duration)), or the nearest earlier frame as a fallback, or undefined when nothing earlier is cached.
has(timestampUs: Us) => boolean
evict(playheadUs: Us) => voidEvict (and close) frames until within budget: the past before the future. Behind frames go first (oldest first); ahead frames only when nothing behind remains (farthest first); the frame covering the playhead never. Symmetric farthest-from-playhead eviction is a trap during playback: right after a seek the behind-tail is short, so the farthest frame is the freshly decoded AHEAD frame — decode-ahead output gets discarded on arrival, burning a hole in the timeline exactly one cache-capacity past the seek point. The playhead reaches the hole, re-seeks (legitimately — the frame really was evicted), and the new stream re-arms the same trap: a re-seek every cacheCapacity/fps seconds, forever.
clear() => voidClose and drop every cached frame.

MediaManager

Central owner of decode resources. One pipeline per ASSET LANE (clips that never overlap share a lane; overlapping clips of one asset get separate lanes), a cap on simultaneously active pipelines, idle release once no clip holds a pipeline, and one frame-memory budget shared by every pipeline.

new MediaManager(options: MediaManagerOptions)
MemberTypeDescription
activeCountnumber
isActive(assetId: string) => boolean
acquire(assetId: string, src: unknown, lane?: string, holder?: string) => Promise<VideoPipeline>The pipeline for an asset (lane), creating it — and evicting over the cap — as needed. A holder registers the caller as a user of the pipeline until unhold; held pipelines are never idle-released.
touch(assetId: string, lane?: string) => voidMark an asset's pipeline (or a lane's) as in use WITHOUT acquiring it. Holders cache the pipeline they acquired and never call acquire again, so without this the LRU order freezes at acquisition time — and the eviction picks the pipeline that has been held (and ticked every frame) longest, i.e. exactly the one still on screen. Call it from the per-frame tick.
unhold(assetId: string, lane: string | undefined, holder: string) => voidholder no longer uses the pipeline (its clip left the neighbourhood of the playhead, was deleted, or moved to another lane). Once no holder remains the pipeline is released after idleReleaseMs (see sweep).
sweep() => voidDispose pipelines nobody has held for idleReleaseMs. Cheap; the player calls it from its pump, and every acquire calls it first.
reportMemoryPressure() => voidThe platform is under memory pressure (a decoder was reclaimed, the GPU context was lost…): shrink the frame budget by a quarter, down to one minimum share per live pipeline. Playback degrades (shorter decode-ahead) instead of the page being killed.
resetMemoryBudget() => voidRestore the configured frame budget (e.g. after the app is foregrounded).
stats() => MediaManagerStats
release(assetId: string) => voidRelease one asset's pipeline (e.g. when its last clip is removed).
dispose() => void

Guides: Production readiness

RealtimeClock

Wall-clock playback clock with rate control. The time source is injectable (defaults to performance.now / Date.now) so it is fully testable; the audio graph will later supply an AudioContext-derived source for drift-free sync.

Extends Clock.

new RealtimeClock(nowMs?: () => number)
MemberTypeDescription
playingboolean
ratenumber
timeUsUs
play() => void
pause() => void
seek(us: Us) => void
setRate(rate: number) => void

StepClock

Deterministic clock for export and tests: time moves only when told to.

Extends Clock.

MemberTypeDescription
timeUsUs
playingboolean
setTime(us: Us) => void
step(fps: FrameRateLike) => UsAdvance to the start of the next frame at the given rate (a number or { num, den }). The new time is computed from the frame index (frameToUs(usToFrame(t) + 1)), so stepping never accumulates rounding.

UnsupportedMediaError

Raised when an asset's codec cannot be decoded in this environment.

Extends Error.

new UnsupportedMediaError(assetId: string, message: string, codec?: string)
MemberTypeDescription
assetIdstring
codecstring | undefined

Guides: Browser, Compatibility

VideoPipeline

Per-asset decode pipeline with continuous streaming: one decoder is kept alive and fed forward as the playhead advances (with an ahead budget as backpressure). A fresh keyframe seek happens only on a backward jump or a large forward jump — linear playback never tears the decoder down, which is what keeps preview smooth. A prime() whose target invalidates the current decode position aborts the in-flight feed via a generation counter.

new VideoPipeline(demuxer: VideoTrackDemuxer, createDecoder: FrameDecoderFactory, options?: VideoPipelineOptions)
MemberTypeDescription
isDisposedbooleanTrue once dispose() ran — the MediaManager evicts least-recently-used pipelines over its cap, so holders must check this and re-acquire rather than keep priming a dead pipeline.
frameAt(us: Us) => VideoFrameHandle | undefined
info() => Promise<VideoTrackInfo>Track metadata (native size, duration, fps) — fetched once, then cached.
waitForFrame(us: Us, timeoutMs?: number) => Promise<void>Resolve once a cached frame COVERS us — not merely once decode has been scheduled. prime() resolves when chunks are fed; the decoded frames arrive asynchronously afterwards (decoder callback plus a GPU copy). Playback polls every animation frame so it never notices, but a render-once consumer (export, thumbnails) must wait for the actual frame or it captures the previous one — duplicated frames in an export. Best-effort bounded: resolves anyway at end of stream (a time past the last frame) or after bounded re-priming, showing the nearest frame.
cacheSizenumber
cacheBytesnumberBytes of decoded frames currently held.
setCacheBudget(bytes: number) => voidResize this pipeline's share of the frame budget (set by the MediaManager as pipelines come and go). The ahead window adapts through effectiveAheadUs; excess frames go at the next arrival's eviction.
prime(targetUs: Us) => Promise<void>Ensure frames around targetUs are decoded into the cache.
dispose() => void

Constants

createWebCodecsDecoder

Full-resolution decoder factory. For preview playback of large sources, prefer createWebCodecsDecoderFactory.

const createWebCodecsDecoder: FrameDecoderFactory

Z_PER_TRACK

Clips per track are stacked by start time (later starts on top).

const Z_PER_TRACK: 10000

Interfaces

ActiveEffect

MemberTypeDescription
kindstring
filterFilterThe Pixi filter applied to the clip's node (destroyed when removed).
update(params: Record<string, unknown>) => voidApply new params IN PLACE — called on every effect/update, so no shader recompiles.

Guides: Custom effects

AnimationDescriptor

One CSS animation, reduced to what decides when its output changes.

MemberTypeDescription
delaySnumberAnimation start on the clip's animation clock (seconds) — CSS delay.
durationSnumberOne iteration (seconds).
iterationsnumberIteration count; Infinity for infinite.
direction"normal" | "reverse" | "alternate" | "alternate-reverse"CSS animation-direction.
keyframes{ offset: number; easing: string; values: Record<string, string>; }[]Keyframes in offset order: values holds every animated property's value (as strings), easing the timing function of the segment that STARTS at this keyframe.

AudioChannel

One mixable output lane (per clip): gain + scheduled buffers.

MemberTypeDescription
schedule(native: unknown, whenUs: Us, offsetUs: Us, durationUs: Us, rate: number) => voidPlay native starting at absolute output time whenUs, skipping offsetUs into the buffer, playing at most durationUs of it, at rate speed.
setGain(value: number) => void
setGainAutomation?(points: readonly { atOutputUs: Us; value: number; }[]) => voidReplace the channel's gain automation with linear ramps through these output-clock points (animated clip volume). Optional — engines fall back to setGain at the window start when a backend doesn't implement it.
stopAll() => voidStop everything scheduled on this channel.
close() => void

AudioChunk

A decoded chunk of audio on the media's own timeline.

MemberTypeDescription
timestampUsUs
durationUsUs
nativeunknownThe underlying buffer (a Web Audio AudioBuffer in the browser).

AudioEngineOptions

MemberTypeDescription
aheadUs?UsHow far ahead of the playhead audio is kept scheduled (default 3s).
prerollUs?UsOpen a clip's audio source this long before it enters the scheduling window (default 1s), and close it this long after the clip ends. Only clips near the playhead hold an open source: each one is a demuxer with its own read cache, and opening every audible clip of a long timeline at play start doesn't scale.

AudioMixJob

Offline audio mix for export: the same clip math the realtime AudioEngine uses (shared via audio/mapping.ts), rendered in one non-realtime pass with an OfflineAudioContext instead of being scheduled against a live clock.

MemberTypeDescription
clipIdstring
assetIdstring
srcunknown
fromMediaUsUsMedia position to start pulling chunks from.
gainnumber
clipVideoClip | AudioClip

AudioOutput

Audio output abstraction (a WebAudio AudioContext in the browser; a fake in tests). Its clock is the master playback clock.

MemberTypeDescription
currentTimeUsUsThe output's own clock — monotonic, drift-free vs what it plays.
channel(id: string) => AudioChannel
resume() => Promise<void>Unlock/resume the output (autoplay policies). Safe to call repeatedly.
close() => void

AudioTrackSource

Streaming source of decoded audio for one asset. Audio is pulled in chunks around the playhead — a long file is never decoded whole (an hour of PCM is gigabytes). The real implementation wraps mediabunny's AudioBufferSink.

MemberTypeDescription
chunksFrom(startUs: Us) => AsyncGenerator<AudioChunk, void, undefined>Yield chunks in order starting at or before startUs.
dispose() => void

Guides: Timeline

ClipBounds

Where a clip is drawn, in composition pixels, at one timeline position: the content rectangle after the clip's scale and rotation (keyframes evaluated). What an interaction layer needs to draw a selection box and map pointer drags back onto the clip's transform.

MemberTypeDescription
clipIdstring
originXPxnumberThe placement point (the clip's transform x/y, in pixels). Scale and rotation pivot here.
originYPxnumber
centerXPxnumberCenter of the drawn content (equals the origin for centered content).
centerYPxnumber
widthPxnumberContent size after the clip's scale.
heightPxnumber
scalenumberThe clip's (evaluated) scale and rotation.
rotationDegnumber
corners[Point, Point, Point, Point]Content corners, clockwise from top-left (rotation applied).
localLocalBoundsThe unscaled, unrotated content rectangle relative to the origin.
znumberStacking order (higher draws on top).

Clock

The compositor renders as a pure function of (document, clock.timeUs). Playback uses a realtime clock (audio-driven once the audio graph lands); export drives the same compositor with a StepClock.

MemberTypeDescription
timeUsUs
playingboolean

CompositorOptions

MemberTypeDescription
factories?Record<string, NodeFactory>Extra or overriding node factories per clip kind (the v4/custom-kind seam).
outputSize?{ width: number; height: number; }Render at this pixel size instead of the composition size (the export width/height option). Composition coordinates are unaffected — the backend scales the scene. Requires a backend with setOutputSize (the Pixi backend has it).
residency?CompositorResidencyOptions | falseMount scene nodes only for clips near the time being rendered, instead of one node per clip in the document (default: on, 2s ahead / 1s behind). Nodes hold the heavy resources — decoded frames, textures, html rasters, effect filters — so with residency, memory follows how much is on screen around the playhead, not how long the timeline is. A node is unmounted once its clip is more than twice the window away (hysteresis, so a scrub along a window edge doesn't remount it every frame). Pass false for the old mount-everything behaviour.

CompositorResidencyOptions

MemberTypeDescription
aheadUs?UsMount clips starting within this far ahead of the render time (default 2s).
behindUs?UsKeep clips that ended within this far behind the render time (default 1s).

ConstantSpan

The answer for one time.

MemberTypeDescription
sinceSnumberThe picture has been constant since this time (seconds, ≤ the time asked). Two times with the same sinceS render identically.

CreateMediabunnySinkOptions

MemberTypeDescription
canvasHTMLCanvasElement | OffscreenCanvasThe canvas the compositor renders into — captured once per frame.
formatExportFormat
quality?ExportQualityPreset | { videoBitrate: number; }Preset, or an explicit video bitrate in bits/second. Default "standard".
keyFrameIntervalSeconds?numberKeyframe interval in seconds (default 2 — mediabunny's default).
cpuCapture?booleanRoute the per-frame capture through a 2D canvas (CPU-backed frames) instead of snapshotting the WebGL canvas directly. Needed under SOFTWARE WebGL (SwiftShader/llvmpipe — headless CI, some VMs), where Chromium's GPU process retains one shared-image per captured frame while a decoder and encoder run simultaneously — an environment quirk that turns long exports into unbounded memory growth (found by the stress suite; every frame IS closed correctly on our side). On real GPUs the direct snapshot is cheaper and clean, so exportProject sets this automatically from the detected renderer.
target?WritableStream<StreamTargetChunk>Stream the encoded file out as it is produced instead of buffering it in memory: each chunk is { type: "write", data, position } (positions may seek backwards — containers patch headers), the shape FileSystemWritableFileStream.write accepts directly, so a showSaveFilePicker() writable works as-is. Backpressure on the stream throttles the encoders. With a target set, finalize() resolves with an EMPTY array — the bytes went to the stream.

CreatePlayerOptions

MemberTypeDescription
backendSceneBackend
openDemuxerDemuxerFactory
createDecoderFrameDecoderFactory
audioOutputAudioOutput
openAudioAudioSourceFactory
factories?Record<string, NodeFactory>Scene-node factories for custom clip kinds (registered in core with registerClipKind) — the same seam built-in video uses. Keyed by clip kind; "video" is reserved (the player owns the video pipeline).
outputSize?{ width: number; height: number; }Render the preview canvas at this pixel size while composition coordinates stay unchanged (the stage scales). Pass the canvas's CSS size × devicePixelRatio for a sharp hi-DPI preview — html rasters and text glyphs regenerate at that density automatically.
loop?booleanLoop back to 0 at the end of the composition (default false: pause).
onError?(error: Error, clipId: string) => voidMedia failures per clip (unsupported codec, decode error, …). Default: console.error.
raf?(callback: () => void) => numberFrame scheduler — injectable for tests (default requestAnimationFrame).
cancelRaf?(handle: number) => void
schedule?(callback: () => void, intervalMs: number) => numberInterval scheduler for the hidden-tab fallback — injectable for tests (default setInterval/clearInterval). See the pump interval below.
cancelSchedule?(handle: number) => void
memory?PlayerMemoryOptionsMemory limits for preview playback. The defaults are sized for phones as well as desktops; raise frameBudgetBytes on a desktop app for deeper decode-ahead.

EffectContext

The Pixi half of the effect registry: kind → filter factory. Built-ins (colorAdjust, blur, chromaKey) register through the same public contract custom kinds use — registerEffectRenderer. Filters mutate in place on param updates (no shader recompiles while dragging a slider).

MemberTypeDescription
compositionSize() => { width: number; height: number; }
renderScale?() => numberPhysical pixels per composition pixel (output size ÷ composition size, or the preview's devicePixelRatio) — the density at which rasterized content (html rasters, text glyphs) should be generated so it stays sharp when the stage scales up. Absent or 1 = render at composition density.

Guides: Custom effects

EffectThumbnailOptions

MemberTypeDescription
kinds?readonly string[]Kinds to render (built-in or registered custom). Default: every catalog kind.
size?numberSquare thumbnail size in px. Default 144.
sample?CanvasImageSourceImage to apply effects to (drawn cover-fit). Default: a built-in sample scene.
lengthScale?numberMagnifies length params (dot size, blur distance…): at thumbnail size they would be 1–3px and unreadable, so the default 3 reads like a crop of a full-size frame. 1 = true scale.
type?"image/jpeg" | "image/png" | "image/webp"Default "image/jpeg".
quality?number
onThumbnail?(kind: string, dataUrl: string) => voidCalled as each thumbnail is ready — fill a grid progressively.
signal?AbortSignal

EncodedChunkLike

An encoded chunk flowing from demuxer to decoder. Opaque beyond metadata.

MemberTypeDescription
timestampUsUs
isKeyboolean
nativeunknownThe underlying chunk (an EncodedVideoChunk in the browser).

ExportCompositionOptions

MemberTypeDescription
startUsUsComposition range to export. endUs must be greater than startUs.
endUsUs
fpsnumber | FrameRateOutput frame rate: a number (29.97 is read as 30000/1001) or an exact { num, den } ratio — see toFrameRate in
renderFrame(timeUs: Us) => Promise<void>Render the composition at a timeline position into the sink's canvas — awaits exact decode (videos.renderFrameAt in production).
sinkExportSink
mixAudio?(context: MixAudioContext) => Promise<unknown | null>Produce the mixed audio for the WHOLE range in one buffer (OfflineAudioContext in production). Return null for a silent/audio-less composition — no audio track is added. Receives the abort signal and a progress callback. Simple, but the buffer is the entire timeline's PCM — for long timelines prefer mixAudioChunk, which bounds that memory. When both are given, mixAudioChunk wins.
mixAudioChunk?(context: MixAudioContext, chunkRange: { startUs: Us; endUs: Us; }) => Promise<unknown | null>Produce the mixed audio for ONE chunk of the range. The orchestrator calls it with strictly sequential sub-ranges of audioChunkUs (the last one clipped to endUs), interleaved with the frame walk, so a long timeline never holds more than one chunk of uncompressed PCM. Return null from the FIRST chunk to declare the whole composition audio-less — no audio track is added and no further chunks are requested. After a first non-null chunk, every later chunk MUST return a buffer (a silent stretch returns a silent buffer — chunk timestamps accumulate by buffer duration, so a skipped chunk would shift all later audio earlier); a null then fails the export loudly.
audioChunkUs?UsAudio mix chunk length for mixAudioChunk (default 60s). Bounds PCM memory: 48kHz stereo float is ~23MB per minute, so the default holds ~23MB regardless of timeline length. Integer seconds keep chunk boundaries sample-exact.
signal?AbortSignal
onProgress?(progress: ExportProgress) => void
encodeAheadFrames?numberHow many encoder submissions may be in flight while the next frame renders (default 4). 1 = fully serial. The window is what lets decode, compositing, and encoding overlap instead of hand-off lockstep.

ExportProgress

MemberTypeDescription
phase"audio" | "video" | "finalizing"
framesDonenumber
totalFramesnumber
audioMixedUs?UsDuring the audio phase: timeline µs mixed so far, out of the range total.
audioTotalUs?Us

Guides: Browser, Web worker

ExportProjectInWorkerOptions

Extends ExportViaWorkerOptions.

MemberTypeDescription
worker?WorkerUse this Worker instead of spawning one (it will NOT be terminated).
workerUrl?string | URLSpawn from this URL instead of the bundled entry (module worker).

ExportProjectOptions

MemberTypeDescription
formatExportFormat
quality?ExportQualityPreset | { videoBitrate: number; }
fps?number | FrameRateOutput frame rate (default: the project's exact rate, projectFrameRate). A number or { num, den }.
width?numberOutput size (default: the project's composition size).
height?number
range?ExportRangeComposition range (default: 0 → end of the last clip).
signal?AbortSignal
onProgress?(progress: ExportProgress) => void
openDemuxer?DemuxerFactoryMedia adapters — injectable, with browser defaults.
createDecoder?FrameDecoderFactory
openAudio?AudioSourceFactory
factories?Record<string, NodeFactory>Scene-node factories for custom clip kinds — pass the SAME factories the player uses so custom kinds render identically in preview and export. (Functions can't cross a process boundary: server-side export via @miraiclip/server-export supports built-in kinds only for now.)
target?WritableStream<StreamTargetChunk>Stream the encoded file out as it is produced, instead of holding it in memory and resolving with the bytes. Each chunk is { type: "write", data, position } — exactly what FileSystemWritableFileStream.write accepts, so a showSaveFilePicker() writable works directly (positions may seek backwards; containers patch their headers). Backpressure on the stream throttles the encoders. With a target set, the returned promise resolves with an EMPTY Uint8Array once the stream has everything — required for long exports, whose output does not fit in memory.
audioChunkSeconds?numberAudio mix chunk length in seconds (default 60). Audio is mixed in bounded sequential chunks (~23MB of PCM per minute at 48kHz stereo) instead of one whole-timeline buffer, so timeline length does not grow mix memory. Use integer seconds — boundaries stay sample-exact at 48kHz.
audioOverride?{ hasAudio: boolean; mixChunk?: (context: MixAudioContext, chunkRange: ExportRange) => Promise<unknown | null>; }Advanced — replace the built-in audio pipeline entirely (worker hosts: OfflineAudioContext and AudioBuffer are window-only, so a worker export gets its audio mixed OUTSIDE the worker and fed in as chunks). hasAudio replaces the asset probe; when true, mixChunk must return a chunk (an AudioBuffer or a PcmAudioChunk) for EVERY requested range — silent stretches as real silence, never null.

ExportRange

MemberTypeDescription
startUsUs
endUsUs

ExportSink

Where exported media goes: one video frame at a time (the sink captures the canvas it was constructed around), plus the mixed audio. The production implementation wraps mediabunny's Output; tests use a fake.

MemberTypeDescription
addVideoFrame(timestampUs: Us, durationUs: Us) => Promise<void>Capture the canvas's CURRENT content as the frame at timestampUs. CONTRACT: the capture must happen SYNCHRONOUSLY during this call (before the promise is returned), so the caller may repaint the canvas for the next frame immediately. The returned promise is encode/mux backpressure only — the export loop keeps a small bounded window of them in flight (encoder works on frames n-3…n-1 while frame n renders) and awaits the oldest when the window is full, which bounds memory.
addAudio?(buffer: unknown) => Promise<void>Append mixed audio (an AudioBuffer in the browser). Optional per sink. May be called MULTIPLE times with strictly sequential chunks — chunk N+1 plays immediately after chunk N (timestamps accumulate by buffer duration), which is what lets a long export mix audio in bounded chunks instead of one whole-timeline buffer. The FIRST call must land before the first video frame (it registers the audio track; tracks freeze when the container starts).
finalize() => Promise<Uint8Array>Finish the container and return the encoded file bytes. A sink writing to a streaming target returns an EMPTY array — the bytes already went to the stream.
cancel() => Promise<void>Abort: discard everything, release encoders. Safe to call once, any time.

ExportViaWorkerOptions

Main-thread side of the worker export: serializes the document across, relays progress and abort, mixes audio chunks on THIS thread (the OfflineAudioContext mixer is window-only) and transfers them as PCM planes, and resolves with the result exactly like exportProject.

Two layers:

  • exportViaWorker(worker, project, options) — bring your own Worker (bundler worker imports, custom URLs). The worker is NOT terminated.
  • exportProjectInWorker(project, options) — convenience: spawns the bundled entry (new URL("./export.worker.js", import.meta.url), the pattern Vite/webpack/Rollup rewrite; pass worker or workerUrl where that pattern can't resolve) and terminates it when done.

Boundary rules: custom clip-kind factories cannot cross (functions) — built-in kinds only, same as server export. Media src values must be reachable from a worker: http(s)/blob URLs and Blob/File objects all work. Html clips DO cross: they rasterize on this thread (workers have no DOM) and the bitmaps transfer with the start message.

Extends WorkerExportWireOptions.

MemberTypeDescription
signal?AbortSignal
onProgress?(progress: ExportProgress) => void
target?WritableStream<StreamTargetChunk>Stream the encoded file out — same contract as exportProject's target, and a showSaveFilePicker() writable works directly. Chunks RELAY from the worker through this thread (FileSystemWritableFileStream is not transferable), acked only after each write resolves, so the stream's backpressure reaches the worker's encoders. The stream is left unclosed for the caller, exactly like a main-thread export.

FrameCacheOptions

MemberTypeDescription
maxFrames?numberMaximum number of frames held (default 60).
maxBytes?numberMaximum total bytes held (default 512 MiB — decoded frames are large).
maxBehindFrames?numberFrames kept BEHIND the playhead regardless of budget headroom (default 8). Played frames are only useful for a short step back; without this cap a cache fills its whole budget with history (120 × 720p ≈ 440 MB per pipeline), which is what exhausted mobile memory.
maxPoolFrames?numberCap on frames that still hold decoder output buffers (poolBacked handles — live VideoFrames), default 6. Hardware decoders own small output pools (often 8–16) and stall when a cache keeps them all; 6 leaves headroom while still covering a few frames of decode-ahead.

FrameDecoder

Decoder abstraction (a WebCodecs VideoDecoder in the browser).

MemberTypeDescription
configure(config: unknown) => void
decode(chunk: EncodedChunkLike) => void
flush() => Promise<void>Resolves once all pending frames have been emitted via onFrame.
reset() => voidAbort in-flight work and drop pending frames, so the same decoder can be re-pointed to a new keyframe without a costly teardown/re-init. The decoder returns to an unconfigured state — configure before decoding.
close() => void
onFrame((frame: VideoFrameHandle) => void) | null
onError((error: Error) => void) | null

HtmlAnimationTimeline

MemberTypeDescription
constantSpanAt(timeS: number) => ConstantSpan | undefinedThe constant span containing timeS, or undefined when the picture is changing continuously at timeS (every frame differs).

HtmlRasterOptions

Live-preview quality limits for html clips (exports leave these unset: exact, full density).

MemberTypeDescription
minDensity?numberLowest raster density (physical ÷ composition pixels). Default 1: rasters are never smaller than the composition. A live preview smaller than the composition (a phone) can pass e.g. 0.25 to raster at the size actually displayed — ~6× fewer pixels per raster for a 1080p project on a phone.
maxAnimatedFps?numberCap animated html re-rasters per clip (preview only; export waits for every frame).
budget?numberShare of main-thread time animated html rasters may use, 0..1 (e.g. 0.3). An html raster is a full document build on the main thread (~10–15 ms on a desktop, several times that on a phone), so an unbounded preview spends the whole frame on it and video, audio and input starve. With a budget, every animating clip gets an equal share and lowers its own frame rate to stay inside it — the picture stays exact, just updated less often. Preview only; unset = no limit.
reuseStaticFrames?booleanKeep the current raster while nothing animates. The template's CSS animations are analysed once (through the browser's own CSS engine) to find when the picture actually changes — fly-ins, then holds; steps() counters — and identical frames are not rasterized again. Anything that can't be analysed (markup reading --t directly, SMIL) re-rasters every frame as before. Preview only (exports leave it off and raster every frame).

LocalBounds

A node's drawn content as a rectangle in its LOCAL space: relative to the node's placement point (the clip's transform x/y), before the clip's own scale and rotation, in composition pixels. Node-intrinsic sizing is already applied (a video's fit-to-composition, a text's measured glyphs, an html clip's raster box). xPx/yPx is the rectangle's top-left corner, so a node drawn centered on its placement point has xPx = -widthPx / 2.

MemberTypeDescription
xPxnumber
yPxnumber
widthPxnumber
heightPxnumber

MediabunnyDemuxerOptions

MemberTypeDescription
maxCacheBytes?numberRead cache per open source, in bytes (default 16 MiB). Mediabunny's own default for URL sources is 64 MiB — per open file, and a timeline keeps several open at once (one per decoder lane), which on a phone is a meaningful slice of the tab's memory for no playback benefit: decoding reads forward, a few seconds of stream is plenty.

MediaManagerOptions

MemberTypeDescription
openDemuxerDemuxerFactory
createDecoderFrameDecoderFactory
maxActivePipelines?numberMaximum simultaneously active pipelines (≈ hardware decoder instances). Browsers cap these — some Safari configurations allow very few. Default 4.
evictionIdleMs?numberA pipeline used (acquired/touched) within this window is never evicted, even over the cap — the cap is then temporarily exceeded instead. Evicting a pipeline that is being ticked every frame just makes its holder re-acquire it, evicting another live one: an acquire/evict livelock that shows up as playback churning whenever more clips are simultaneously visible than the cap allows. Default 500ms; 0 restores a hard cap.
frameBudgetBytes?numberDecoded-frame memory shared by ALL pipelines (default 256 MiB). Each live pipeline gets an equal share (never below minPipelineBudgetBytes), and the shares are rebalanced as pipelines open and close — so total frame memory stays bounded however many clips a timeline has. Before this was global, every pipeline could hold 512 MiB on its own.
minPipelineBudgetBytes?numberFloor for one pipeline's share of the frame budget (default 24 MiB).
idleReleaseMs?numberA pipeline whose holders have all released it (its clips left the playhead's neighbourhood) is disposed after this long (default 1500ms). The short grace keeps scrubbing back and forth across a cut cheap.
pipeline?VideoPipelineOptions
now?() => numberClock for idle bookkeeping (tests). Defaults to Date.now.

MediaManagerStats

MemberTypeDescription
pipelinesnumber
idlePipelinesnumberPipelines nothing currently holds (waiting out idleReleaseMs).
cachedFramesnumber
cachedBytesnumber
frameBudgetBytesnumber

MixCompositionAudioOptions

Extends MixAudioContext.

MemberTypeDescription
docProjectDocument
rangeExportRange
openAudioAudioSourceFactory
sampleRate?number
channels?number
silenceIfEmpty?booleanReturn a zero-filled buffer instead of null when nothing contributes in this range. Chunked exports set this for every chunk after deciding (via planAudioJobs over the FULL range) that the composition has an audio track: chunk timestamps accumulate by buffer duration, so a silent stretch must still occupy its samples.

MonitoredAudioOutput

A preview audio output whose level can be changed after creation.

MemberTypeDescription
outputAudioOutput
setGain(gain: number) => voidSet the monitoring gain (0..1); implementations should ramp to avoid clicks.

NodeFactoryContext

MemberTypeDescription
backendSceneBackend
assetsReadonly<Record<string, Asset>>

Guides: Custom clip kinds

PcmAudioChunk

Raw planar PCM audio chunk — the worker-export audio interchange. AudioBuffer and OfflineAudioContext are window-only, so a worker export mixes audio on the main thread and transfers chunks as channel planes (zero-copy); the sink turns them into encoder samples. Accepted anywhere an AudioBuffer chunk is (the exporter treats chunks as opaque).

MemberTypeDescription
sampleRatenumber
numberOfFramesnumber
numberOfChannelsnumber
planesFloat32Array[]One Float32Array per channel, numberOfFrames samples each.

PixiBackendOptions

MemberTypeDescription
canvasHTMLCanvasElement
widthnumber
heightnumber
background?number
preserveDrawingBuffer?booleanKeep the drawing buffer readable after render (canvas.toDataURL, drawImage-based pixel readback). Costs a little GPU memory/bandwidth — enable for testing, thumbnails, or screenshot features (default false).
htmlRaster?HtmlRasterOptionsHtml clip raster limits for live preview — see HtmlRasterOptions.

Placement

Pixel-space placement computed from a clip's normalized transform.

MemberTypeDescription
xPxnumber
yPxnumber
scalenumber
rotationRadnumber
opacitynumber

Player

MemberTypeDescription
on(type: K, listener: (event: PlayerEvents[K]) => void) => () => voidSubscribe to a transport event. Returns an unsubscribe function.
play() => void
pause() => void
seek(timeUs: Us) => void
setRate(rate: number) => void
playingboolean
ratenumberCurrent playback speed (1 = normal).
timeUsUs
durationUsUsEnd of the last clip — the composition's duration.
getClipBounds(clipId: string, timeUs?: Us) => ClipBounds | nullWhere a clip is drawn now (or at timeUs), in composition pixels — see Compositor.getClipBounds.
hitTest(xPx: number, yPx: number, options?: { timeUs?: Us; filter?: (clip: Clip) => boolean; }) => string | nullTopmost clip drawn at a composition-pixel point now (or at timeUs) — see Compositor.hitTest.
stats() => PlayerStatsLive resource counts (see PlayerStats).
reportMemoryPressure() => voidTell the player the platform is short of memory (e.g. on a webglcontextlost or a page memorypressure signal): it shrinks the frame budget so playback degrades instead of the tab being killed.
destroy() => void

PlayerEvents

Transport events. Subscribe with player.on(type, listener); adapters use these instead of polling playing.

Extends Record<string, unknown>.

MemberTypeDescription
play{ timeUs: Us; }playing turned true (play() was called; frames may still be loading).
pause{ timeUs: Us; reason: "pause" | "ended"; }playing turned false. reason is "ended" when the composition ran out (no loop).
ended{ timeUs: Us; }Playback reached the end of the composition and stopped (fires after its pause).
seek{ timeUs: Us; }A seek moved the transport (user seek or a loop back to 0).
ratechange{ rate: number; }Playback speed changed.
error{ error: Error; clipId: string; }A media failure on one clip (also passed to onError).

PlayerMemoryOptions

MemberTypeDescription
frameBudgetBytes?numberDecoded-frame memory shared by all video pipelines (default 256 MiB).
maxActivePipelines?numberSimultaneous decoder pipelines before least-recently-used release (default 4).
idleReleaseMs?numberDispose a pipeline no clip has needed for this long (default 1500ms).
residency?CompositorResidencyOptions | falseMount scene nodes only near the playhead (default on) — see CompositorOptions.residency.

PlayerStats

Live resource counts — for dev overlays, tests and memory dashboards.

MemberTypeDescription
pipelinesnumberDecoder pipelines open (including idle ones about to be released).
idlePipelinesnumber
cachedFramesnumberDecoded frames held across all pipelines, and their bytes.
cachedBytesnumber
frameBudgetBytesnumber
mountedNodesnumberScene nodes mounted (clips near the playhead) / clips in the document.
clipsnumber

Point

MemberTypeDescription
xPxnumber
yPxnumber

PreviewController

MemberTypeDescription
getState() => PreviewState
subscribe(listener: () => void) => () => voidCalled after every state change. Returns an unsubscribe function.
play() => void
pause() => void
toggle() => void
seek(timeUs: Us) => voidSeek, clamped to [0, composition end].
attach(canvas: HTMLCanvasElement, size: { width: number; height: number; }) => voidBuild the live renderer on canvas. size is its CSS size; safe to call again on the same canvas.
resize(width: number, height: number) => voidThe canvas's CSS size changed (zoom, layout).
detach() => voidTear the live renderer down; the preview falls back to the clock-only mode.
setVolume(volume: number) => void
toggleMute() => void
getClipBounds(clipId: string) => ClipBounds | nullWhere a clip is drawn now, in composition px (null when not live or not drawn).
hitTest(xPx: number, yPx: number, filter?: (clip: Clip) => boolean) => string | nullTopmost clip at a composition-px point (null when not live or nothing there).
playerPlayer | nullThe live player, when there is one.
destroy() => voidDetach and drop every listener. The controller can't be used afterwards.

PreviewControllerOptions

MemberTypeDescription
backend?Omit<PixiBackendOptions, "canvas" | "width" | "height">Extra createPixiBackend options (e.g. htmlRaster, background).
player?Omit<CreatePlayerOptions, "backend" | "openDemuxer" | "createDecoder" | "audioOutput" | "openAudio" | "onError">Extra createPlayer options (e.g. memory, factories, loop).
maxDecodeDimensionPx?numberLongest decoded frame side, in px (default 1920). Decoding never exceeds the preview's own size.
maxPixelRatio?numberdevicePixelRatio cap for the render target (default 2).
initialVolume?numberStarting monitoring volume, 0..1 (default 1).
beforeLive?(project: Project) => Promise<void>Runs after the player is built, before the preview goes live (e.g. load web fonts).
onPlayerReady?(player: Player) => voidCalled once per live player, e.g. to expose stats to a dev overlay.
onError?(error: Error, clipId: string) => voidMedia failures per clip. Also reflected in lastError.
isSupported?() => boolean
createBackend?(options: PixiBackendOptions) => Promise<SceneBackend>
createPlayer?(project: Project, options: CreatePlayerOptions) => Player
createAudioOutput?(gain: number) => MonitoredAudioOutput
devicePixelRatio?() => number
raf?(callback: (timeMs: number) => void) => number
cancelRaf?(handle: number) => void

PreviewState

Everything a transport UI renders. A new object whenever any field changes.

MemberTypeDescription
playingboolean
modePreviewMode
lastErrorstring | nullLast renderer or media error, as text (null after a successful attach).
volumenumberPreview monitoring volume, 0..1. Never written to the project or exports.
mutedboolean

RasterizeHtmlOptions

MemberTypeDescription
templatestring
paramsRecord<string, HtmlParamValue>
widthPxnumber
heightPxnumber
assets?Readonly<Record<string, Asset>>Project assets: font assets inline as
density?numberPhysical pixels per logical (composition) pixel — raster at this density so upscaled outputs and hi-DPI previews stay sharp. Layout happens at the LOGICAL size (a scale transform supersamples it), so a template renders identically at every density. Default 1.
timeS?numberAnimated templates: seconds into the animation. Every CSS animation is paused and seeked to this time (animation-delay: calc(var(--d, 0s) - var(--t))), so the raster is the exact frame at timeS — deterministic in preview and export alike. --t and --T (durationS) are set on the template's root. Omit for a static raster (animations untouched).
durationS?numberAnimated templates: the animation's total length in seconds (--T).
target?HTMLCanvasElementDraw into this canvas (resized if needed) instead of a new one. Animated clips re-raster every frame; a fresh full-size canvas per frame was the largest source of preview memory churn. DOM path only.
image?HTMLImageElementLoad the SVG through this image element instead of a new one. Changing the src of a live element releases the previous SVG document and its resource bytes right away; a fresh Image() per raster left each one waiting for garbage collection — hundreds of MB of retained SVG documents (inlined fonts included) within a minute of animated preview. DOM path only; one raster at a time per element.

RenderProjectStillOptions

MemberTypeDescription
timeUsUsComposition time to render, in microseconds.
width?numberOutput size (default: the project's composition size).
height?number
type?"image/png" | "image/jpeg" | "image/webp"Image encoding (default PNG).
quality?numberFor lossy types: 0..1.
openDemuxer?DemuxerFactoryMedia adapters — injectable, with browser defaults (same as export).
createDecoder?FrameDecoderFactory
factories?Record<string, NodeFactory>Scene-node factories for custom clip kinds — pass the player's.

SceneBackend

Rendering backend abstraction. The production implementation is PixiJS; tests use a fake. Backends draw — the Compositor decides what and when.

MemberTypeDescription
resize(widthPx: number, heightPx: number) => voidSet the COMPOSITION size — the coordinate space clips are placed in.
setOutputSize?(widthPx: number, heightPx: number) => voidRender the composition at a different pixel size (the export/still width/height option): the canvas becomes this size and the scene scales to fit, while composition coordinates — placement, masks, caption layout — stay in composition space. Optional per backend; without it, output size falls back to the composition size.
createImage(clip: ImageClip, asset: Asset | undefined) => SceneNode
createText(clip: TextClip) => SceneNode
createVideo(clip: VideoClip) => VideoSceneNode
createCaption?(clip: CaptionClip) => SceneNodeKaraoke caption block (word wrap + active-word emphasis). Optional per backend.
createSolid?() => SolidSceneNodeFull-composition solid overlay (dip transitions). Optional per backend.
createHtml?(clip: HtmlClip, assets: Readonly<Record<string, Asset>>) => SceneNodeHTML clip: template rasterized to a texture. Optional per backend.
render() => void
destroy() => void

SceneNode

MemberTypeDescription
setPlacement(placement: Placement) => voidApply placement. The object may be REUSED by the caller — copy it if kept.
setVisible(visible: boolean) => void
setZ(z: number) => voidStacking order: higher renders on top.
update(clip: Clip) => voidRe-apply content-affecting clip properties (text, color, asset swap…).
setEffects?(effects: readonly EffectInstance[]) => voidApply the clip's effect stack (enabled entries, array order). Optional per backend.
setReveal?(fraction: number, direction: RevealDirection) => voidWipe transitions: show only part of the node, clipped in COMPOSITION space. The revealed region grows to cover the frame as fraction goes 0 → 1, its edge sweeping in direction; 1 clears the clip entirely. Optional per backend.
tick?(clip: Clip, timeUs: number) => voidCalled every render while the clip is visible, for time-dependent content (video frames). timeUs is the timeline position.
whenReady?() => Promise<void>Resolves when the node's content is ready to draw (async textures: image assets, html rasters). Exports and stills await every node's readiness before the frame walk so early frames never bake in missing content. Optional; absent means always ready.
isPending?() => booleanTrue while content for the LAST render is still loading — time-varying async content (animated html rasters) that a tick just requested. Frame-exact renderers (export, stills) then await whenReady and render the frame again. Optional; absent means never pending.
getLocalBounds?() => LocalBounds | nullThe content rectangle in local space (see LocalBounds), or null when nothing is drawn yet (texture still loading, no decoded frame). Powers Compositor.getClipBounds / hitTest. Optional per backend.
destroy() => void

Guides: Custom clip kinds

SolidSceneNode

A full-composition solid overlay — how dip-to-black/white transitions cover the hard cut. Owned by the Compositor, created lazily on first use.

MemberTypeDescription
set(colorRgb: number, alpha: number) => voidCover the composition with colorRgb (0xRRGGBB) at alpha.
setVisible(visible: boolean) => void
setZ(z: number) => void
destroy() => void

TransitionFrameEffects

The transition renderer registry: kind → how the compositor draws it.

A transition renderer is PURE math over progress — it returns per-frame adjustments the compositor applies to the participating clips through the existing primitives (opacity, directional reveal, pixel offset, and a full-composition overlay). The built-ins are expressed through this exact contract, so a registered custom kind is a first-class citizen: it renders identically in preview, browser export, and stills.

Custom renderers are functions, so they cannot cross a process or thread boundary: server export and worker export support built-in kinds only — the same rule as custom clip-kind factories. Register the matching param schema in core with registerTransitionKind so transition/add validates.

MemberTypeDescription
opacity?numberMultiplied into the clip's opacity (crossDissolve sets progress on the "to" side).
reveal?{ fraction: number; direction: RevealDirection; }Directional reveal 0..1 of the clip from an edge (wipe).
offsetXPx?numberOffset in composition pixels (slide).
offsetYPx?number
overlay?{ color: number; alpha: number; }Full-composition solid overlay (dips): when several transitions are active, the highest-alpha overlay wins for the frame.

Guides: Custom transitions

TransitionRenderContext

MemberTypeDescription
compositionSize{ width: number; height: number; }The composition size in its own coordinate space.

TransitionRenderer

MemberTypeDescription
rendersBothClipsbooleanWhether BOTH clips render through the window (blend kinds — the outgoing clip keeps showing past its end and the incoming starts early, both from source trim headroom, which transition/add validates as insufficient-handles). False for overlay kinds (dips), which cover the hard cut instead and need no out-of-bounds media at all.
frame(role: TransitionRole, progress: number, params: Record<string, unknown> | undefined, context: TransitionRenderContext) => TransitionFrameEffects | nullThe adjustments for one participating clip at one moment. progress runs 0 → 1 across the window (0.5 is the cut); role says which side this clip is on. Return null for "no adjustment". Called every rendered frame inside the window — keep it allocation-light and pure.

Guides: Custom transitions

VideoFrameHandle

A decoded video frame owned by the cache. close() releases the underlying resource (a WebCodecs VideoFrame in the browser) — every handle MUST be closed exactly once, by whoever holds it last.

MemberTypeDescription
timestampUsUsPresentation timestamp on the media's own timeline.
durationUsUs
byteLengthnumberApproximate memory footprint, for cache budgeting.
nativeunknownThe underlying frame (a VideoFrame in the browser).
poolBacked?booleanThe frame still holds a decoder output buffer (a live VideoFrame). Hardware decoders own a small pool of these and stall when it runs dry, so caches hold only a few such frames at a time (see FrameCache).
close() => void

VideoPipelineOptions

MemberTypeDescription
aheadUs?UsHow far past the target to keep decoding ahead (default 1s).
reseekGapUs?UsA forward jump larger than this triggers a fresh keyframe seek instead of decoding through the gap (default 3s). Backward jumps always re-seek.
cache?FrameCacheOptions
onMemoryPressure?() => voidCalled when the platform reclaims this pipeline's decoder for lack of resources (QuotaExceededError) — the MediaManager shrinks its frame budget in response.

VideoSceneNode

A scene node that displays decoded video frames.

Extends SceneNode.

MemberTypeDescription
setFrame(frame: unknown | null) => voidShow a decoded frame (a VideoFrame in the browser), or clear with null.
setSourceSize?(widthPx: number, heightPx: number) => voidThe source's native pixel size. Layout treats scale 1 as native size, so a backend that receives frames decoded below native resolution (proxy playback of 4K sources) uses this to keep the rendered size identical.

VideoSupport

MemberTypeDescription
factoryNodeFactoryRegister as the "video" factory on the Compositor.
prepare(timeUs: Us) => Promise<void>Await decode for every clip visible at (or starting soon after) timeUs.
renderFrameAt(compositor: Compositor, timeUs: Us) => Promise<void>Prepare then draw one exact frame — thumbnails, posters, export.
sweep() => voidRelease pipelines no clip near the playhead needs (see MediaManager.sweep).
dispose() => void

VideoSupportOptions

MemberTypeDescription
lookaheadUs?UsPrepare clips that start within this window ahead of the playhead (default 1s).
onError?(error: Error, clipId: string) => voidCalled when a clip's media fails (unsupported codec, decode error, …). Silent-by-default was a mistake we made once — the default logs loudly.
laneGapUs?UsClips of one asset whose render spans come closer than this share a lane only if they don't — closer clips get separate decoder lanes, so the next clip can warm up while the current one plays (default: the lookahead).

VideoTrackDemuxer

Demuxer abstraction over one media source's primary video track. The real implementation wraps mediabunny; tests inject fakes.

MemberTypeDescription
info() => Promise<VideoTrackInfo>
decoderConfig() => Promise<unknown>Opaque decoder configuration (a WebCodecs VideoDecoderConfig); cached per asset.
chunksFrom(targetUs: Us) => AsyncGenerator<EncodedChunkLike, void, undefined>Seek to the keyframe at or before targetUs and yield chunks in decode order from there. The demuxer owns the single keyframe lookup.
dispose() => void

VideoTrackInfo

MemberTypeDescription
codecstring
widthnumber
heightnumber
durationUsUs
fpsnumber | undefined

WaveformPeaks

Waveform peaks for timeline drawing: one max-abs amplitude (0..1) per bucket of media time, across all channels. Streams the asset through an AudioTrackSource chunk by chunk — never holds the decoded file — and is computed once per asset (cache the result; peaks don't depend on clips).

Chunks are read as Web Audio AudioBuffer-like objects (numberOfChannels, sampleRate, getChannelData).

MemberTypeDescription
peaksFloat32ArrayMax |sample| per bucket, 0..1.
bucketUsUsMedia time each bucket covers.
durationUsUsMedia time the peaks span (peaks.length * bucketUs).

WaveformPeaksOptions

MemberTypeDescription
durationUsUsMedia duration (asset durationUs).
bucketsPerSecond?numberResolution. Default 50 buckets/second — enough for any zoom a timeline uses.
maxBuckets?numberCap on bucket count for very long media. Default 20 000.
signal?AbortSignal

WebCodecsDecoderOptions

MemberTypeDescription
maxOutputDimensionPx?number | (() => number | undefined)Cap the longest side of decoded output bitmaps, preserving aspect ratio (proxy playback). A 4K source composited onto a preview canvas doesn't need full-resolution frames — and full-res 4K60 is ~4 GB/s of ImageBitmap copies plus texture uploads, which drops frames on most machines. The downscale happens on the GPU inside createImageBitmap. Layout is unaffected: scene nodes normalize scale against the source's native size. Leave unset for full-resolution output (export/thumbnails at full size). A function is read per frame — pass the preview's current pixel size so decoded frames never exceed what the canvas can show (a phone preview ~800px wide has no use for 1920px frames, and frame memory scales with the square of this).
frameStorage?"auto" | "bitmap" | "videoframe"How decoded frames are held until drawn. "auto" (default) decides per frame from where its pixels live: - CPU frames (I420/I422/I444 family — software decoders, e.g. VP9 in most desktop Chromes): kept as the VideoFrame and uploaded straight to WebGL. Converting them to ImageBitmaps went through the canvas image path and left ~1 GB of image-cache and GPU transfer memory behind on the launch film. They hold no decoder pool slot, so decode-ahead stays as deep as the byte budget allows. - GPU frames (NV12, BGRA… — hardware decoders, e.g. H.264/HEVC on most devices): copied to an ImageBitmap (GPU-side, resized to maxOutputDimensionPx) and closed at once. Hardware decoders own a small output pool and stall when a cache holds its frames — keeping them as VideoFrames made playback of phone/camera footage stutter. "videoframe" / "bitmap" force one storage for every frame.

WorkerExportWireOptions

The subset of ExportProjectOptions that crosses the boundary as data.

MemberTypeDescription
formatExportFormat
quality?ExportQualityPreset | { videoBitrate: number; }
fps?number | FrameRate
width?number
height?number
range?ExportRange
audioChunkSeconds?number

Types

AudioSourceFactory

Resolves to null when the asset has no audio track.

type AudioSourceFactory = (assetId: string, src: unknown) => Promise<AudioTrackSource | null>;

DemuxerFactory

type DemuxerFactory = (assetId: string, src: unknown) => Promise<VideoTrackDemuxer>;

EffectRendererFactory

Builds the live filter for one effect instance. params arrive validated against the kind's core schema (register it with registerEffectKind); length-denoting params should be composition-relative fractions, converted to pixels via context.compositionSize() — absolute pixels diverge between scaled preview and full-res export.

type EffectRendererFactory = (params: Record<string, unknown>, context: EffectContext) => ActiveEffect;

ExportFormat

The production ExportSink: mediabunny Output + WebCodecs encoding. MP4 (H.264 + AAC) and WebM (VP9 + Opus). Browser-only; everything above it (the export orchestrator) is environment-agnostic and unit-tested in Node.

type ExportFormat = "mp4" | "webm";

ExportQualityPreset

type ExportQualityPreset = "draft" | "standard" | "high";

FrameDecoderFactory

type FrameDecoderFactory = () => FrameDecoder;

HtmlRasterRequest

A raster request as data (no assets — the main thread has the document).

type HtmlRasterRequest = Omit<RasterizeHtmlOptions, "assets">;

HtmlRasterSource

Where a DOM-less thread gets rasters it wasn't given up front — animated html clips need one raster PER FRAME, far too many to pre-render and transfer, so the export worker installs a source that asks the main thread for each frame's raster on demand (see export.worker.ts).

type HtmlRasterSource = (request: HtmlRasterRequest) => Promise<ImageBitmap>;

MainToWorkerMessage

type MainToWorkerMessage = {
    type: "start";
    doc: ProjectDocument;
    options: WorkerExportWireOptions;
    hasAudio: boolean;
    streamOutput?: boolean;
    htmlRasters?: Record<string, ImageBitmap>;
} | {
    type: "abort";
} | {
    type: "audio-chunk";
    id: number;
    chunk: PcmAudioChunk | null;
    error?: string;
} | {
    type: "html-raster";
    id: number;
    bitmap: ImageBitmap | null;
    error?: string;
} | {
    type: "output-chunk-ack";
    id: number;
    error?: string;
};

MediaSrc

type MediaSrc = string | Blob;

NodeFactory

Creates the scene node for one clip kind — the extension seam for custom clip kinds (and how video plugs in). Return null to render nothing.

type NodeFactory = (clip: Clip, context: NodeFactoryContext) => SceneNode | null;

PreviewMode

  • live: real decode + WebGL compositing on the attached canvas.
  • fallback: no live renderer (no canvas yet, or it failed to build); a requestAnimationFrame clock still moves the playhead so timelines work.
  • unsupported: this browser has no WebCodecs; same clock as fallback.
type PreviewMode = "live" | "fallback" | "unsupported";

RevealDirection

Which way a wipe's edge sweeps / a slide's clip moves.

type RevealDirection = "left" | "right" | "up" | "down";

Us

Microseconds, matching

type Us = number;

WorkerToMainMessage

type WorkerToMainMessage = {
    type: "need-audio-chunk";
    id: number;
    startUs: number;
    endUs: number;
} | {
    type: "need-html-raster";
    id: number;
    request: HtmlRasterRequest;
} | {
    type: "output-chunk";
    id: number;
    chunk: StreamTargetChunk;
} | {
    type: "progress";
    progress: ExportProgress;
} | {
    type: "done";
    bytes: Uint8Array;
} | {
    type: "error";
    name: string;
    message: string;
};

On this page

FunctionsaccumulatePeaksanalyzeHtmlAnimationsassertDecodableassignVideoLanesbuildAnimationTimelineclipFadescollectHtmlRasterscompositionEndcomputePlacementcomputeWaveformPeakscreateMediabunnyDemuxerFactorycreateMediabunnySinkcreateMonitoredWebAudioOutputcreatePixiBackendcreatePlayercreatePreviewControllercreateVideoSupportcreateWebAudioOutputcreateWebCodecsDecoderFactoryexportCompositionexportFrameWindowexportProjectexportProjectInWorkerexportViaWorkerfadeGainAtgetEffectRenderergetTransitionRendererhasHardwareVideoEncoderhtmlAnimationTiminghtmlRasterKeyisPcmAudioChunkisRecoverableDecoderErrorisSoftwareWebGLisWebCodecsSupportedloadFontAssetsmixCompositionAudioopenMediabunnyAudioopenMediabunnyDemuxerpeaksForRangeplanAudioJobsprobeCompositionAudioprovideHtmlRastersrasterizeHtmlregisterEffectRendererregisterTransitionRendererrenderEffectThumbnailsrenderProjectStillsetHtmlRasterSourcesubstituteParamstoMediaUszIndexForClassesAudioEngineCompositorExportAbortedErrorFrameCacheMediaManagerRealtimeClockStepClockUnsupportedMediaErrorVideoPipelineConstantscreateWebCodecsDecoderZ_PER_TRACKInterfacesActiveEffectAnimationDescriptorAudioChannelAudioChunkAudioEngineOptionsAudioMixJobAudioOutputAudioTrackSourceClipBoundsClockCompositorOptionsCompositorResidencyOptionsConstantSpanCreateMediabunnySinkOptionsCreatePlayerOptionsEffectContextEffectThumbnailOptionsEncodedChunkLikeExportCompositionOptionsExportProgressExportProjectInWorkerOptionsExportProjectOptionsExportRangeExportSinkExportViaWorkerOptionsFrameCacheOptionsFrameDecoderHtmlAnimationTimelineHtmlRasterOptionsLocalBoundsMediabunnyDemuxerOptionsMediaManagerOptionsMediaManagerStatsMixCompositionAudioOptionsMonitoredAudioOutputNodeFactoryContextPcmAudioChunkPixiBackendOptionsPlacementPlayerPlayerEventsPlayerMemoryOptionsPlayerStatsPointPreviewControllerPreviewControllerOptionsPreviewStateRasterizeHtmlOptionsRenderProjectStillOptionsSceneBackendSceneNodeSolidSceneNodeTransitionFrameEffectsTransitionRenderContextTransitionRendererVideoFrameHandleVideoPipelineOptionsVideoSceneNodeVideoSupportVideoSupportOptionsVideoTrackDemuxerVideoTrackInfoWaveformPeaksWaveformPeaksOptionsWebCodecsDecoderOptionsWorkerExportWireOptionsTypesAudioSourceFactoryDemuxerFactoryEffectRendererFactoryExportFormatExportQualityPresetFrameDecoderFactoryHtmlRasterRequestHtmlRasterSourceMainToWorkerMessageMediaSrcNodeFactoryPreviewModeRevealDirectionUsWorkerToMainMessage