Skip to content
MotionActor
Esc
↑↓navigate↵open⌘Jpreview

How it works

Scene code runs once at compile time or once per frame at resolve time, and a renderer only reads the result.

Code in a MotionActor scene runs at one of two times. Generator code runs once, when the scene compiles. derived bodies and channel reads run once per frame, when a frame resolves. A renderer runs after both and only reads what they produced. Most questions about the runtime come down to which of these a line belongs to.

Compile time

createScene returns a definition. scene.build({ viewport, fps }) runs its generator to the end and returns a CompiledScene. While it runs, the runtime keeps a cursor in frames and records every operation at the cursor’s position. yield* on a tween or a wait moves the cursor forward; set writes at the cursor and moves nothing.

Durations are seconds. Each one is multiplied by the build’s fps, which the host passes, and rounded to the nearest whole frame, so the last wait, 7.5 frames at 30 fps, becomes 8. Authoring in seconds keeps a scene’s timing when its frame rate changes.

The record is ordinary data:

import { 
const scene: SceneDefinition<Readonly<Record<never, SceneInput>>, Readonly<Record<never, SceneLayer<BackendRef<string>>>>, {
    title: Title;
}>
scene
} from "./cursor.scene";
using
using compiled: CompiledScene<{
    title: Title;
}, CompiledLayersOf<Readonly<Record<never, SceneLayer<BackendRef<string>>>>>>
compiled
=
const scene: SceneDefinition<Readonly<Record<never, SceneInput>>, Readonly<Record<never, SceneLayer<BackendRef<string>>>>, {
    title: Title;
}>
scene
.
SceneDefinition<Readonly<Record<never, SceneInput>>, Readonly<Record<never, SceneLayer<BackendRef<string>>>>, { ...; }>.build(args: SceneBuildInput<Readonly<Record<never, SceneInput>>>): CompiledScene<{
    title: Title;
}, CompiledLayersOf<Readonly<Record<never, SceneLayer<BackendRef<string>>>>>>
build
({ viewport: SceneViewportviewport: { SceneViewport.width: numberwidth: 1280, SceneViewport.height: numberheight: 720 }, fps: numberfps: 30 });
using compiled: CompiledScene<{
    title: Title;
}, CompiledLayersOf<Readonly<Record<never, SceneLayer<BackendRef<string>>>>>>
compiled
.CompiledScene<{ ...; }, CompiledLayersOf<...>>.fps: numberfps; // 30
using compiled: CompiledScene<{
    title: Title;
}, CompiledLayersOf<Readonly<Record<never, SceneLayer<BackendRef<string>>>>>>
compiled
.CompiledScene<{ ...; }, CompiledLayersOf<...>>.totalDuration: numbertotalDuration; // 56
using compiled: CompiledScene<{
    title: Title;
}, CompiledLayersOf<Readonly<Record<never, SceneLayer<BackendRef<string>>>>>>
compiled
.CompiledScene<{ ...; }, CompiledLayersOf<...>>.operations: Operation[]operations.Array<Operation>.map<"transition" | "emit" | "set" | "all" | "chain" | "sequence" | "delay" | "wait" | "fork" | "loop" | "subscribe" | "tween" | "play" | "spawn" | "command" | "scope" | "apply" | "host" | "reorder" | "despawn">(callbackfn: (value: Operation, index: number, array: Operation[]) => "transition" | "emit" | "set" | "all" | "chain" | "sequence" | "delay" | "wait" | "fork" | "loop" | "subscribe" | "tween" | "play" | "spawn" | "command" | "scope" | "apply" | "host" | "reorder" | "despawn", thisArg?: any): ("transition" | "emit" | ... 17 more ... | "despawn")[]
Calls a defined callback function on each element of an array, and returns an array that contains the results.
@paramcallbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.@paramthisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.
map
((operation: Operationoperation) => operation: Operationoperation.kind: "transition" | "emit" | "set" | "all" | "chain" | "sequence" | "delay" | "wait" | "fork" | "loop" | "subscribe" | "tween" | "play" | "spawn" | "command" | "scope" | "apply" | "host" | "reorder" | "despawn"kind);
// ["spawn", "set", "tween", "wait", "tween", "wait"]

createScene returns a definition and never compiles by itself. Each build({ viewport, fps, inputs }) compiles a fresh record for those values.

Resolve time

compiled.at(frame) rebuilds the state of every actor at one frame from the record. Signals take their tweened values, derived bodies run, channels supply their current values and layout boxes are computed. The result is a FrameState, with typed scene exports and lookups by class.

A channel is how a value the scene cannot know gets in, such as a measured width. Here a meter’s fill depends on its level, which the scene tweens, and on a width the host supplies:

import {
  
function channel<TValue>(options: {
    readonly default: TValue;
    readonly record?: boolean;
    readonly equals?: (previous: TValue, next: TValue) => boolean;
}): ChannelDefinition<TValue> & {
    readonly hasDefault: true;
    readonly default: TValue;
} (+1 overload)
A channel is a writable, scope-aware cell carrying an actor-channel brand. Declared as an actor field it lives in the spawning build's store (keyed `${actorId}:${name}` by the spawn walk, like a signal); declared free (in `buildScene`, a script, or module top) it keys by its scope, in its build's store or the module's, and is shared by reference. Either way resolution reads it live. The static return type stays `ChannelDefinition<TValue>` so the `isChannelDefinition` predicate is unchanged; at runtime the handle is a live cell.
channel
,
function createScene<TSceneExports, const I extends SceneInputs = Readonly<Record<never, SceneInput>>, const L extends SceneLayers = Readonly<Record<never, SceneLayer<BackendRef<string>>>>>(script: SceneScript<I, L, TSceneExports>, options?: SceneDefinitionOptions<I, L>): SceneDefinition<I, L, TSceneExports>
Declare a scene. Nothing compiles until a host builds it: `scene.build({ viewport, fps, inputs })` runs the script as `(ctx, inputs, viewport, layers)` and returns the compiled scene. `ctx.spawn` places a root actor in the default layer; `layers.<key>.spawn` places it in a declared one. `ctx.stage` stages another scene on this scene's clock, and a staged scene may stage again.
createScene
,
function derived<TThis, T>(fn: (this: TThis) => T): DerivedDefinition<T, TThis>derived, class LayoutActor<TEvents extends ActorEventMap = {}>LayoutActor,
function signal<T>(): RequiredSignal<T> (+1 overload)
module signal
A timeline value on an actor, and an input to every spawn of it. Without an initial value the signal is required: `ctx.spawn` must supply it.
signal
,
} from "@motionactor/core"; class class MeterMeter extends class LayoutActor<TEvents extends ActorEventMap = {}>LayoutActor { Meter.level: ActorFieldSignal<number>level = signal<number>(initial: number): ActorFieldSignal<number> (+1 overload)
A timeline value on an actor, and an input to every spawn of it. Without an initial value the signal is required: `ctx.spawn` must supply it.
signal
(0);
Meter.width: ChannelDefinition<number> & {
    readonly hasDefault: true;
    readonly default: number;
}
width
=
channel<number>(options: {
    readonly default: number;
    readonly record?: boolean;
    readonly equals?: ((previous: number, next: number) => boolean) | undefined;
}): ChannelDefinition<number> & {
    readonly hasDefault: true;
    readonly default: number;
} (+1 overload)
A channel is a writable, scope-aware cell carrying an actor-channel brand. Declared as an actor field it lives in the spawning build's store (keyed `${actorId}:${name}` by the spawn walk, like a signal); declared free (in `buildScene`, a script, or module top) it keys by its scope, in its build's store or the module's, and is shared by reference. Either way resolution reads it live. The static return type stays `ChannelDefinition<TValue>` so the `isChannelDefinition` predicate is unchanged; at runtime the handle is a live cell.
channel
({ default: numberdefault: 400 });
Meter.fill: DerivedDefinition<number, unknown>fill = derived<unknown, number>(fn: (this: unknown) => number): DerivedDefinition<number, unknown>derived(() => this.Meter.level: () => numberlevel() * this.Meter.width: () => numberwidth()); } const
const compiled: CompiledScene<{
    meter: Meter;
}, CompiledLayersOf<Readonly<Record<never, SceneLayer<BackendRef<string>>>>>>
compiled
=
createScene<{
    meter: Meter;
}, Readonly<Record<never, SceneInput>>, Readonly<Record<never, SceneLayer<BackendRef<string>>>>>(script: SceneScript<Readonly<Record<never, SceneInput>>, Readonly<Record<never, SceneLayer<BackendRef<string>>>>, {
    meter: Meter;
}>, options?: SceneDefinitionOptions<Readonly<Record<never, SceneInput>>, Readonly<Record<never, SceneLayer<BackendRef<string>>>>> | undefined): SceneDefinition<...>
Declare a scene. Nothing compiles until a host builds it: `scene.build({ viewport, fps, inputs })` runs the script as `(ctx, inputs, viewport, layers)` and returns the compiled scene. `ctx.spawn` places a root actor in the default layer; `layers.<key>.spawn` places it in a declared one. `ctx.stage` stages another scene on this scene's clock, and a staged scene may stage again.
createScene
(function* (ctx: SceneContextctx) {
const const meter: Metermeter = ctx: SceneContextctx.
CompileContext.spawn<Meter>(definition: ActorClass<Meter>, overrides?: {
    measuredBox?: undefined;
    level?: SignalInputValue<number> | undefined;
    width?: undefined;
    fill?: undefined;
    layout?: {
        width?: Readonly<{
            kind: "layout.percent";
            value: number;
        }> | SignalInputValue<number | `${number}%` | Readonly<{
            kind: "layout.percent";
            value: number;
        }>> | undefined;
        x?: SignalInputValue<LayoutValue> | undefined;
        y?: SignalInputValue<LayoutValue> | undefined;
        height?: Readonly<...> | ... 1 more ... | undefined;
    } | undefined;
    ... 20 more ...;
    parent?: undefined;
} | undefined, options?: {
    ...;
} | undefined): Meter (+1 overload)
spawn
(class MeterMeter);
yield* const meter: Metermeter.Meter.level: ActorFieldSignal<number>level.LiveSignal<number>.tween(target: number, options: number | SignalTweenOptions<number>): Generator<Frame, void, unknown>
Tween to target over authored seconds — must yield*
tween
(1, 1);
return { meter: Metermeter }; }).
SceneDefinition<Readonly<Record<never, SceneInput>>, Readonly<Record<never, SceneLayer<BackendRef<string>>>>, { ...; }>.build(args: SceneBuildInput<Readonly<Record<never, SceneInput>>>): CompiledScene<{
    meter: Meter;
}, CompiledLayersOf<Readonly<Record<never, SceneLayer<BackendRef<string>>>>>>
build
({ viewport: SceneViewportviewport: { SceneViewport.width: numberwidth: 1920, SceneViewport.height: numberheight: 1080 }, fps: numberfps: 30 });
const const meter: ResolvedActorOfInstance<Meter>meter =
const compiled: CompiledScene<{
    meter: Meter;
}, CompiledLayersOf<Readonly<Record<never, SceneLayer<BackendRef<string>>>>>>
compiled
.
CompiledScene<{ ...; }, CompiledLayersOf<...>>.at(frame: Frame, options?: FrameResolutionOptions): FrameState<{
    meter: Meter;
}>
at
(15).
FrameState<{ ...; }>.scene: {
    meter: ResolvedActorOfInstance<Meter> | undefined;
}
scene
.meter: ResolvedActorOfInstance<Meter> | undefinedmeter!;
const meter: ResolvedActorOfInstance<Meter>meter.fill: numberfill; // 200 const meter: ResolvedActorOfInstance<Meter>meter.channels: ActorClassChannels<Omit<Meter, keyof Actor<any>>>channels.width: ResolvedChannelHandle<number>width.ResolvedChannelHandle<number>.write(value: number, frame?: number): void
Publish `value`. `frame` is the frame the producer's scene host committed; a `record: true` channel files the value under it, so a later seek reads the value observed at that frame. Without one the write is live only.
write
(800);
const compiled: CompiledScene<{
    meter: Meter;
}, CompiledLayersOf<Readonly<Record<never, SceneLayer<BackendRef<string>>>>>>
compiled
.
CompiledScene<{ ...; }, CompiledLayersOf<...>>.at(frame: Frame, options?: FrameResolutionOptions): FrameState<{
    meter: Meter;
}>
at
(15).
FrameState<{ ...; }>.scene: {
    meter: ResolvedActorOfInstance<Meter> | undefined;
}
scene
.meter: ResolvedActorOfInstance<Meter> | undefinedmeter?.fill: number | undefinedfill; // 400

The host publishes through the resolved actor’s handle, and every later read sees the value.

Render time

A renderer receives the resolved actors of one frame and draws them. It has no clock and no way to change what the scene authored; a value it measures goes back in through a channel. The same resolved frame can be drawn by the DOM, Pixi, Three.js or Skia backend, inside Remotion, HyperFrames, the native preview or the exporter.

Compile time Resolve time Render time
Runs once per scene, or per build once per at(frame) once per drawn frame
Code the generator, commands, set, ctx.spawn derived bodies, channel reads, layout actor renderers, backend hosts
Moves time yield* never never

What a frame depends on

at(frame, options) depends on three things: the frame number, the values of the channels it reads, and the options passed to it (viewport). Anything else that changes the result is a bug in the scene. Date.now(), Math.random() or a module-level counter inside a derived body will look right during the first playthrough and drift once someone seeks.

Because the inputs are known, frames cache exactly. at() keeps each actor’s resolved values and revisits an actor only when a timeline it reads moves, a channel it read is published, or an effect on it is active. Asking for the same frame again with nothing changed returns the same object; a call with cache: false resolves afresh.

import { 
const scene: SceneDefinition<Readonly<Record<never, SceneInput>>, Readonly<Record<never, SceneLayer<BackendRef<string>>>>, {
    title: Title;
}>
scene
} from "./cursor.scene";
using
using compiled: CompiledScene<{
    title: Title;
}, CompiledLayersOf<Readonly<Record<never, SceneLayer<BackendRef<string>>>>>>
compiled
=
const scene: SceneDefinition<Readonly<Record<never, SceneInput>>, Readonly<Record<never, SceneLayer<BackendRef<string>>>>, {
    title: Title;
}>
scene
.
SceneDefinition<Readonly<Record<never, SceneInput>>, Readonly<Record<never, SceneLayer<BackendRef<string>>>>, { ...; }>.build(args: SceneBuildInput<Readonly<Record<never, SceneInput>>>): CompiledScene<{
    title: Title;
}, CompiledLayersOf<Readonly<Record<never, SceneLayer<BackendRef<string>>>>>>
build
({ viewport: SceneViewportviewport: { SceneViewport.width: numberwidth: 1280, SceneViewport.height: numberheight: 720 }, fps: numberfps: 30 });
using compiled: CompiledScene<{
    title: Title;
}, CompiledLayersOf<Readonly<Record<never, SceneLayer<BackendRef<string>>>>>>
compiled
.
CompiledScene<{ ...; }, CompiledLayersOf<...>>.at(frame: Frame, options?: FrameResolutionOptions): FrameState<{
    title: Title;
}>
at
(40) ===
using compiled: CompiledScene<{
    title: Title;
}, CompiledLayersOf<Readonly<Record<never, SceneLayer<BackendRef<string>>>>>>
compiled
.
CompiledScene<{ ...; }, CompiledLayersOf<...>>.at(frame: Frame, options?: FrameResolutionOptions): FrameState<{
    title: Title;
}>
at
(40); // true
using compiled: CompiledScene<{
    title: Title;
}, CompiledLayersOf<Readonly<Record<never, SceneLayer<BackendRef<string>>>>>>
compiled
.
CompiledScene<{ ...; }, CompiledLayersOf<...>>.at(frame: Frame, options?: FrameResolutionOptions): FrameState<{
    title: Title;
}>
at
(40, { FrameResolutionOptions.cache?: false | undefined
`false` resolves the frame afresh: nothing is reused from earlier calls and nothing is kept for later ones. The frame equals a cached call's.
cache
: false }) ===
using compiled: CompiledScene<{
    title: Title;
}, CompiledLayersOf<Readonly<Record<never, SceneLayer<BackendRef<string>>>>>>
compiled
.
CompiledScene<{ ...; }, CompiledLayersOf<...>>.at(frame: Frame, options?: FrameResolutionOptions): FrameState<{
    title: Title;
}>
at
(40); // false

Was this page helpful?