@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:
stateAt(frame)— advance generator(s) to the requested frame.layout(scope)— lay out the current scene's node tree.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
Render viewport size; passed to each scene on init.
fps
number
Frames per second — used to convert frames ↔ seconds.
assets
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
Text-measurement scope for the internal layout passes the replay loop runs between generator steps (see class doc).
globals?
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
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
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
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
framematches 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?
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?
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.