@miraiclip/renderer
Rendering and playback for Miraiclip: WebCodecs media pipeline, WebGL compositor, frame-accurate playback.
npm install @miraiclip/renderer| Version | 0.8.1 |
| Runs in | Browser (WebCodecs + WebGL) |
| Exports | 143 |
| Guides | Preview & 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): voidanalyzeHtmlAnimations
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 | undefinedassertDecodable
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 | undefinedclipFades
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): UsGuides: Timeline
computePlacement
Convert a clip's normalized transform to pixel-space placement.
function computePlacement(clip: Clip, settings: ProjectSettings): PlacementcomputeWaveformPeaks
Compute peaks for a whole asset. Resolves with all-zero peaks for silent media.
function computeWaveformPeaks(source: AudioTrackSource, options: WaveformPeaksOptions): Promise<WaveformPeaks>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): MonitoredAudioOutputcreatePixiBackend
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): PlayerGuides: 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): PreviewControllerGuides: 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): VideoSupportcreateWebAudioOutput
Create the WebAudio output (and its master clock source).
function createWebAudioOutput(context?: AudioContext): AudioOutputcreateWebCodecsDecoderFactory
A decoder factory with options — e.g. { maxOutputDimensionPx: 1920 } for proxy preview of 4K sources.
function createWebCodecsDecoderFactory(options?: WebCodecsDecoderOptions): FrameDecoderFactoryexportComposition
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): numbergetEffectRenderer
function getEffectRenderer(kind: string): EffectRendererFactory | undefinedGuides: Custom effects
getTransitionRenderer
function getTransitionRenderer(kind: string): TransitionRenderer | undefinedGuides: 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;
}): stringisPcmAudioChunk
function isPcmAudioChunk(value: unknown): value is PcmAudioChunkisRecoverableDecoderError
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): booleanisSoftwareWebGL
True when the canvas's WebGL context reports a software rasterizer.
function isSoftwareWebGL(canvas: HTMLCanvasElement | OffscreenCanvas): booleanisWebCodecsSupported
True when this environment can run the WebCodecs decode pipeline.
function isWebCodecsSupported(): booleanGuides: 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>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): Float32ArrayGuides: 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>): voidrasterizeHtml
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): voidGuides: 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): voidGuides: 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): voidGuides: HTML clips
substituteParams
{{name}} → params.name, HTML-escaped (params are data, never markup).
function substituteParams(template: string, params: Record<string, HtmlParamValue>): stringGuides: HTML clips
toMediaUs
Timeline position → position on the clip's source media.
function toMediaUs(clip: VideoClip, timelineUs: Us): UszIndexFor
function zIndexFor(trackIndex: number, clipIndexInTrack: number): numberClasses
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)| Member | Type | Description |
|---|---|---|
start | (timelineUs: Us, rate?: number) => void | Begin (or re-anchor after a seek/rate change) playback at timelineUs. |
pump | (timelineUs: Us) => void | Extend the scheduling window as the clock advances. Call periodically. |
stop | () => void | Stop 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)| Member | Type | Description |
|---|---|---|
nodeCount | number | Mounted scene nodes (with residency: only clips near the last render time). |
updateResidency | (timeUs: Us) => void | Mount 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) => void | Render the composition at a timeline position. Cheap when nothing changed. |
getClipBounds | (clipId: string, timeUs?: Us) => ClipBounds | null | Where 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 | null | The 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 | () => boolean | True when some node is still loading content for the last render. |
resync | () => void | Re-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)| Member | Type | Description |
|---|---|---|
frameCapacity | number | How 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) => void | Change the byte budget (the MediaManager splits one global budget across its live pipelines). Takes effect at the next evict. |
size | number | |
byteSize | number | |
byteBudget | number | |
put | (frame: VideoFrameHandle) => void | Store a frame. A frame already cached at the same timestamp is replaced (and closed). |
frameAt | (us: Us) => VideoFrameHandle | undefined | The 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) => void | Evict (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 | () => void | Close 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)| Member | Type | Description |
|---|---|---|
activeCount | number | |
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) => void | Mark 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) => void | holder 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 | () => void | Dispose pipelines nobody has held for idleReleaseMs. Cheap; the player calls it from its pump, and every acquire calls it first. |
reportMemoryPressure | () => void | The 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 | () => void | Restore the configured frame budget (e.g. after the app is foregrounded). |
stats | () => MediaManagerStats | |
release | (assetId: string) => void | Release 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)| Member | Type | Description |
|---|---|---|
playing | boolean | |
rate | number | |
timeUs | Us | |
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.
| Member | Type | Description |
|---|---|---|
timeUs | Us | |
playing | boolean | |
setTime | (us: Us) => void | |
step | (fps: FrameRateLike) => Us | Advance 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)| Member | Type | Description |
|---|---|---|
assetId | string | |
codec | string | 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)| Member | Type | Description |
|---|---|---|
isDisposed | boolean | True 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. |
cacheSize | number | |
cacheBytes | number | Bytes of decoded frames currently held. |
setCacheBudget | (bytes: number) => void | Resize 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: FrameDecoderFactoryZ_PER_TRACK
Clips per track are stacked by start time (later starts on top).
const Z_PER_TRACK: 10000Interfaces
ActiveEffect
| Member | Type | Description |
|---|---|---|
kind | string | |
filter | Filter | The Pixi filter applied to the clip's node (destroyed when removed). |
update | (params: Record<string, unknown>) => void | Apply 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.
| Member | Type | Description |
|---|---|---|
delayS | number | Animation start on the clip's animation clock (seconds) — CSS delay. |
durationS | number | One iteration (seconds). |
iterations | number | Iteration 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.
| Member | Type | Description |
|---|---|---|
schedule | (native: unknown, whenUs: Us, offsetUs: Us, durationUs: Us, rate: number) => void | Play 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; }[]) => void | Replace 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 | () => void | Stop everything scheduled on this channel. |
close | () => void |
AudioChunk
A decoded chunk of audio on the media's own timeline.
| Member | Type | Description |
|---|---|---|
timestampUs | Us | |
durationUs | Us | |
native | unknown | The underlying buffer (a Web Audio AudioBuffer in the browser). |
AudioEngineOptions
| Member | Type | Description |
|---|---|---|
aheadUs? | Us | How far ahead of the playhead audio is kept scheduled (default 3s). |
prerollUs? | Us | Open 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.
| Member | Type | Description |
|---|---|---|
clipId | string | |
assetId | string | |
src | unknown | |
fromMediaUs | Us | Media position to start pulling chunks from. |
gain | number | |
clip | VideoClip | AudioClip |
AudioOutput
Audio output abstraction (a WebAudio AudioContext in the browser; a fake in tests). Its clock is the master playback clock.
| Member | Type | Description |
|---|---|---|
currentTimeUs | Us | The 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.
| Member | Type | Description |
|---|---|---|
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.
| Member | Type | Description |
|---|---|---|
clipId | string | |
originXPx | number | The placement point (the clip's transform x/y, in pixels). Scale and rotation pivot here. |
originYPx | number | |
centerXPx | number | Center of the drawn content (equals the origin for centered content). |
centerYPx | number | |
widthPx | number | Content size after the clip's scale. |
heightPx | number | |
scale | number | The clip's (evaluated) scale and rotation. |
rotationDeg | number | |
corners | [Point, Point, Point, Point] | Content corners, clockwise from top-left (rotation applied). |
local | LocalBounds | The unscaled, unrotated content rectangle relative to the origin. |
z | number | Stacking 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.
| Member | Type | Description |
|---|---|---|
timeUs | Us | |
playing | boolean |
CompositorOptions
| Member | Type | Description |
|---|---|---|
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 | false | Mount 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
| Member | Type | Description |
|---|---|---|
aheadUs? | Us | Mount clips starting within this far ahead of the render time (default 2s). |
behindUs? | Us | Keep clips that ended within this far behind the render time (default 1s). |
ConstantSpan
The answer for one time.
| Member | Type | Description |
|---|---|---|
sinceS | number | The picture has been constant since this time (seconds, ≤ the time asked). Two times with the same sinceS render identically. |
CreateMediabunnySinkOptions
| Member | Type | Description |
|---|---|---|
canvas | HTMLCanvasElement | OffscreenCanvas | The canvas the compositor renders into — captured once per frame. |
format | ExportFormat | |
quality? | ExportQualityPreset | { videoBitrate: number; } | Preset, or an explicit video bitrate in bits/second. Default "standard". |
keyFrameIntervalSeconds? | number | Keyframe interval in seconds (default 2 — mediabunny's default). |
cpuCapture? | boolean | Route 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
| Member | Type | Description |
|---|---|---|
backend | SceneBackend | |
openDemuxer | DemuxerFactory | |
createDecoder | FrameDecoderFactory | |
audioOutput | AudioOutput | |
openAudio | AudioSourceFactory | |
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? | boolean | Loop back to 0 at the end of the composition (default false: pause). |
onError? | (error: Error, clipId: string) => void | Media failures per clip (unsupported codec, decode error, …). Default: console.error. |
raf? | (callback: () => void) => number | Frame scheduler — injectable for tests (default requestAnimationFrame). |
cancelRaf? | (handle: number) => void | |
schedule? | (callback: () => void, intervalMs: number) => number | Interval scheduler for the hidden-tab fallback — injectable for tests (default setInterval/clearInterval). See the pump interval below. |
cancelSchedule? | (handle: number) => void | |
memory? | PlayerMemoryOptions | Memory 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).
| Member | Type | Description |
|---|---|---|
compositionSize | () => { width: number; height: number; } | |
renderScale? | () => number | Physical 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
| Member | Type | Description |
|---|---|---|
kinds? | readonly string[] | Kinds to render (built-in or registered custom). Default: every catalog kind. |
size? | number | Square thumbnail size in px. Default 144. |
sample? | CanvasImageSource | Image to apply effects to (drawn cover-fit). Default: a built-in sample scene. |
lengthScale? | number | Magnifies 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) => void | Called as each thumbnail is ready — fill a grid progressively. |
signal? | AbortSignal |
EncodedChunkLike
An encoded chunk flowing from demuxer to decoder. Opaque beyond metadata.
| Member | Type | Description |
|---|---|---|
timestampUs | Us | |
isKey | boolean | |
native | unknown | The underlying chunk (an EncodedVideoChunk in the browser). |
ExportCompositionOptions
| Member | Type | Description |
|---|---|---|
startUs | Us | Composition range to export. endUs must be greater than startUs. |
endUs | Us | |
fps | number | FrameRate | Output 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). |
sink | ExportSink | |
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? | Us | Audio 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? | number | How 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
| Member | Type | Description |
|---|---|---|
phase | "audio" | "video" | "finalizing" | |
framesDone | number | |
totalFrames | number | |
audioMixedUs? | Us | During the audio phase: timeline µs mixed so far, out of the range total. |
audioTotalUs? | Us |
Guides: Browser, Web worker
ExportProjectInWorkerOptions
Extends ExportViaWorkerOptions.
| Member | Type | Description |
|---|---|---|
worker? | Worker | Use this Worker instead of spawning one (it will NOT be terminated). |
workerUrl? | string | URL | Spawn from this URL instead of the bundled entry (module worker). |
ExportProjectOptions
| Member | Type | Description |
|---|---|---|
format | ExportFormat | |
quality? | ExportQualityPreset | { videoBitrate: number; } | |
fps? | number | FrameRate | Output frame rate (default: the project's exact rate, projectFrameRate). A number or { num, den }. |
width? | number | Output size (default: the project's composition size). |
height? | number | |
range? | ExportRange | Composition range (default: 0 → end of the last clip). |
signal? | AbortSignal | |
onProgress? | (progress: ExportProgress) => void | |
openDemuxer? | DemuxerFactory | Media 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? | number | Audio 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
| Member | Type | Description |
|---|---|---|
startUs | Us | |
endUs | Us |
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.
| Member | Type | Description |
|---|---|---|
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; passworkerorworkerUrlwhere 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.
| Member | Type | Description |
|---|---|---|
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
| Member | Type | Description |
|---|---|---|
maxFrames? | number | Maximum number of frames held (default 60). |
maxBytes? | number | Maximum total bytes held (default 512 MiB — decoded frames are large). |
maxBehindFrames? | number | Frames 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? | number | Cap 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).
| Member | Type | Description |
|---|---|---|
configure | (config: unknown) => void | |
decode | (chunk: EncodedChunkLike) => void | |
flush | () => Promise<void> | Resolves once all pending frames have been emitted via onFrame. |
reset | () => void | Abort 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
| Member | Type | Description |
|---|---|---|
constantSpanAt | (timeS: number) => ConstantSpan | undefined | The 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).
| Member | Type | Description |
|---|---|---|
minDensity? | number | Lowest 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? | number | Cap animated html re-rasters per clip (preview only; export waits for every frame). |
budget? | number | Share 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? | boolean | Keep 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.
| Member | Type | Description |
|---|---|---|
xPx | number | |
yPx | number | |
widthPx | number | |
heightPx | number |
MediabunnyDemuxerOptions
| Member | Type | Description |
|---|---|---|
maxCacheBytes? | number | Read 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
| Member | Type | Description |
|---|---|---|
openDemuxer | DemuxerFactory | |
createDecoder | FrameDecoderFactory | |
maxActivePipelines? | number | Maximum simultaneously active pipelines (≈ hardware decoder instances). Browsers cap these — some Safari configurations allow very few. Default 4. |
evictionIdleMs? | number | A 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? | number | Decoded-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? | number | Floor for one pipeline's share of the frame budget (default 24 MiB). |
idleReleaseMs? | number | A 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? | () => number | Clock for idle bookkeeping (tests). Defaults to Date.now. |
MediaManagerStats
| Member | Type | Description |
|---|---|---|
pipelines | number | |
idlePipelines | number | Pipelines nothing currently holds (waiting out idleReleaseMs). |
cachedFrames | number | |
cachedBytes | number | |
frameBudgetBytes | number |
MixCompositionAudioOptions
Extends MixAudioContext.
| Member | Type | Description |
|---|---|---|
doc | ProjectDocument | |
range | ExportRange | |
openAudio | AudioSourceFactory | |
sampleRate? | number | |
channels? | number | |
silenceIfEmpty? | boolean | Return 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.
| Member | Type | Description |
|---|---|---|
output | AudioOutput | |
setGain | (gain: number) => void | Set the monitoring gain (0..1); implementations should ramp to avoid clicks. |
NodeFactoryContext
| Member | Type | Description |
|---|---|---|
backend | SceneBackend | |
assets | Readonly<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).
| Member | Type | Description |
|---|---|---|
sampleRate | number | |
numberOfFrames | number | |
numberOfChannels | number | |
planes | Float32Array[] | One Float32Array per channel, numberOfFrames samples each. |
PixiBackendOptions
| Member | Type | Description |
|---|---|---|
canvas | HTMLCanvasElement | |
width | number | |
height | number | |
background? | number | |
preserveDrawingBuffer? | boolean | Keep 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? | HtmlRasterOptions | Html clip raster limits for live preview — see HtmlRasterOptions. |
Placement
Pixel-space placement computed from a clip's normalized transform.
| Member | Type | Description |
|---|---|---|
xPx | number | |
yPx | number | |
scale | number | |
rotationRad | number | |
opacity | number |
Player
| Member | Type | Description |
|---|---|---|
on | (type: K, listener: (event: PlayerEvents[K]) => void) => () => void | Subscribe to a transport event. Returns an unsubscribe function. |
play | () => void | |
pause | () => void | |
seek | (timeUs: Us) => void | |
setRate | (rate: number) => void | |
playing | boolean | |
rate | number | Current playback speed (1 = normal). |
timeUs | Us | |
durationUs | Us | End of the last clip — the composition's duration. |
getClipBounds | (clipId: string, timeUs?: Us) => ClipBounds | null | Where 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 | null | Topmost clip drawn at a composition-pixel point now (or at timeUs) — see Compositor.hitTest. |
stats | () => PlayerStats | Live resource counts (see PlayerStats). |
reportMemoryPressure | () => void | Tell 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>.
| Member | Type | Description |
|---|---|---|
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
| Member | Type | Description |
|---|---|---|
frameBudgetBytes? | number | Decoded-frame memory shared by all video pipelines (default 256 MiB). |
maxActivePipelines? | number | Simultaneous decoder pipelines before least-recently-used release (default 4). |
idleReleaseMs? | number | Dispose a pipeline no clip has needed for this long (default 1500ms). |
residency? | CompositorResidencyOptions | false | Mount scene nodes only near the playhead (default on) — see CompositorOptions.residency. |
PlayerStats
Live resource counts — for dev overlays, tests and memory dashboards.
| Member | Type | Description |
|---|---|---|
pipelines | number | Decoder pipelines open (including idle ones about to be released). |
idlePipelines | number | |
cachedFrames | number | Decoded frames held across all pipelines, and their bytes. |
cachedBytes | number | |
frameBudgetBytes | number | |
mountedNodes | number | Scene nodes mounted (clips near the playhead) / clips in the document. |
clips | number |
Point
| Member | Type | Description |
|---|---|---|
xPx | number | |
yPx | number |
PreviewController
| Member | Type | Description |
|---|---|---|
getState | () => PreviewState | |
subscribe | (listener: () => void) => () => void | Called after every state change. Returns an unsubscribe function. |
play | () => void | |
pause | () => void | |
toggle | () => void | |
seek | (timeUs: Us) => void | Seek, clamped to [0, composition end]. |
attach | (canvas: HTMLCanvasElement, size: { width: number; height: number; }) => void | Build the live renderer on canvas. size is its CSS size; safe to call again on the same canvas. |
resize | (width: number, height: number) => void | The canvas's CSS size changed (zoom, layout). |
detach | () => void | Tear the live renderer down; the preview falls back to the clock-only mode. |
setVolume | (volume: number) => void | |
toggleMute | () => void | |
getClipBounds | (clipId: string) => ClipBounds | null | Where 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 | null | Topmost clip at a composition-px point (null when not live or nothing there). |
player | Player | null | The live player, when there is one. |
destroy | () => void | Detach and drop every listener. The controller can't be used afterwards. |
PreviewControllerOptions
| Member | Type | Description |
|---|---|---|
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? | number | Longest decoded frame side, in px (default 1920). Decoding never exceeds the preview's own size. |
maxPixelRatio? | number | devicePixelRatio cap for the render target (default 2). |
initialVolume? | number | Starting 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) => void | Called once per live player, e.g. to expose stats to a dev overlay. |
onError? | (error: Error, clipId: string) => void | Media 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.
| Member | Type | Description |
|---|---|---|
playing | boolean | |
mode | PreviewMode | |
lastError | string | null | Last renderer or media error, as text (null after a successful attach). |
volume | number | Preview monitoring volume, 0..1. Never written to the project or exports. |
muted | boolean |
RasterizeHtmlOptions
| Member | Type | Description |
|---|---|---|
template | string | |
params | Record<string, HtmlParamValue> | |
widthPx | number | |
heightPx | number | |
assets? | Readonly<Record<string, Asset>> | Project assets: font assets inline as |
density? | number | Physical 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? | number | Animated 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? | number | Animated templates: the animation's total length in seconds (--T). |
target? | HTMLCanvasElement | Draw 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? | HTMLImageElement | Load 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
| Member | Type | Description |
|---|---|---|
timeUs | Us | Composition time to render, in microseconds. |
width? | number | Output size (default: the project's composition size). |
height? | number | |
type? | "image/png" | "image/jpeg" | "image/webp" | Image encoding (default PNG). |
quality? | number | For lossy types: 0..1. |
openDemuxer? | DemuxerFactory | Media 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.
| Member | Type | Description |
|---|---|---|
resize | (widthPx: number, heightPx: number) => void | Set the COMPOSITION size — the coordinate space clips are placed in. |
setOutputSize? | (widthPx: number, heightPx: number) => void | Render 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) => SceneNode | Karaoke caption block (word wrap + active-word emphasis). Optional per backend. |
createSolid? | () => SolidSceneNode | Full-composition solid overlay (dip transitions). Optional per backend. |
createHtml? | (clip: HtmlClip, assets: Readonly<Record<string, Asset>>) => SceneNode | HTML clip: template rasterized to a texture. Optional per backend. |
render | () => void | |
destroy | () => void |
SceneNode
| Member | Type | Description |
|---|---|---|
setPlacement | (placement: Placement) => void | Apply placement. The object may be REUSED by the caller — copy it if kept. |
setVisible | (visible: boolean) => void | |
setZ | (z: number) => void | Stacking order: higher renders on top. |
update | (clip: Clip) => void | Re-apply content-affecting clip properties (text, color, asset swap…). |
setEffects? | (effects: readonly EffectInstance[]) => void | Apply the clip's effect stack (enabled entries, array order). Optional per backend. |
setReveal? | (fraction: number, direction: RevealDirection) => void | Wipe 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) => void | Called 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? | () => boolean | True 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 | null | The 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.
| Member | Type | Description |
|---|---|---|
set | (colorRgb: number, alpha: number) => void | Cover 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.
| Member | Type | Description |
|---|---|---|
opacity? | number | Multiplied 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? | number | Offset 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
| Member | Type | Description |
|---|---|---|
compositionSize | { width: number; height: number; } | The composition size in its own coordinate space. |
TransitionRenderer
| Member | Type | Description |
|---|---|---|
rendersBothClips | boolean | Whether 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 | null | The 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.
| Member | Type | Description |
|---|---|---|
timestampUs | Us | Presentation timestamp on the media's own timeline. |
durationUs | Us | |
byteLength | number | Approximate memory footprint, for cache budgeting. |
native | unknown | The underlying frame (a VideoFrame in the browser). |
poolBacked? | boolean | The 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
| Member | Type | Description |
|---|---|---|
aheadUs? | Us | How far past the target to keep decoding ahead (default 1s). |
reseekGapUs? | Us | A 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? | () => void | Called 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.
| Member | Type | Description |
|---|---|---|
setFrame | (frame: unknown | null) => void | Show a decoded frame (a VideoFrame in the browser), or clear with null. |
setSourceSize? | (widthPx: number, heightPx: number) => void | The 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
| Member | Type | Description |
|---|---|---|
factory | NodeFactory | Register 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 | () => void | Release pipelines no clip near the playhead needs (see MediaManager.sweep). |
dispose | () => void |
VideoSupportOptions
| Member | Type | Description |
|---|---|---|
lookaheadUs? | Us | Prepare clips that start within this window ahead of the playhead (default 1s). |
onError? | (error: Error, clipId: string) => void | Called when a clip's media fails (unsupported codec, decode error, …). Silent-by-default was a mistake we made once — the default logs loudly. |
laneGapUs? | Us | Clips 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.
| Member | Type | Description |
|---|---|---|
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
| Member | Type | Description |
|---|---|---|
codec | string | |
width | number | |
height | number | |
durationUs | Us | |
fps | number | 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).
| Member | Type | Description |
|---|---|---|
peaks | Float32Array | Max |sample| per bucket, 0..1. |
bucketUs | Us | Media time each bucket covers. |
durationUs | Us | Media time the peaks span (peaks.length * bucketUs). |
WaveformPeaksOptions
| Member | Type | Description |
|---|---|---|
durationUs | Us | Media duration (asset durationUs). |
bucketsPerSecond? | number | Resolution. Default 50 buckets/second — enough for any zoom a timeline uses. |
maxBuckets? | number | Cap on bucket count for very long media. Default 20 000. |
signal? | AbortSignal |
WebCodecsDecoderOptions
| Member | Type | Description |
|---|---|---|
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.
| Member | Type | Description |
|---|---|---|
format | ExportFormat | |
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 asfallback.
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;
};@miraiclip/core
Headless, command-driven engine for building video editors — state, commands, history, patches, events.
@miraiclip/editor
Headless editor logic for Miraiclip: timeline scale, clip drag and trim with snapping, selectors, typed commands, gestures, shortcuts, media import. No UI framework.