---
title: How it works
description: 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.

```ts
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.

<Timeline
  fps={30}
  seconds={2}
  tracks={[
    { label: "opacity.tween(1, 0.2)", start: 0, duration: 0.2, note: "6f" },
    { label: "ctx.wait(1)", start: 0.2, duration: 1, note: "30f" },
    { label: "layout.y.tween(80, 0.4)", start: 1.2, duration: 0.4, note: "12f" },
  ]}
>
  Authored seconds become integer frames at compile time using the scene's `fps`. At 30 fps
  this scene is 48 frames long.
</Timeline>

## 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.

```ts
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

**Compile time**

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

**Resolution time**

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](/docs/reactivity/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.

:::info[Where host values enter]
  Anything observed rather than authored — a measured DOM box, a stepped physics body, a
  fetched value — enters through a [`channel`](/docs/reactivity/channel). Channel
  publications are versioned, so cached frames invalidate precisely: publishing to channel
  A does not bust a frame that only read channel B.
:::

## 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.

```ts
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.
