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

How it works

Compile records authored intent once. Resolution reconstructs actor state for any frame from that record. Nothing else may influence a frame.

MotionActor has exactly two stages, and almost every question about the runtime — why a value did not update, why a seek produced the wrong result, why a renderer should not own a timer — is answered by knowing which stage you are in.

Stage 1: compile

createScene(generator) runs your generator once, immediately, to completion. It does not render anything and it does not advance a clock. What it does is keep a cursor of authored time and write down every authored event it passes.

const compiled = createScene(function* (ctx) {
  const title = ctx.spawn(TextBlock, { id: "title" });

  yield* title.appearance.opacity.tween(1, 0.2); //  cursor 0.0 → 0.2
  yield* ctx.wait(1); //                             cursor 0.2 → 1.2
  yield* title.layout.y.tween(80, 0.4); //           cursor 1.2 → 1.6

  return { title };
});

The result is a CompiledScene: a record of spawns, tween ranges, structural hosting and scene exports. yield* is the only thing that moves the cursor — a plain set(...) writes at the current cursor and takes no time.

opacity.tween(1, 0.2)
6f
ctx.wait(1)
30f
layout.y.tween(80, 0.4)
12f
30 fps
0s·0f
1s·30f
2s·60f

Authored seconds become integer frames at compile time using the scene’s fps. At 30 fps this scene is 48 frames long.

Stage 2: resolution

compiled.at(frame) reconstructs every actor’s state at that frame from the record. It is a pure function and it is random access — frame 400 costs the same whether or not you have asked for frame 399.

const state = compiled.at(60);

state.scene.title; // typed scene export
state.findActor(TextBlock); // typed lookup by blueprint
state.camera.zoom; // resolved camera

Renderers read resolved state and draw it. They never own animation truth, never hold a timer, and never decide what the next frame looks like.

What separates them

Runs once, in the generator.

  • ctx.spawn(...) — create an actor
  • yield* tween(...), yield* ctx.wait(...) — consume authored time
  • signal.set(...) — write at the current cursor
  • child(...), hostInSlot(...) — declare structure
  • watch(...) — register a reactive response

Runs per frame, per at() call.

  • derived(...) bodies evaluate
  • channel reads resolve through the precedence ladder
  • localLayout() / worldLayout() produce numeric boxes
  • effects report their normalized progress
  • the renderer draws

Determinism is a contract, not a goal

at(frame) is pure in three inputs and nothing else:

  1. the frame number,
  2. the values of the channel publications it reads,
  3. the explicit options you pass (viewport, channelValues).

Anything else that changes the answer is a bug in the scene, not a feature of it. Date.now(), Math.random(), module-level mutable counters and closed-over state inside derived, transition or watch bodies all break seeking and caching — silently, because the first playthrough looks fine and only scrubbing backwards reveals it.

Why this shape

Seeking works without replay

Scrubbing to an arbitrary frame is one at(frame) call. A runtime that simulated forward would need to replay from zero, which is why timeline scrubbing in imperative animation systems is either slow or wrong.

Rendering is parallelizable

Remotion renders frames concurrently across processes. Because no frame depends on another having been resolved, distributed rendering needs no coordination beyond handing each worker a frame number.

Frames cache precisely

memoizedScene(compiled) wraps at() with an LRU keyed on (frame, viewport) and invalidated per channel key. Precision here is only possible because the set of inputs to a frame is closed and known.

Scenes can be read without being drawn

A test, an agent, or an editor can ask what the state is at frame 90 without a renderer, a canvas or a browser. This is what makes headless evaluation and machine judging of compositions possible.

Authored time

Authored durations are seconds. The runtime converts to integer frames using the scene’s fps, which defaults to 30.

type Duration =
  | number // seconds
  | { frames: number }; // explicit frames, when you really mean frames

Use { frames: n } only when a duration is genuinely frame-quantized (a one-frame flash, a sprite step). Everything else should be authored in seconds so the scene survives an fps change.

Was this page helpful?