an.stage.serialize#

JSON contract between the Python compiler and the (future) JS runtime.

These Pydantic models describe exactly the JSON shape the PixiJS runtime consumes — nothing more. compile_shot produces these objects; the runtime reads them. The schema is deliberately separate from the cutout-internal Python evaluation types (clip.Clip, channel.Channel) so the Python side can evolve internally without breaking the runtime contract.

This file used to carry a sketched Spine-flavoured swap vocabulary (SkinJSON, RigJSON, CutoutSceneJSON.rigs, SlotJSON/ NodeJSON.slots, current_attachment) that nothing populated and nothing read — deleted in an#86 so the real swap mechanism (VisualJSON’s per-node asset maps driven by step channels) is the only one the contract describes. A few declared-but-unwired scalars remain (PlacedClipJSON.blend_in / blend_out — recorded, never applied; VisualJSON.texture_id): the same debt class at smaller scale, kept only because they are field-shaped placeholders rather than a parallel vocabulary for a capability that shipped elsewhere. Do not add more; a new field needs its producer and its consumer in the same change.

>>> j = CutoutSceneJSON(
...     meta={"fps": 30, "width": 1920, "height": 1080, "duration": 5.0},
...     scene=NodeJSON(name="root"),
...     animations={},
...     timeline=TimelineJSON(duration=5.0, tracks=[]),
...     assets=AssetsJSON(textures={}, audio={}),
... )
>>> from_dict(to_dict(j)) == j
True

Module Attributes

BUILTIN_VISUAL_KINDS

The visual kinds runtime.js draws itself.

Functions

from_dict(d)

Rebuild a scene from a plain-dict representation.

to_dict(scene)

Dump a scene to a plain-dict representation (no None pruning).

Classes

AnimationClipJSON(**data)

A named, reusable animation clip.

AssetJSON(**data)

A single asset (texture / audio file).

AssetResolutionJSON(**data)

How one scene entity's store reference actually resolved at compile time.

AssetsJSON(**data)

Map of asset id → AssetJSON, split by kind.

ChannelJSON(**data)

One animated property of one target.

CutoutSceneJSON(**data)

Top-level cutout scene JSON — the JS runtime's input contract.

CutoutSceneMetaJSON(**data)

Per-shot metadata.

KeyframeJSON(**data)

Single keyframe in an animation channel.

NodeJSON(**data)

One node in the scene tree.

PathJSON(**data)

A stroked path's drawing instruction (an#160), carried on a path visual.

PlacedClipJSON(**data)

An animation placed on a track at a specific time.

TimelineJSON(**data)

Top-level timeline: total duration + tracks.

TrackJSON(**data)

A sequence of placed clips with optional target-prefix metadata.

TransformJSON(**data)

Local transform of a scene-graph node (authoring form).

UnderlayJSON(**data)

A copy of a node's own visual, drawn BEHIND it in the same container (an#163 gap 5: the outline and the paper-gap shadow).

VisualJSON(**data)

Drawable content attached to a node.

class an.stage.serialize.AnimationClipJSON(**data)[source]#

Bases: _JSONModel

A named, reusable animation clip.

model_config: ClassVar[ConfigDict] = {'extra': 'allow', 'populate_by_name': True}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class an.stage.serialize.AssetJSON(**data)[source]#

Bases: _JSONModel

A single asset (texture / audio file).

model_config: ClassVar[ConfigDict] = {'extra': 'allow', 'populate_by_name': True}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class an.stage.serialize.AssetResolutionJSON(**data)[source]#

Bases: _JSONModel

How one scene entity’s store reference actually resolved at compile time.

The IR declares every drawable entity as store + ref. What the compiler builds from that pair is not recoverable from the scene tree afterwards: a character whose descriptor is missing and a character that never had one produce byte-identical procedural rigs. That ambiguity is an#33 — three CI runners once agreed perfectly about a picture that was not the picture, and the agreement read as a clean positive result.

So the compiler records what it did, per entity, in the artifact the browser actually loads. fallback is the load-bearing bit: True means the declared ref supplied nothing and a stand-in was drawn in its place.

>>> AssetResolutionJSON(
...     id="maya", kind="character", store="characters", ref="maya-v1",
...     resolved="descriptor", fallback=False,
... ).fallback
False
detail: str#

One human sentence saying why, when fallback is True.

fallback: bool#

True when the declared ref supplied nothing and a stand-in was drawn.

model_config: ClassVar[ConfigDict] = {'extra': 'allow', 'populate_by_name': True}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

resolved: str#

“descriptor” | “parts” | “placeholder”. Environments: “store” | “preset” | “default”.

Type:

What was built. Characters

class an.stage.serialize.AssetsJSON(**data)[source]#

Bases: _JSONModel

Map of asset id → AssetJSON, split by kind.

model_config: ClassVar[ConfigDict] = {'extra': 'allow', 'populate_by_name': True}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

an.stage.serialize.BUILTIN_VISUAL_KINDS: tuple[str, ...] = ('sprite', 'rect', 'ellipse', 'svg_sprite', 'path')#

The visual kinds runtime.js draws itself. Any other kind must be registered by a genre’s runtime script (an#247), or it draws as a rect.

class an.stage.serialize.ChannelJSON(**data)[source]#

Bases: _JSONModel

One animated property of one target.

model_config: ClassVar[ConfigDict] = {'extra': 'allow', 'populate_by_name': True}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class an.stage.serialize.CutoutSceneJSON(**data)[source]#

Bases: _JSONModel

Top-level cutout scene JSON — the JS runtime’s input contract.

Versioned so the runtime can refuse incompatible inputs.

asset_resolution: list[AssetResolutionJSON]#

One entry per drawable entity, in scene order — see AssetResolutionJSON. Inert to the runtime; read by the bench harness and the golden-corpus bless to assert WHICH render path ran.

model_config: ClassVar[ConfigDict] = {'extra': 'allow', 'populate_by_name': True}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

overlay: NodeJSON | None#

a second top-level container the runtime centres on the canvas and never indexes, so no channel — the camera’s root.pivot/root.scale included — can reach it. Its children are indexed by their own paths exactly like scene’s, so an overlay title’s words tween like anything else. None when the shot has no overlay, and then omitted from the serialized document.

Type:

The camera-immune layer (an#155)

class an.stage.serialize.CutoutSceneMetaJSON(**data)[source]#

Bases: _JSONModel

Per-shot metadata.

Per-entity blink phase in [0, 1), a pure function of the entity NAME (an#88). Stamped by the compiler — which now emits blinks as channels — and inert to the runtime. It is recorded because renaming a corpus character silently re-phases every blink and moves every pixel metric; a stamped phase turns that into a visible diff instead of an unexplained metric shift. (The runtime’s determinism probe used to carry this; the fact moved with the mechanism.)

entity_spaces: dict[str, str]#

Per entity, the property space its kind declares, where that is NOT the kernel default (stage.node) – so evaluating this document with no explicit space evaluates each target in its entity’s space, as an validate and the compiler already do (an#245; read by an.timing.timeline.timeline_from_compiled()). **Serialized only when non-empty**: no shipped kind declares another space, so no document and no contract hash moves until a genre registers one.

extensions: dict[str, Any]#

What genre compile passes add to the document’s meta, by key (an#247): the generic slot a pass writes through CompileState.meta_extensions rather than a field the stage would have to know by name. Also where a genre’s explicit replacement of a stage pass is recorded (replaced_compile_passes). Serialized only when non-empty.

fonts: dict[str, str]#

entity id -> "<family> <style> [(embedded)] sha256:<digest>" — identity by the font’s BYTES, since a family name is not one. Provenance, inert to the runtime (the glyphs are already SVG). Serialized only when non-empty.

Type:

Per text block, the face that set it (an#155)

gaze_seeds: dict[str, int]#

Per-entity saccade seed (an#99), a pure function of the entity NAME like blink_phases; stamped for the rigs that have pupils and serialized only when non-empty — a pre-Wave-6 rig has no pupils and its compiled document, the bench’s scene contract, must not move.

model_config: ClassVar[ConfigDict] = {'extra': 'allow', 'populate_by_name': True}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

spaces: dict[str, dict[str, Any]]#

The definition of each space entity_spaces names, in the timing contract’s kinds.json form (PropertySpace.to_json), so the document is self-describing: runtime.js has no registry and evaluates a declared entity’s targets by THESE field kinds and write groups (an#287), and an.timing.timeline.timeline_from_compiled() reads the same definitions back. Serialized only when non-empty (it is empty exactly when entity_spaces is).

step_hz: float | None#

The stepped-timing policy the shot’s tweens were compiled under (an#89); None = smooth. Serialized only when set: the compiled document is the bench’s scene contract (scene_contract_sha256), so a null here would move every committed row’s hash for a knob nobody turned. Inert to the runtime; read by the ledger.

style_pack: str | None#

The name of the StylePack this scene was compiled under, or None. Recorded so a rendered document says which art direction produced it — the pack’s COLOURS are already resolved into the nodes, so this is provenance rather than an instruction. Omitted from the serialized document when unset (see the wrap serializer below).

class an.stage.serialize.KeyframeJSON(**data)[source]#

Bases: _JSONModel

Single keyframe in an animation channel.

model_config: ClassVar[ConfigDict] = {'extra': 'allow', 'populate_by_name': True}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class an.stage.serialize.NodeJSON(**data)[source]#

Bases: _JSONModel

One node in the scene tree.

model_config: ClassVar[ConfigDict] = {'extra': 'allow', 'populate_by_name': True}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

paint_order: list[str] | None#

The order its descendants’ visuals are PAINTED in, when it is not the tree’s (an#430): node paths relative to this node, back to front. Set on a rig’s entity node whose nested chain interleaves with an unrelated part (an upper arm 1, a cord 2, a forearm 3); the runtime keeps each visual in its transform tree and paints it from this list.

scope: str | None#

as the children of the node named scope in the same parent ("": as the parent’s own children). None = under this node’s own path, the rule before an#343. An environment’s foreground container carries scope=<env>, so every plane is <env>/<plane> wherever the environment is cut. an.stage.tree is the Python statement of the rule.

Type:

Where this node’s CHILDREN are indexed (an#343)

z_index: float | None#

This node’s place among its siblings when its parent sorts them (an#403): a nested chain whose declared draw order is not the tree’s (a far arm behind the torso it nests under). None = insertion order.

class an.stage.serialize.PathJSON(**data)[source]#

Bases: _JSONModel

A stroked path’s drawing instruction (an#160), carried on a path visual.

points is always a POLYLINE — the compiler flattens cubic Béziers (an.stage.path_geometry.flatten_curve), so the runtime knows one geometry. trim_start / trim_end are the values shown before any channel touches the node; channels on those two properties move them. head_length == 0 means no arrowhead. What the runtime draws from this is specified by an.stage.path_geometry.path_geometry.

closed: bool#

closed joins the stroke at the first point (the polyline already returns there); fill (#rrggbb) is drawn under the stroke at fill_alpha and is not trimmed. Omitted from the wire when off, so no existing path document’s hash moves.

Type:

A closed shape and its fill (an#161)

dash: float#

dash > 0 is on. dash_offset is the value shown before a channel touches it. These are wire fields of path visuals only, so they cannot move a non-path document’s hash.

Type:

Dash pattern (an#161)

model_config: ClassVar[ConfigDict] = {'extra': 'allow', 'populate_by_name': True}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

tail_head_length: float#

A tail arrowhead at the trimmed start (an#161), 0 = none. Omitted from the wire when off, so no existing path document’s hash moves.

width_profile: list[tuple[float, float]] | None#

[[t, factor], ...] over the whole path’s length; the runtime fills path_geometry’s outlines. Omitted when unset.

Type:

A variable-width stroke (an#161)

class an.stage.serialize.PlacedClipJSON(**data)[source]#

Bases: _JSONModel

An animation placed on a track at a specific time.

model_config: ClassVar[ConfigDict] = {'extra': 'allow', 'populate_by_name': True}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class an.stage.serialize.TimelineJSON(**data)[source]#

Bases: _JSONModel

Top-level timeline: total duration + tracks.

model_config: ClassVar[ConfigDict] = {'extra': 'allow', 'populate_by_name': True}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class an.stage.serialize.TrackJSON(**data)[source]#

Bases: _JSONModel

A sequence of placed clips with optional target-prefix metadata.

model_config: ClassVar[ConfigDict] = {'extra': 'allow', 'populate_by_name': True}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class an.stage.serialize.TransformJSON(**data)[source]#

Bases: _JSONModel

Local transform of a scene-graph node (authoring form).

alpha is not geometry, but it lives here for the same reason the rest does: this is the per-node property bag the runtime applies, and it is animatable through the same channel machinery.

It is set on the node’s container, so it cascades — fading a character fades every part of it. Note that this is per-part compositing, not a flattened group fade: where two parts of the same character overlap, the seam is visible mid-fade. That is the standard behaviour of a 2D scene graph and the right default; a true group fade needs the subtree rendered to a texture first, which costs a render pass per node per frame.

**This class’s field defaults are the single source of truth for a property’s rest value** — see compile.py’s _PROPERTY_REST_VALUES, which is derived from them rather than restated.

model_config: ClassVar[ConfigDict] = {'extra': 'allow', 'populate_by_name': True}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class an.stage.serialize.UnderlayJSON(**data)[source]#

Bases: _JSONModel

A copy of a node’s own visual, drawn BEHIND it in the same container (an#163 gap 5: the outline and the paper-gap shadow).

Being in the node’s container is the whole design: the copy takes every transform the node takes — tweens, play, the camera — with no channel of its own, and a swap on the node re-textures its copies (runtime.js applySwap). One copy is drawn per entry of offsets (in the node’s own frame), each grown by grow pixels: a rect/ellipse is redrawn with its geometry grown, an SVG sprite is scaled about its art’s centre so its box grows by grow on every side. color is the fill of a redrawn shape and the tint (a multiply) of a sprite copy. Compiled by an.stage.surface; never authored.

model_config: ClassVar[ConfigDict] = {'extra': 'allow', 'populate_by_name': True}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class an.stage.serialize.VisualJSON(**data)[source]#

Bases: _JSONModel

Drawable content attached to a node.

kind="svg_sprite" is the Phase 11b path: the runtime instantiates a PIXI.Sprite from a pre-loaded SVG texture identified by asset_id.

asset_sets carries this node’s swap vocabulary — {set_name: {KEY: asset_id}}, the compiler’s per-slot projection of the descriptor’s asset_sets onto the slot this visual draws (an#87). A channel whose property names one of these sets swaps the sprite’s texture by key; viseme is just the conventional set name lip-sync uses. Same field name as CharacterDescriptor.asset_sets on purpose: one vocabulary, two layers (descriptor = declared, wire = resolved to texture aliases). Replaces the mouth-only viseme_assets.

width/height are the box the art is fitted into, not the size it is forced to. Under fit="contain" the art keeps its own aspect ratio and may leave slack on one axis; that slack is the correct rendering, not a bug.

asset_geometry: dict[str, dict[str, float]] | None#

Per-texture geometry for a swap key drawn differently from the built one (an#211): {asset_id: {"width", "height", "anchor_x", "anchor_y", "x", "y"}} — the box the key is fitted into, its anchor, and its offset from the node (scene pixels). A swap used to carry the texture only, so every key drew in the DEFAULT attachment’s box: a closed mouth on a thin canvas squashed every open mouth to a fraction of a pixel. Only keys whose geometry differs are listed; None = every key shares it.

blend: Literal['add', 'multiply'] | None#

"add" for a glow, "multiply" for the paper grain. PixiJS 7 does both in the blend equation — no filter, no render texture. None = normal.

Type:

The engine’s native blend mode for this visual (an#163)

fit: Literal['stretch', 'contain']#

How the art is fitted to width/height.

"contain" scales uniformly so the art keeps the shape it was drawn with — the invariant of an#74. "stretch" sizes each axis independently, which is what every sprite did before that issue and what distorted arm_l by 3.929x on the repo’s own art.

Additive with a "stretch" default so no stored scene changes meaning; the compiler emits "contain" for every sprite it builds.

kind: str#

One of BUILTIN_VISUAL_KINDS, or a kind a genre’s runtime script registers (window.anRegisterVisual, an#247). A string on the wire, as it always was; the open set is what lets cutan ship the mouth and eye.

model_config: ClassVar[ConfigDict] = {'extra': 'allow', 'populate_by_name': True}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

path: PathJSON | None#

The stroke for kind="path" (an#160); None on every other visual.

underlays: list[UnderlayJSON] | None#

Copies drawn behind this visual, back to front (an#163) — the outline and the paper-gap shadow. Only rect, ellipse and svg_sprite take them; the runtime refuses any other kind.

z_index: float | None#

The visual’s place among its node’s children when the node sorts them (an#403): a part drawn over the chain nested under it.

an.stage.serialize.from_dict(d)[source]#

Rebuild a scene from a plain-dict representation.

Return type:

CutoutSceneJSON

an.stage.serialize.to_dict(scene)[source]#

Dump a scene to a plain-dict representation (no None pruning).

Return type:

dict[str, Any]