Typed 2D motion composition core for browser motion graphics.
@willyrg/motionkit gives application code one model for layers, transforms, animation, effects, masks, media sync, live editing, serialization, and export. It coordinates three runtime libraries without making every app rebuild the glue:
- Scrawl-canvas draws and owns canvas artefacts, Cells, Groups, styles, filters, and render cycles.
- GSAP can own timeline interpolation, easing, and choreography.
- Mediabunny can own media metadata, decoded frames/audio, and video export plumbing.
The package has one public import path:
import { createComposition } from '@willyrg/motionkit';Do not import from internal folders. The source tree is allowed to change; @willyrg/motionkit is the public API.
- When To Use It
- Install
- Quick Start
- Core Model
- Reconfiguration
- Layers
- Playback And Synchronization
- Animation And Expressions
- Effects
- Masks
- Precomposition
- Media
- Live Editing
- Export
- Serialization
- Browser Adapter
- Internal Architecture
- API Reference
- Troubleshooting
Use @willyrg/motionkit when you need a typed composition model for browser-based motion work:
- Build a timeline of image, video, text, shape, SVG, audio, particle, or precomp layers.
- Animate layer transforms and stable numeric state.
- Apply Scrawl filter effects without leaking Scrawl details through app code.
- Coordinate timeline time, media time, Scrawl state, precompositions, and export.
- Drive live UI controls into the same state model used by playback and export.
Do not use it as a general scene graph or DOM animation library. The design assumes a canvas renderer and frame-based composition workflow.
For package consumers:
bun add @willyrg/motionkit gsap mediabunny scrawl-canvasFor this repository:
bun install
bun test
bun run typecheck
bun run buildRuntime libraries are peer dependencies:
{
"gsap": "^3.15.0",
"mediabunny": "^1.44.0",
"scrawl-canvas": "^8.19.0"
}<canvas id="preview-canvas"></canvas>import {
createAnimationController,
createComposition,
loadBrowserScrawlAdapter,
} from '@willyrg/motionkit';
const adapters = await loadBrowserScrawlAdapter({
canvas: 'preview-canvas',
namespace: 'demo',
fit: 'contain',
backgroundColor: '#101114',
});
const composition = createComposition(
{
name: 'main',
width: 1920,
height: 1080,
duration: 6,
frameRate: 60,
backgroundColor: '#101114',
},
adapters,
);
const box = composition.addLayer('shape', {
name: 'box',
shape: {
kind: 'rectangle',
width: 320,
height: 180,
fillStyle: '#ffcc00',
},
transform: {
position: { x: 240, y: 540 },
anchor: { x: 160, y: 90 },
},
});
const animation = createAnimationController(composition);
animation.addKeyframe(box, 'position.x', 0, 240);
animation.addKeyframe(box, 'position.x', 2, 960, { easing: 'power2.inOut' });
composition.seek(0);
composition.play();What happens:
createCompositioncreates the typed model and runtime state.addLayercreates a typed layer and a backing Scrawl entity through the adapter.createAnimationControllerregisters a motion target with the composition.seekmoves the timeline, applies motion state, syncs layer state to Scrawl, and renders one frame.playstarts media and the renderer-backed playback loop.
A composition is the source of truth.
const composition = createComposition({
width: 1280,
height: 720,
duration: 10,
frameRate: 30,
backgroundColor: 'transparent',
name: 'program',
});The core objects are:
- Composition: owns dimensions, duration, timeline, renderer, assets, layers, and frame sync.
- Layer: owns typed layer state, transform, visibility, opacity, mask, effects, media target, and Scrawl backing entity.
- Motion target: any object with numeric
valuesand anapply()method. - Adapter: runtime integration that creates Scrawl entities, timelines, renderers, Cells, effects, and styles.
Design rule: app code writes into @willyrg/motionkit state first. The engine then maps that state into Scrawl, media, and export.
Use configure() to change composition-owned settings after creation.
composition.configure({
width: 800,
height: 240,
duration: 8,
frameRate: 60,
});configure() is intentionally one transaction instead of shallow setters. It validates the full next config before mutating and applies only values that actually changed.
Composition-owned updates:
widthheightdurationframeRatebackgroundColorname
The core updates composition-owned runtime state with the same transaction: timeline duration, renderer configuration, and composition-owned mask Cells. If the current timeline time is beyond a new shorter duration, the composition clamps to the new end time.
Existing synchronizers use the current composition frameRate after reconfiguration unless they were created with an explicit frameRate override.
Precomposition ownership is explicit. If a child composition is mounted inside a parent precomp layer, calling child.configure(...) changes the child only. It does not resize or rebuild the parent-owned Cell. Parent container changes should be done through the parent/precomp API because the parent may want to crop, preserve bounds, scale, or animate the container independently.
Do not mutate config fields directly:
composition.width = 800; // TypeScript rejects this; use configure().Use addLayer for all content.
composition.addLayer('shape', { name: 'background' });
composition.addLayer('text', { text: 'Caption', name: 'caption' });
composition.addLayer('image', '/assets/plate.png', { name: 'plate' });
composition.addLayer('video', '/assets/clip.mp4', { name: 'clip' });
composition.addLayer('audio', '/assets/voice.wav', { name: 'voice' });
composition.addLayer('svg', '/assets/logo.svg', { name: 'logo' });
composition.addLayer('particle', { name: 'sparks', variant: 'emitter' });Layer transforms are typed and stable:
const layer = composition.addLayer('image', '/assets/plate.png', {
name: 'plate',
transform: {
position: { x: 640, y: 360 },
rotation: 15,
scale: { x: 1.2, y: 1.2 },
anchor: { x: 0.5, y: 0.5 },
},
opacity: 0.85,
visible: true,
});Child layers pivot/mimic against their parent Scrawl entity.
const parent = composition.addLayer('shape', { name: 'card' });
const child = composition.addLayer('text', {
name: 'label',
text: 'Hello',
parent,
transform: {
position: { x: 24, y: 36 },
},
});Removing a parent removes its children.
Simple shape:
composition.addLayer('shape', {
name: 'circle',
shape: {
kind: 'wheel',
radius: 120,
fillStyle: '#33ccff',
strokeStyle: '#ffffff',
lineWidth: 4,
},
});Typed fill/stroke animation requires Scrawl entity parts. The browser adapter creates them for supported shape configs.
const shape = composition.addLayer('shape', {
name: 'badge',
shape: {
kind: 'wheel',
radius: 90,
fill: { color: '#f97316', opacity: 1 },
stroke: { color: '#ffffff', opacity: 1, width: 6 },
},
});
shape.shape?.fill.values.opacity = 0.5;
composition.applyMotionTargets();Basic label:
composition.addLayer('text', {
name: 'title',
text: 'Motion',
});Enhanced text exposes numeric motion values:
const text = composition.addLayer('text', {
name: 'body',
text: 'Animated layout text',
textMode: 'enhanced',
enhancedText: {
fontString: '48px sans-serif',
lineWidth: 2,
pathPosition: 0,
lineSpacing: 1.2,
},
});
text.textState!.values.pathPosition = 0.4;
composition.applyMotionTargets();Basic controls:
composition.play();
composition.pause();
composition.seek(2.5);composition.syncFrame(time) is the single frame-state path:
- seeks the timeline,
- syncs precomposition time,
- maps layer transforms into Scrawl state,
- applies registered motion targets.
It does not render a frame and does not seek media. That separation keeps preview, export, and UI editing from inventing separate frame logic.
Use TimelineSynchronizer when you need a complete frame:
import { createTimelineSynchronizer } from '@willyrg/motionkit';
const sync = createTimelineSynchronizer(composition, {
frameRate: 30,
onDesync: ({ target, timelineTime, mediaTime }) => {
console.warn(target.name, timelineTime, mediaTime);
},
});
await sync.seek(1.5);The synchronizer:
- runs
composition.syncFrame, - seeks explicit media targets and layer-owned media targets once,
- runs pre-render hooks,
- renders the frame.
Add hooks for per-frame work such as expressions:
const sync = createTimelineSynchronizer(composition, {
hooks: [createExpressionRenderHook(animation)],
});Create one animation controller per composition when you need keyframes, tweens, or expressions.
const animation = createAnimationController(composition);animation.addKeyframe(layer, 'position.x', 0, 100);
animation.addKeyframe(layer, 'position.x', 2, 900, {
easing: 'power2.inOut',
});
composition.seek(1);Editable keyframes:
animation.editKeyframe(layer, 'position.x', 2, 760);
animation.removeKeyframe(layer, keyframe);editKeyframe is the preferred UI path: it creates a keyframe when missing and replaces the existing keyframe at the exact same time.
Supported layer motion properties:
type AnimatableProperty =
| 'position.x'
| 'position.y'
| 'rotation'
| 'scale.x'
| 'scale.y'
| 'anchor.x'
| 'anchor.y'
| 'opacity';With a GSAP timeline adapter:
import { createGsapTimelineFactory } from '@willyrg/motionkit';
import { gsap } from 'gsap';
adapters.createTimeline = createGsapTimelineFactory(gsap);
animation.animate(layer, {
'position.x': 900,
opacity: 0.4,
}, {
duration: 1.2,
easing: 'power2.out',
});Any object with numeric values and apply() can be animated.
const blurEffect = composition.addEffect(layer, blur({ id: 'soft', radius: 0 }));
animation.animateTarget(blurEffect, {
radius: 16,
}, {
duration: 0.6,
});Expressions are evaluator functions. The engine does not compile strings.
animation.setExpression(
layer,
'position.x',
({ value, time, frame, layer }, { clamp }) =>
clamp(value + time * 20 + frame * 0.1 + layer.transform.position.y, 0, 1000),
);
const result = animation.applyExpressions(1);Use a render hook to apply expressions every synchronized frame:
const hook = createExpressionRenderHook(animation);
const sync = createTimelineSynchronizer(composition, { hooks: [hook] });
await sync.seek(2);Audio-reactive expressions:
const hook = createExpressionRenderHook(animation, () => ({
amplitude: 0.5,
bands: { bass: 0.8, mid: 0.2, treble: 0.1 },
}));
animation.setExpression(
layer,
'opacity',
({ audio }) => (audio?.amplitude ?? 0) * (audio?.bands.bass ?? 0),
);Helpers available to expression evaluators:
clamp(value, min, max)lerp(start, end, amount)random(min?, max?, seed?)wiggle(frequency, amplitude, seed?)
If evaluation fails, the controller keeps the last valid value and returns an EngineError in ExpressionApplyResult.errors.
Effects are Scrawl filter configs using the modern actions format.
import {
blur,
brightness,
pixelate,
threshold,
tint,
} from '@willyrg/motionkit';
const image = composition.addLayer('image', '/assets/plate.png', {
name: 'plate',
effects: [
blur({ id: 'soften', radius: 3 }),
brightness({ id: 'lift', level: 1.1, opacity: 0.8 }),
],
});
composition.addEffect(image, threshold({
id: 'matte-threshold',
level: 120,
high: [255, 255, 255, 255],
low: [0, 0, 0, 0],
}));
composition.addEffect(image, tint({
id: 'cool-tint',
blueInBlue: 1,
greenInBlue: 0.25,
}));
composition.addEffect(image, pixelate({
id: 'blocks',
tileWidth: 12,
tileHeight: 12,
}));Preset helpers:
blurthresholdpixelatetintbrightnesssaturationchannelsgrayscaleinvert
Advanced Scrawl filter actions:
composition.addEffect(image, {
id: 'outline-composite',
actions: [
{ action: 'gaussian-blur', radius: 2, lineOut: 'blurred' },
{
action: 'threshold',
lineIn: 'blurred',
level: 8,
high: [0, 0, 0, 255],
low: [0, 0, 0, 0],
lineOut: 'edge',
},
{
action: 'compose',
compose: 'source-over',
lineIn: 'source',
lineMix: 'edge',
},
],
});Effect numeric action values are collected into effect.values, which makes effects stable motion targets.
const effect = composition.addEffect(image, blur({ id: 'animated-blur', radius: 0 }));
effect.values.radius = 12;
composition.applyMotionTargets();There are two mask models.
Applies directly to a Scrawl entity.
composition.setMask(layer, {
mode: 'destination-in',
opacity: 0.9,
feather: 4,
memoize: true,
});Uses a Scrawl Cell when the runtime supports Cells.
const target = composition.addLayer('image', '/assets/subject.png', {
name: 'subject',
});
const matte = composition.addLayer('shape', {
name: 'subject-matte',
shape: {
kind: 'wheel',
radius: 180,
fillStyle: '#ffffff',
},
});
composition.setLayerMask(target, matte, {
mode: 'clip',
feather: 2,
});Layer masks default to strategy: 'cell'. Target and matte move into the Cell group. The target stamps first, the matte stamps after it, and clip maps to destination-in style compositing in the Cell.
Clear masks:
composition.clearMask(target);A precomposition is a child composition rendered into a Scrawl Cell and used as a parent layer source.
const child = createComposition({
name: 'lower-third',
width: 640,
height: 180,
duration: 4,
frameRate: 30,
});
child.addLayer('text', {
text: 'Live',
name: 'label',
});
const parent = createComposition({
name: 'program',
width: 1920,
height: 1080,
duration: 10,
frameRate: 30,
}, adapters);
const precompLayer = parent.addPrecomposition(child, {
name: 'lower-third-layer',
timeOffset: 1,
playbackRate: 1.5,
transform: {
position: { x: 120, y: 820 },
},
});
parent.seek(3);Child time is:
const childTime = (parentTime - timeOffset) * playbackRate;The engine clamps child time to the child duration.
Important constraints:
- Circular precomposition references throw.
- A child composition can be mounted into one precomposition owner at a time.
- Removing the precomp layer tears down the Cell and detaches the child composition from that host group.
Media timing uses normalized config:
composition.addLayer('video', '/assets/clip.mp4', {
name: 'clip',
video: {
inPoint: 1.2,
outPoint: 8,
playbackRate: 1,
},
});
composition.addLayer('audio', '/assets/voice.wav', {
name: 'voice',
audio: {
inPoint: 0,
outPoint: 12,
playbackRate: 1,
volume: 0.8,
fadeIn: 0.2,
fadeOut: 0.5,
},
});Composition time maps to source time:
const sourceTime = inPoint + compositionTime * playbackRate;The browser adapter can create a Mediabunny-backed decoded-frame bridge. It decodes frames into one reusable Scrawl RawAsset.
const video = composition.addLayer('video', '/assets/clip.mp4', {
name: 'clip',
});
await adapters.createVideoFrameBridge(video, '/assets/clip.mp4');
await createTimelineSynchronizer(composition).seek(1.25);const analyzer = createAudioAnalyzer(audioContext, audioNode, {
fftSize: 2048,
smoothingTimeConstant: 0.8,
});
const frame = createEmptyAudioAnalysisFrame();
analyzer.analyzeInto(frame);Frequency bands are normalized to 0..1:
const bands = analyzeFrequencyBands(frequencyData, {
sampleRate: 48_000,
fftSize: 2048,
});Attach a decoded audio bridge to an audio layer:
const bridge = attachAudioBridgeToLayer(audioLayer, audioBridge);
await bridge.seek(2);Live edit sessions write UI input into the same state model used by animation and export.
const editor = createLiveEditSession(composition);
editor.bindLayerInput(xInput, layer, 'position.x', {
parse: 'float',
});Edit generic motion target values:
const effect = composition.addEffect(layer, blur({ id: 'blur', radius: 0 }));
editor.bindInput(radiusInput, effect.values, 'radius', {
parse: 'float',
});Auto-key layer properties:
const animation = createAnimationController(composition);
editor.setLayerProperty(layer, 'position.x', 400, {
mode: 'autoKey',
animation,
time: composition.timeline.time(),
});Auto-key generic values:
editor.setValue(effect.values, 'radius', 12, {
mode: 'autoKey',
time: composition.timeline.time(),
});Dispose sessions when UI is removed:
editor.dispose();const blob = await exportFrame(composition, 2, {
format: 'png',
outputType: 'blob',
});
const buffer = await exportFrame(composition, 2, {
format: 'webp',
quality: 0.8,
outputType: 'arraybuffer',
});
const dataUrl = await exportFrame(composition, 2, {
format: 'jpg',
quality: 0.85,
outputType: 'dataurl',
});Frame export requires a renderer with captureFrame.
const frames = await exportFrameSequence(composition, {
startTime: 0,
endTime: 2,
frameRate: 30,
frameStep: 1,
format: 'png',
filenamePrefix: 'shot-',
filenamePadding: 4,
onProgress: (progress) => console.log(progress),
});const videoBlob = await exportVideo(composition, {
format: 'mp4',
quality: 'high',
frameRate: 30,
});The built-in video export adapter uses Mediabunny and requires a renderer with getFrameCanvas.
const json = composition.serialize();
const restored = deserializeComposition(json, adapters);Serialization includes:
- composition metadata,
- timeline time and duration,
- layers and layer config,
- transforms,
- effects,
- masks,
- asset metadata,
- Scrawl packets when available.
Serialization does not include:
- live
Blobobjects, - decoded frames,
- WebCodecs handles,
- Web Audio nodes,
- Scrawl runtime instances,
- dispose callbacks.
Host integrations recreate those runtime resources after load.
Create an adapter from an existing canvas:
const adapters = await loadBrowserScrawlAdapter({
canvas: 'preview-canvas',
namespace: 'demo',
fit: 'contain',
backgroundColor: '#101114',
title: 'Preview',
label: 'Motion preview',
description: 'Canvas preview for the motion composition',
});Options:
canvas:HTMLCanvasElement | stringnamespace: name prefix for Scrawl objectsfit:'none' | 'contain' | 'cover' | 'fill'backgroundColor: Scrawl canvas backgroundtitle,label,description: canvas accessibility metadata
The browser adapter provides:
- Scrawl entity factories,
- Scrawl group factory,
- renderer backed by Scrawl
makeRender, - precomposition and mask Cell factories,
- effects controller,
- styles controller,
- video frame bridge,
- Scrawl packet import,
dispose().
Custom renderers can react to composition reconfiguration through the renderer contract:
interface RenderAdapter {
play(): void;
pause(): void;
renderFrame(): void | Promise<void>;
configure?(composition: CompositionRuntime, changes: Readonly<CompositionUpdateConfig>): void;
captureFrame?(options: Readonly<FrameCaptureOptions>): Promise<Blob>;
getFrameCanvas?(): HTMLCanvasElement | OffscreenCanvas;
}The mental map is intentionally small:
app code
-> motionkit public API
-> Composition state
-> adapters
-> Scrawl / GSAP / Mediabunny
Composition owns the source of truth. Scrawl entities, media objects, and export encoders are runtime projections of that state.
seek/play/export
-> timeline time
-> layer and motion target state
-> Scrawl state
-> media sync
-> render/capture
src/core: composition lifecycle, layers, assets, masks, precomp ownership, motion target registry.src/animation: keyframes, tweens, expression evaluator functions.src/editor: UI/live-edit bindings that write into core state.src/integration: Scrawl, GSAP, Mediabunny, serialization, synchronization.src/audio: audio analysis and audio media bridge.src/export: frame, sequence, and video export.src/shared: public contracts, validation, errors, Scrawl types, effect presets.src/public: curated package exports.
- One import path keeps package usage stable.
- One layer creation path avoids duplicate layer state.
- One frame-state path prevents preview/export/designer drift.
- Motion targets make effects, text, shape parts, and custom state animatable without special APIs.
- Expression evaluators are functions, not strings, because compiling strings would make untrusted input part of the runtime model.
- Precomposition ownership is exclusive so nested render targets have one obvious host.
This section documents the public API exported from @willyrg/motionkit.
function createComposition(
config: CompositionConfig,
adapters?: EngineAdapters,
): Composition;
function deserializeComposition(
json: string,
adapters?: EngineAdapters,
): Composition;interface CompositionConfig {
width: number;
height: number;
duration?: number;
frameRate?: number;
backgroundColor?: string;
name?: string;
}type CompositionUpdateConfig = Partial<CompositionConfig>;interface Composition {
readonly id: string;
readonly name: string;
readonly width: number;
readonly height: number;
readonly duration: number;
readonly frameRate: number;
readonly backgroundColor: string;
readonly layers: Layer[];
readonly assets: CompositionAsset[];
readonly timeline: TimelineAdapter;
readonly renderer: RenderAdapter;
configure(config: CompositionUpdateConfig): void;
addLayer(type: LayerType, config?: LayerConfig): Layer;
addLayer(type: LayerType, source?: string, config?: LayerConfig): Layer;
addPrecomposition(
composition: Composition,
config?: Omit<LayerConfig, 'content' | 'precomp'> & {
readonly timeOffset?: number;
readonly playbackRate?: number;
},
): Layer;
addEffect(layer: Layer, config: EffectConfig): LayerEffectState;
removeEffect(layer: Layer, effect: LayerEffectState | string): void;
clearEffects(layer: Layer): void;
createGradient(config: GradientConfig): MotionStyle;
createPattern(config: PatternConfig): MotionStyle;
removeStyle(style: MotionStyle): void;
registerAsset(asset: CompositionAsset): CompositionAsset;
removeAsset(asset: CompositionAsset | string): void;
registerMotionTarget(target: MotionStateTarget): () => void;
applyMotionTargets(): void;
syncFrame(time?: number, suppressEvents?: boolean): void;
setMask(layer: Layer, config: LayerMaskConfig): LayerMaskState;
setLayerMask(targetLayer: Layer, sourceLayer: Layer, config?: Omit<LayerMaskConfig, 'sourceLayerId'>): LayerMaskState;
clearMask(layer: Layer): void;
removeLayer(layer: Layer): void;
reorderLayer(layer: Layer, newIndex: number): void;
play(): void;
pause(): void;
seek(time: number): void;
serialize(): string;
}type LayerType =
| 'image'
| 'video'
| 'audio'
| 'svg'
| 'shape'
| 'text'
| 'particle'
| 'precomp';interface LayerConfig {
name?: string;
transform?: Partial<Transform>;
opacity?: number;
visible?: boolean;
locked?: boolean;
parent?: Layer;
content?: unknown;
scaleMode?: 'fill' | 'fit' | 'none';
shape?: ShapeLayerConfig;
video?: VideoLayerConfig;
audio?: AudioLayerConfig;
scrawl?: Readonly<Record<string, unknown>>;
textMode?: 'label' | 'enhanced';
text?: string;
enhancedText?: EnhancedTextLayerConfig;
variant?: 'emitter' | 'net' | 'tracer';
effects?: readonly EffectConfig[];
mask?: LayerMaskConfig;
precomp?: PrecompositionLayerConfig;
}interface Transform {
position: { x: number; y: number };
rotation: number;
scale: { x: number; y: number };
anchor: { x: number; y: number };
rotationX?: number;
rotationY?: number;
rotationZ?: number;
}function createAnimationController(composition: Composition): AnimationController;class AnimationController {
addKeyframe(layer: Layer, property: AnimatableProperty, time: number, value: number, config?: KeyframeConfig): Keyframe;
editKeyframe(layer: Layer, property: AnimatableProperty, time: number, value: number, config?: KeyframeConfig): Keyframe;
findKeyframe(layer: Layer, property: AnimatableProperty, time: number): Keyframe | undefined;
removeKeyframe(layer: Layer, keyframe: Keyframe): void;
animate(layer: Layer, values: AnimationValues, config: AnimationConfig): Animation;
animateTarget<TValues extends Record<string, number>>(
target: MotionStateTarget<TValues>,
values: Partial<TValues>,
config: AnimationConfig,
): Animation;
setExpression(layer: Layer, property: AnimatableProperty, evaluator: ExpressionEvaluator): Expression;
removeExpression(layer: Layer, property: AnimatableProperty): void;
applyExpressions(time?: number, audio?: ExpressionAudioContext): ExpressionApplyResult;
getExpressionErrors(): readonly EngineError[];
}type ExpressionEvaluator = (
context: ExpressionContext,
helpers: ExpressionHelpers,
) => unknown;interface AnimationConfig {
duration: number;
delay?: number;
easing?: string | ((progress: number) => number);
repeat?: number;
yoyo?: boolean;
onComplete?: () => void;
}function createTimelineSynchronizer(
composition: Composition,
config?: TimelineSynchronizerConfig,
): TimelineSynchronizer;interface TimelineSynchronizerConfig {
frameRate?: number;
hooks?: PreRenderHook[];
onDesync?: (details: {
target: MediaSyncTarget;
timelineTime: number;
mediaTime: number;
}) => void;
}interface EffectConfig {
id?: string;
actions: EffectAction[];
opacity?: number;
}interface LayerMaskConfig extends MaskConfig {
sourceLayerId?: string;
strategy?: 'entity' | 'cell';
}Preset functions:
blur(config?: BlurEffectConfig): EffectConfig;
threshold(config: ThresholdEffectConfig): EffectConfig;
pixelate(config: PixelateEffectConfig): EffectConfig;
tint(config?: TintEffectConfig): EffectConfig;
brightness(config: UniformChannelModulationEffectConfig): EffectConfig;
saturation(config: UniformChannelModulationEffectConfig): EffectConfig;
channels(config?: ChannelModulationEffectConfig): EffectConfig;
grayscale(config?: EffectPresetBase): EffectConfig;
invert(config?: EffectPresetBase): EffectConfig;function createLiveEditSession(
composition: Composition,
options?: LiveEditSessionOptions,
): LiveEditSession;interface LiveEditSession {
bindInput(input: LiveEditInput, target: Record<string, number>, property: string, options?: LiveEditBindingOptions): () => void;
bindLayerInput(input: LiveEditInput, layer: Layer, property: AnimatableProperty, options?: LiveEditBindingOptions): () => void;
setValue(target: Record<string, number>, property: string, value: number, options?: LiveEditOptions): void;
setLayerProperty(layer: Layer, property: AnimatableProperty, value: number, options?: LiveEditOptions): void;
flush(): void;
dispose(): void;
}function exportFrame(
composition: Composition,
time: number,
config?: FrameExportConfig,
): Promise<Blob | ArrayBuffer | string>;function exportFrameSequence(
composition: Composition,
config?: FrameSequenceExportConfig,
): Promise<Array<ExportedFrame<Blob | ArrayBuffer | string>>>;function exportVideo(
composition: Composition,
config: VideoExportConfig,
adapter?: VideoExportAdapter,
): Promise<Blob>;function loadBrowserScrawlAdapter(
options: BrowserScrawlAdapterOptions,
): Promise<BrowserScrawlAdapter>;
function createBrowserScrawlAdapter(
scrawl: ScrawlBrowserModule,
options: BrowserScrawlAdapterOptions,
): BrowserScrawlAdapter;function createAudioAnalyzer(
context: AudioContext,
source: AudioNode,
config?: AudioAnalysisConfig,
): AudioAnalyzer;
function createEmptyAudioAnalysisFrame(): AudioAnalysisFrame;
function normalizeAmplitude(samples: Float32Array): number;
function normalizeBand(value: number, maxValue?: number): number;
function analyzeFrequencyBands(data: Uint8Array, options: FrequencyAnalysisOptions): AudioBands;
function attachAudioBridgeToLayer(layer: Layer, bridge: AudioLayerBridge): AudioLayerBridge;Most validation, capability, resource, and runtime failures throw EngineError.
try {
composition.seek(-1);
} catch (error) {
if (error instanceof EngineError) {
console.error(error.code, error.category, error.context);
}
}- Confirm you created the composition with
loadBrowserScrawlAdapter. - Confirm the canvas element exists before creating the adapter.
- Call
composition.seek(0)orawait createTimelineSynchronizer(composition).seek(0)after adding layers. - Confirm your layer has visible geometry or a valid media source.
- Use
composition.seek(time)orcomposition.syncFrame(time)after keyframe changes. - For expression functions, call
animation.applyExpressions(time)or usecreateExpressionRenderHook. - For generic motion targets, call
composition.applyMotionTargets()or sync a frame.
- Effects require a Scrawl runtime with
makeFilter. - Empty
actionsarrays are invalid. - Only numeric action fields appear in
effect.values.
- Visual layer-to-layer masks need a runtime that can create Scrawl Cells.
- Same-layer masks can work directly on the entity.
- Check that the matte layer is visible before masking; the engine owns visibility after
setLayerMask.
- Use
createTimelineSynchronizer. - Watch
onDesync. - Keep
frameRateconsistent between composition, preview, and export. - Avoid manually seeking media outside the synchronizer.
- Frame export requires
renderer.captureFrame. - Video export requires
renderer.getFrameCanvas. - Video export needs browser encoder support required by Mediabunny.
outputType: 'dataurl'requires a runtime withbtoa.
Import from @willyrg/motionkit, not internal paths.
import type { Composition, Layer, EffectConfig } from '@willyrg/motionkit';- Scrawl-canvas reference: https://scrawl-v8.rikweb.org.uk/docs/reference/index.html
- Scrawl-canvas filters: https://scrawl-v8.rikweb.org.uk/docs/reference/sc-filter-engine.html
- Scrawl-canvas Groups and Cells: https://scrawl-v8.rikweb.org.uk/docs/reference/sc-groups-cells.html
- GSAP docs: https://gsap.com/docs/
- Mediabunny docs: https://mediabunny.dev/