MotionScript

@motion-script/core


@motion-script/core / StateEvaluator

Class: StateEvaluator

Defined in: runtime/state-evaluator.ts:69

Drives scene generators forward in time and exposes the evaluated state for layout and rendering. It is the stateful playback engine that PlaybackController calls on every tick (and on seek).

Each scene gets a SceneSlot that holds its generator and the highest local frame it has reached. Forward seeks simply advance the generator; backward seeks within a scene reset that slot and replay from frame 0. Scenes that haven't been entered yet are initialised lazily when first needed.

Call order per frame:

  1. stateAt(frame) — advance generator(s) to the requested frame.
  2. layout(scope) — lay out the current scene's node tree.
  3. render(context) — draw the current scene into the render context.

Why the replay loop lays out per frame

A backward seek (and any multi-frame forward jump) replays the scene generator from frame 0 to the target in one stateAt call. Some generator bodies read post-layout state — the animated removeChildAt/reparent helpers pin a child's box to its laid-out measuredWidth/measuredHeight, and the hug/fill addChildAt path measures against parent._lastScope (see node-lifecycle.ts). Those are only fresh after a layout()/measure() pass. So the replay loop lays out before each generator.next(dt), exactly as Precomp does — otherwise the generator would read a stale (or zero, for a just-added child) layoutRect and the animation would diverge from forward playback. This is what makes a backward scrub reproduce the forward result.

Constructors

Constructor

new StateEvaluator(scenes, viewport, fps, assets, tracks, measureScope, globals?): StateEvaluator

Defined in: runtime/state-evaluator.ts:124

Parameters

scenes

Scene[]

Scene list in timeline order.

viewport

Size2D

Render viewport size; passed to each scene on init.

fps

number

Frames per second — used to convert frames ↔ seconds.

assets

AssetCatalog

Asset catalog bound to scenes before each generator step.

tracks

number[]

Per-scene frame counts in timeline order (one entry per scene). Used to build global frame ranges so stateAt can jump directly to the owning scene without scanning.

measureScope

MeasureScope

Text-measurement scope for the internal layout passes the replay loop runs between generator steps (see class doc).

globals?

ProjectGlobals

The project's global layers/audio. Pass the same instance the Precomp was given (precomp.globals), so the layers that draw are the ones whose assets were measured.

Returns

StateEvaluator

Accessors

currentFrame

Get Signature

get currentFrame(): number

Defined in: runtime/state-evaluator.ts:104

Most-recently evaluated global frame (integer).

Returns

number


currentScene

Get Signature

get currentScene(): Scene

Defined in: runtime/state-evaluator.ts:187

Returns

Scene


currentSceneIndex

Get Signature

get currentSceneIndex(): number

Defined in: runtime/state-evaluator.ts:192

Index of the current scene in the scenes array, or -1 if none.

Returns

number

Methods

dispose()

dispose(): void

Defined in: runtime/state-evaluator.ts:603

Dispose all scenes and global layer frames, and drop generator references.

Returns

void


invalidate()

invalidate(): void

Defined in: runtime/state-evaluator.ts:430

Internal

Returns

void


layout()

layout(scope?): void

Defined in: runtime/state-evaluator.ts:274

Lay out the current scene's node tree — and the active global layers — against the full viewport.

Parameters

scope?

MeasureScope = ...

Returns

void


render()

render(context): void

Defined in: runtime/state-evaluator.ts:289

Draw the frame: global backgrounds, the current scene, then global overlays.

The scene paints its own fill over the backgrounds, so a background only shows through where that fill is absent (the default) or translucent — and because the layers sit outside the scene root, neither is touched by the scene camera or its clip.

Parameters

context

RenderContext

Returns

void


replaceScene()

replaceScene(index, newScene, tracks): void

Defined in: runtime/state-evaluator.ts:572

Swap a single scene in place (hot reload). Disposes the old scene at index, installs newScene, and recomputes every slot's global frame range from tracks (the replaced scene's new duration shifts everything downstream). Only the replaced slot's generator is dropped — untouched slots keep their cached generators, so scenes ≠ index never re-run.

Parameters

index

number

Index of the scene to replace.

newScene

Scene

The edited scene instance to install.

tracks

number[]

New per-scene frame counts in timeline order.

Returns

void


setTracks()

setTracks(tracks): void

Defined in: runtime/state-evaluator.ts:551

Recompute every slot's global frame range from a new per-scene duration list, leaving generators and replay progress untouched.

This is how a progressively-measured project grows: Precomp.runAsync publishes a longer timeline as each scene lands, and the slots have to follow. Deliberately not routed through replaceScene, which nulls the slot's generator — that would throw away a replay the playhead is currently sitting on and force a full re-drive from the scene's frame 0 every time an unrelated later scene finished measuring.

Safe against the live playhead because durations only ever get appended: a scene's startFrame shifts only when an earlier scene's duration changes, and under the sequential invariant an earlier scene is already final by the time the playhead can reach a later one. So stepReplay's globalTime never jumps for the slot being replayed.

Parameters

tracks

number[]

Per-scene frame counts in timeline order.

Returns

void


stateAt()

stateAt(frame, isCancelled?): void

Defined in: runtime/state-evaluator.ts:317

Advance (or rewind) state to the given global frame.

  • If frame matches the current frame and the generator is already primed, this is a no-op (early return).
  • If the target is within the current slot but behind the generator's position, the slot is reset and replayed from frame 0.
  • If the target belongs to a different scene, that slot is entered (resetting it if necessary) and advanced to the local target frame.

If isCancelled returns true mid-replay, the loop bails without advancing _currentFrame, leaving the slot at a partial local frame. The partial work is intentionally discarded: a later backward seek resets and replays cleanly, and a later forward seek simply resumes advancing from the partial frame (the generator is mid-scene but internally consistent). Since _currentFrame is untouched, the early-return guard below won't mistake the partial position for a completed seek.

Parameters

frame

number

Global frame index (float accepted; fractional part ignored).

isCancelled?

SeekCancel

Optional predicate polled between advanced frames; when it returns true the replay stops early (see above).

Returns

void


stateAtAsync()

stateAtAsync(frame, isCancelled?, budgetMs?): Promise<boolean>

Defined in: runtime/state-evaluator.ts:365

Time-sliced twin of stateAt: identical per-frame semantics (both drive stepReplay), but the loop yields to the event loop every budgetMs so a newer seek can actually preempt one already running.

This is the whole point of the async path. stateAt is synchronous, so while it runs no other JS can execute — nothing can bump the caller's generation counter, so its isCancelled predicate is provably always false. A backward seek deep into a long scene therefore blocks the main thread for its full duration and cannot be abandoned. Yielding is what makes cancellation observable.

Re-entrancy

The only suspension point is the await below, so another replay can only begin there. isCancelled is re-checked as the first thing after every resume, before touching slot — so a superseded replay can never mutate state a newer one has already moved on from. On top of that, concurrent async replays are serialized through replayInFlight, because resetSlot rebuilds into the shared stage and two interleaved resets would corrupt it.

The synchronous stateAt is deliberately not gated by replayInFlight: it never suspends, so it always completes atomically with respect to an async replay parked on a yield — and that replay re-validates everything on resume.

Parameters

frame

number

isCancelled?

SeekCancel

budgetMs?

number = DEFAULT_REPLAY_BUDGET_MS

Returns

Promise<boolean>

true if frame was reached, false if the replay was superseded, cancelled, or the evaluator was disposed. A false return means _currentFrame is untouched and the caller must not render.