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.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): voidPublish `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