an.stage.timeline#

Stage timeline helpers: the compiled scene as a Timeline, and screen space.

The evaluation itself — tracks of placed clips, write groups, the pure pose — moved to an.timing.timeline (the timing kernel). This module re-exports it so every existing caller keeps its import path, and keeps what is the STAGE’s own: reading a compiled CutoutSceneJSON (timeline_from_scene()) and composing node transforms into canvas positions (screen_position()).

>>> from an.timing.channel import Channel, Keyframe
>>> from an.adapters.cutout.clip import Clip
>>> ch = Channel("a", "x", [Keyframe(0.0, 0.0), Keyframe(1.0, 10.0)])
>>> clip = Clip("walk", duration=1.0, channels=[ch])
>>> tl = Timeline(duration=2.0, tracks=[Track("a", clips=[PlacedClip(clip, start_time=0.5)])])
>>> evaluate_timeline(tl, 1.0)[("a", "x")]
5.0

Functions

merge_poses(*poses)

Merge multiple poses with override semantics (later wins per key).

write_group(prop)

What prop writes on a STAGE node (the stage.node space's groups).

evaluate_timeline(timeline, t, *[, space])

Evaluate timeline at time t, merging poses across tracks/clips.

clip_from_json(anim, *[, name])

One compiled animation (compiled.schema.json's animation) as a Clip.

timeline_from_scene(scene)

The compiled scene's timeline/animations as an evaluable Timeline.

transform_of(node[, pose, frame_height])

A node's transform, with pose overriding what the document declares.

screen_position(scene, path, *[, pose, point])

Where point in path's local space lands on the canvas.

node_box(scene, path, *[, pose])

The canvas rectangle (x0, y0, x1, y1) the visual at path occupies (an#445).

Classes

Channel(target, property[, keyframes])

Sorted keyframes for one property of one target.

Keyframe(time, value[, easing])

One keyframe: time, value, optional per-segment easing.

Clip(name, duration[, channels, loop_mode])

Named animation: a duration + a bundle of channels.

LoopMode(*values)

How a clip behaves past its natural duration.

PlacedClip(clip[, start_time, duration, ...])

A clip placed at an absolute time on a track.

Track([target_root, clips])

A sequence of placed clips that share a common purpose / target prefix.

Timeline(duration[, tracks, space])

A duration + ordered list of tracks.

Transform2D([x, y, rotation, scale_x, ...])

One node's local transform, in the runtime's own vocabulary.

class an.stage.timeline.Channel(target, property, keyframes=<factory>)[source]#

Bases: object

Sorted keyframes for one property of one target.

Construction validates that keyframes is non-empty and sorted.

class an.stage.timeline.Clip(name, duration, channels=<factory>, loop_mode=LoopMode.ONCE)[source]#

Bases: object

Named animation: a duration + a bundle of channels.

class an.stage.timeline.Keyframe(time, value, easing=None)[source]#

Bases: object

One keyframe: time, value, optional per-segment easing.

The easing on a keyframe describes the curve leaving that keyframe toward the next one. The last keyframe’s easing is therefore unused.

class an.stage.timeline.LoopMode(*values)[source]#

Bases: str, Enum

How a clip behaves past its natural duration.

class an.stage.timeline.PlacedClip(clip, start_time=0.0, duration=None, speed=1.0, blend_in=0.0, blend_out=0.0)[source]#

Bases: object

A clip placed at an absolute time on a track.

property effective_duration: float#

Duration this clip occupies on the timeline (after speed scaling).

class an.stage.timeline.Timeline(duration, tracks=<factory>, space=None)[source]#

Bases: object

A duration + ordered list of tracks. The canonical playback structure.

space is the property space the timeline’s DOCUMENT declares for its targets (an#245): one space, a registered name, or a target -> space resolver. It is what evaluate_timeline() uses when the caller passes none, so every caller of the default evaluates a target in the space its entity’s kind declares. None (every document that declares nothing) is the kernel default, an.timing.spaces.DFLT_TIMELINE_SPACE.

class an.stage.timeline.Track(target_root='', clips=<factory>)[source]#

Bases: object

A sequence of placed clips that share a common purpose / target prefix.

target_root is informational metadata for downstream tools (the JS runtime can use it to scope rendering); evaluation does not filter by it.

class an.stage.timeline.Transform2D(x=0.0, y=0.0, rotation=0.0, scale_x=1.0, scale_y=1.0, pivot_x=0.0, pivot_y=0.0, rotation_x=0.0, eye_distance=0.0)[source]#

Bases: object

One node’s local transform, in the runtime’s own vocabulary.

Field names and defaults mirror applyTransform in runtime.js exactly — x, y, rotation, scale_x, scale_y, pivot_x, pivot_y — because the point of this class is to agree with the vendored engine rather than to re-derive it. skew is deliberately absent: PixiJS composes skew into the same matrix, but no emitter in this package produces a skew channel, and a field nothing writes is a claim this compositor cannot honour.

apply(point)[source]#

This node’s local point, in its PARENT’s coordinates.

world = position + M·(local − pivot) — the composition PixiJS performs, and the reason root.pivot is a 2D camera: moving the pivot moves everything the node contains, in the opposite direction.

Return type:

tuple[float, float]

>>> Transform2D(x=10.0).apply((0.0, 0.0))
(10.0, 0.0)
>>> Transform2D(pivot_x=25.0).apply((0.0, 0.0))
(-25.0, 0.0)
>>> Transform2D(scale_x=2.0).apply((5.0, 0.0))
(10.0, 0.0)
project(q)[source]#

A plane point q = local − pivot as the tilted plane shows it.

k = f / (f − q_y·sin θ), P(q) = (k·q_x, k·q_y·cos θ): the hinge row (q_y = 0) is unmoved, and a row up a receding plane is pulled toward the horizon at f·cot θ above it.

Return type:

tuple[float, float]

>>> t = Transform2D(rotation_x=math.pi / 3, eye_distance=1000.0)
>>> t.project((100.0, 0.0))
(100.0, 0.0)
>>> x, y = t.project((100.0, -500.0))
>>> x < 100.0 and -500.0 < y < 0.0
True

A point behind the eye (or a plane edge-on, or no eye distance) is one the runtime does not draw, so it has no position:

>>> t.project((0.0, 5000.0))
Traceback (most recent call last):
...
ValueError: plane point (0.0, 5000.0) is not drawn: ...
rotation_x: float#

The plane’s tilt (an#314, radians; positive = the top recedes) and the eye’s distance from the hinge in scene px (perspective × the frame height). The projection is applied to local − pivot, before the 2D transform, exactly as runtime.js “Planes” draws it.

unapply(point)[source]#

The inverse of apply() — a parent-space point, in local space.

Return type:

tuple[float, float]

>>> t = Transform2D(x=10.0, pivot_x=3.0, scale_x=2.0, rotation=0.4)
>>> round(t.unapply(t.apply((7.0, -2.0)))[0], 9)
7.0
unproject(p)[source]#

The inverse of project().

Return type:

tuple[float, float]

>>> t = Transform2D(rotation_x=1.0, eye_distance=800.0)
>>> [round(v, 9) for v in t.unproject(t.project((40.0, -300.0)))]
[40.0, -300.0]
an.stage.timeline.clip_from_json(anim, *, name=None)[source]#

One compiled animation (compiled.schema.json’s animation) as a Clip.

Accepts the JSON mapping or any object with the same attributes (the stage’s AnimationClipJSON). Two fields are carried rather than defaulted, and both have cost a bug: loop_mode (without it every loop evaluated as once — an#7) and a list-valued easing, which is a cubic-bezier control quadruple and must stay a tuple for Keyframe. The stage compiler reads a from-less tween’s start through this too (an#212), so it evaluates exactly what the runtime will.

Return type:

Clip

an.stage.timeline.evaluate_timeline(timeline, t, *, space=None)[source]#

Evaluate timeline at time t, merging poses across tracks/clips.

The result is a PURE function of t (an#185): what a node shows at t never depends on which instants were evaluated before it. Per (target, property):

  • Active — some clip writing it is playing at t (inclusive end: a clip at [s, e] is active at t == e too, so the final frame of “play this from 0 to 1 s” is visible at 1.0). Later wins: track order, then clip order within a track. Written at t.

  • Held — no clip writing it is playing, but one has ended: the value the clip reached AT ITS END holds. The latest end wins; a tie goes to the later clip, the same “later wins” as above. Written at that end.

  • At rest — nothing writing it has started yet. The key is ABSENT from the pose, and its value is the node’s own (the entity’s rest state; runtime.js restores what it built).

Keys that write the same thing on one node (a write group: the swap sets of one visual, rotation/rotation_rad) keep only the most recently WRITTEN — an ended viseme@happy span does not outlive the viseme track that took the mouth back.

space says what each property is (an.timing.spaces): one space, a registered space’s name, or a target -> space resolver. Its field kinds interpolate and its write groups resolve. None is the default space, an.timing.spaces.DFLT_TIMELINE_SPACE (the stage node’s declared kinds, by name at call time). space=VALUE_TYPED is the rule runtime.js implements — interpolation by value type, the stage.node write groups — which gives the same pose on every timeline the compiler emits (it refuses a value that fails its field kind).

Forward-order rendering used to show the value at the clip’s last SAMPLED frame instead (the runtime kept whatever it last applied). The two agree whenever a clip ends on the frame grid — true of every golden-corpus clip — and differ when it ends between frames: a 0.37 s tween to 10 at 24 fps used to stop at 9.80 and now lands on 10, as authored. That landing is deliberate (it is the bug the motion presets’ settling set patched one preset at a time), and it is what makes the pose independent of the grid. Also deliberate: a clip shorter than a frame that no frame lands in now leaves its end value, and a held descendant tint stays on top of an ancestor’s later tint (the more specific target wins, as it always did while both played).

runtime.js::evaluateTimeline is a port of this function and cutan’s tests/test_pure_pose.py holds the two to it.

>>> from an.timing.channel import Channel, Keyframe
>>> from an.timing.clip import Clip
>>> ch = Channel("a", "x", [Keyframe(0.0, 0.0), Keyframe(1.0, 10.0)])
>>> tl = Timeline(2.0, [Track("a", [PlacedClip(Clip("m", 1.0, [ch]), 0.5)])])
>>> evaluate_timeline(tl, 0.0)  # not started: at rest, so absent
{}
>>> evaluate_timeline(tl, 1.0)[("a", "x")]  # active
5.0
>>> evaluate_timeline(tl, 1.75)[("a", "x")]  # ended: its end value holds
10.0
Return type:

dict[tuple[str, str], Any]

an.stage.timeline.merge_poses(*poses)[source]#

Merge multiple poses with override semantics (later wins per key).

Used by the timeline to combine concurrent clips on the same target.

Return type:

dict[tuple[str, str], Any]

>>> merge_poses({("a", "x"): 1.0}, {("a", "x"): 2.0, ("a", "y"): 3.0})
{('a', 'x'): 2.0, ('a', 'y'): 3.0}
an.stage.timeline.node_box(scene, path, *, pose=None)[source]#

The canvas rectangle (x0, y0, x1, y1) the visual at path occupies (an#445).

The visual’s box, the one the runtime fits its art into (width × height about its anchor, plus a swap key’s own box, anchor and offset when the key in force has one, an#211), with its four corners composed up the chain by screen_position(). So it rotates, scales and pans with the node, its parents and the camera, exactly as a point does. None when the node draws nothing.

Under fit: contain the art keeps its aspect inside the box, so the rectangle is an upper bound on what is painted: exact on the fitted axis, with the slack the art’s aspect leaves on the other. A stroked path’s box is its polyline’s, grown by half the stroke.

Return type:

tuple[float, float, float, float] | None

>>> from an.stage.serialize import CutoutSceneJSON, NodeJSON, TimelineJSON, TransformJSON, VisualJSON
>>> scene = CutoutSceneJSON(
...     scene=NodeJSON(name="root", children=[NodeJSON(
...         name="card", transform=TransformJSON(x=40.0),
...         visual=VisualJSON(kind="rect", width=20.0, height=10.0))]),
...     timeline=TimelineJSON(duration=1.0),
... )
>>> scene.meta.width, scene.meta.height = 320, 240
>>> node_box(scene, "card")
(190.0, 115.0, 210.0, 125.0)
>>> node_box(scene, "card", pose={("card", "scale_x"): 2.0})
(180.0, 115.0, 220.0, 125.0)
an.stage.timeline.screen_position(scene, path, *, pose=None, point=(0.0, 0.0))[source]#

Where point in path’s local space lands on the canvas.

The composition the runtime performs, walked from the node up to root and then offset by the canvas centre — which is where runtime.js places the root container.

pose is keyed by the FULL path ("street/hills"), matching what evaluate_timeline returns, and each node reads only its own entry.

Return type:

tuple[float, float]

>>> from an.stage.serialize import CutoutSceneJSON, NodeJSON, TimelineJSON, TransformJSON
>>> scene = CutoutSceneJSON(
...     scene=NodeJSON(name="root", children=[
...         NodeJSON(name="hill", transform=TransformJSON(x=40.0))]),
...     timeline=TimelineJSON(duration=1.0),
... )
>>> scene.meta.width, scene.meta.height = 320, 240
>>> screen_position(scene, "hill")
(200.0, 120.0)

…and moving the camera’s pivot moves it the other way, which is the whole reason root.pivot is the camera:

>>> screen_position(scene, "hill", pose={("root", "pivot_x"): 25.0})
(175.0, 120.0)
an.stage.timeline.timeline_from_scene(scene)[source]#

The compiled scene’s timeline/animations as an evaluable Timeline.

compile_shot produces a serialisable document (an.stage.serialize) for the JS runtime; this rebuilds the evaluable form, so a caller can ask what a compiled scene’s pose is at time t without a browser. It is the Python side of the parity contract: evaluate_timeline over this object is the executable spec runtime.js is tested against. The reading itself is the kernel’s (an.timing.timeline.timeline_from_compiled()), which carries loop_mode and keeps a list-valued easing a tuple.

Return type:

Timeline

>>> from an.stage.compile import compile_shot
>>> from an.ir.compose import tween
>>> from an.ir.schema import Shot
>>> shot = Shot(id="s1", renderer="cutout", duration=2.0,
...             actions=[tween("root", "x", 10.0, 1.0, from_=0.0)])
>>> scene = compile_shot(shot, mall=None, fps=24)
>>> evaluate_timeline(timeline_from_scene(scene), 0.5)[("root", "x")]
5.0
an.stage.timeline.transform_of(node, pose=None, *, frame_height=1080.0)[source]#

A node’s transform, with pose overriding what the document declares.

The runtime applies a pose value by assigning the property on the display object, so a channel REPLACES the declared value rather than adding to it — which is why the parallax compensation carries the plane’s own offset in every keyframe instead of an offset from it. frame_height turns a perspective (in frame heights, an#314) into the eye’s distance.

Return type:

Transform2D

an.stage.timeline.write_group(prop)[source]#

What prop writes on a STAGE node (the stage.node space’s groups).

Two keys in one group set the same thing, so only the more recently written can be showing: every swap set on a node swaps the one visual it carries (viseme and viseme@happy both set the mouth’s texture, an#88), and rotation_rad is rotation. Every other runtime property (an.base.TRANSFORM_PROPERTIES, the runtime’s own switch) writes only itself.

Return type:

str

>>> write_group("x"), write_group("rotation_rad"), write_group("viseme@happy")
('x', 'rotation', '<swap>')