an.ir.schema#

Pydantic v2 models for the Scene IR.

Design principles (locked in from the architectural plan):

  • Renderer-agnostic. No cutout-specific or Manim-specific fields here. Backend adapters compile shots into their own internal formats. This module only knows what every shot has in common.

  • Versioned envelope. Every IR document carries version and compatible_version. Migrations live in an.ir.migrate.

  • Forward-compatible reads. Top-level model has extra="allow" so a future field doesn’t crash an older reader.

  • **Open Action union** (ADR 0001 decision 2). The union holds the core kinds (set, tween, sequence, parallel, delay, loop) and ONE open member, ExtensionAction, which a callable discriminator selects for any other kind. A genre registers its kinds (an.genres); a document’s kind: play then validates to the registered model, and before registration it stays an ExtensionAction that round-trips untouched and that validate, flatten and the compiler refuse by name. The union is never rebuilt at registration.

  • Time in seconds (float). Always.

Doctest:

>>> from an.ir.schema import SceneIR, Meta, Shot
>>> scene = SceneIR(
...     meta=Meta(title="Park Bench", duration=45.0),
...     timeline=[Shot(id="s1", renderer="cutout", duration=45.0)],
... )
>>> from an.base import SCHEMA_VERSION
>>> scene.version == SCHEMA_VERSION
True
>>> scene.kind
'SceneIR'
>>> scene.timeline[0].renderer
'cutout'

Module Attributes

DFLT_SHAKE_DURATION

how long a jolt lasts, how far the frame jumps (a fraction of the frame HEIGHT, so it reads the same at any resolution: 0.015 is about 11 px at 720p), and how many times a second it jumps.

CORE_ACTION_KINDS

The kind of every action the core defines (the static union members).

EXTENSION_TAG

The union tag of the open member.

Action

the core kinds plus ExtensionAction for any other kind (a registered genre kind validates to its own model through it).

DEFAULT_CAPTION_MAX_CHARS

the broadcast convention (BBC / Netflix timed-text guidance: 42 characters, two lines).

DEFAULT_CAPTION_SIZE

Caption type size as a fraction of frame height — a little under the title default, as captions are read while something else is watched.

STAGE_NODE_SPACE

The property space a 2D stage engine's node lives in (an.timing.spaces).

SPECIMEN_DURATION

How long a specimen shot runs (an library sheet draws its first frame).

SPECIMEN_SHOT_ID

The id of a specimen shot.

Functions

resolve_step_hz(shot, scene_step_hz)

The stepped-timing policy shot renders under: its own step_hz when it declares one, else the scene's, else None (smooth).

stage_specimen(ref)

A short shot showing the one stage entity ref casts, on its own (an#347).

unregistered_action_kind(kind, *[, where])

The error for an action kind no loaded genre registered.

Classes

AssetRef(**data)

Reference to an entry in a project store.

Camera(**data)

Camera state for a shot: a named move, or explicit keys.

CameraFollow(**data)

A camera that follows an entity (an#445): the frame keeps target where it stood at the shot's start.

CameraKey(**data)

One camera pose at one time — the explicit door behind the named moves.

CameraShake(**data)

A jolt of the frame inside a shot (an#429): seeded screen-space jitter.

Captions(**data)

Captions for the whole film, built from the dialogue's word timings.

CueAnchor(**data)

A sound cue's time, taken from the picture (an#317): when a node reaches a frame row or column.

CueUntil(**data)

Where a sound cue ends: at another cue's start, plus offset (an#317).

DelayAction(**data)

Composition: an empty span that consumes time.

Dialogue(**data)

One line of spoken dialogue.

ExtensionAction(**data)

An action of a kind the core does not define: the IR's one open member.

LoopAction(**data)

Composition: repeat child count times.

Meta(**data)

Scene metadata.

Narration(**data)

Off-screen narration.

ParallelAction(**data)

Composition: run all children simultaneously starting at the same time.

Resolution(**data)

Pixel dimensions of the rendered output.

SceneIR(**data)

Top-level Scene IR document.

SequenceAction(**data)

Composition: run children one after the other.

SetAction(**data)

Set a property to a value at a specific time.

Shot(**data)

A single rendered unit.

SoundCue(**data)

One sound placed on the timeline: an SFX hit, an ambience, a music bed.

StagePlacement(**data)

Where an entity stands on the stage, and how big it is.

Transition(**data)

How a shot is ENTERED — from the previous shot, or (for the first shot) from nothing.

TweenAction(**data)

Animate a property from a start value to an end value over a duration.

VisemeKeyframe(**data)

A single mouth-shape keyframe in a viseme track.

VisemeTrack(**data)

Aligned viseme track produced by the lip-sync stage.

VoiceLoudness(**data)

One loudness for every voice of the film (an#315).

WordTimingIR(**data)

One word of a line and when it was spoken, in seconds from the line's start (like VisemeKeyframe, never absolute).

an.ir.schema.Action#

the core kinds plus ExtensionAction for any other kind (a registered genre kind validates to its own model through it). SerializeAsAny makes a typed genre instance (a PlayAction) serialize with its own fields rather than the open member’s.

Type:

Every action

alias of Annotated[Annotated[SetAction, Tag(tag=set)] | Annotated[TweenAction, Tag(tag=tween)] | Annotated[SequenceAction, Tag(tag=sequence)] | Annotated[ParallelAction, Tag(tag=parallel)] | Annotated[DelayAction, Tag(tag=delay)] | Annotated[LoopAction, Tag(tag=loop)] | Annotated[ExtensionAction, SerializeAsAny(), Tag(tag=extension)], Discriminator(discriminator=_action_tag, custom_error_type=None, custom_error_message=None, custom_error_context=None)]

class an.ir.schema.AssetRef(**data)[source]#

Bases: _IRModel

Reference to an entry in a project store.

The IR never inlines large assets. Instead it references them by store name + key, so the same character/voice/environment is reusable across scenes. overrides lets a single shot tweak presentation without forking the asset.

>>> AssetRef(kind="character", id="maya", store="characters", ref="maya-v1").id
'maya'
kind: str#

it selected nothing (the compiler skipped it, nothing read the styles store) and the name belonged to the renderer selector. Art direction arrives as a StylePack (#112).

A str in the schema, not a Literal (ADR 0001 decision 2): the values are the REGISTERED entity kinds (an.genres) — the core’s environment, prop and voice, a genre’s character — and an validate checks it against that registry, naming the genre that provides an unregistered one.

Type:

"style" was retired in an#106

library: str | None#

The asset-library version this entry was checked out from (ADR 0005 decision 8): "[<library>:]<asset_id>@<version>", e.g. "cutan:character.alice-reiniger@v003" (grammar: an.library.ids.parse_ref(), a pinned version required: vNNN or sha256:<prefix>, never latest). None — the default, and every document written before the library existed — means the asset is the project’s own. Today the strategy is check-out, so ref still names the project-store key the compiler reads and this field is the pin beside it; live reference resolves it instead, later.

Additive and omit-when-unset, like stage: an unset library leaves no trace in a dump, so no stored scene changes and no schema version moves.

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

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

stage: StagePlacement | None#

Where on the stage this entity stands. None — the default and what every existing document has — means “wherever the layout puts it”, which for characters is the evenly-spaced row the compiler computes.

Additive by construction, and hash-free by construction: the contract hashes the COMPILED document, and an AssetRef never reaches it. So this field can grow without retiring a single ledger row.

an.ir.schema.CORE_ACTION_KINDS: tuple[str, ...] = ('set', 'tween', 'sequence', 'parallel', 'delay', 'loop')#

The kind of every action the core defines (the static union members).

class an.ir.schema.Camera(**data)[source]#

Bases: _CameraShakes

Camera state for a shot: a named move, or explicit keys.

>>> Camera(move="push_in").move
'push_in'
>>> Camera(keys=[CameraKey(at=0.0), CameraKey(at=2.0, x=-200.0)]).keys[1].x
-200.0

One code path, two front doors — the shape the dialogue [emotion] sugar already uses. A named move desugars to a key list; keys is that list written out. Setting both raises, because a scene that says two things about the same camera has no reading that is not a guess.

keys defaults to None, not [], and that too is load-bearing: the markdown writer dumps the camera with exclude_none=True, which KEEPS empty lists — an empty default would write keys: [] into every camera block it regenerates.

position, target and focal_length were removed in an#109. They were written into every scene.md this package ever generated and read by nothing; a registered migration drops them.

keys: list[CameraKey] | None#

The explicit door. None = use 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].

move: str | None#

A named preset — sugar for keys. The cutout renderer’s vocabulary is an.stage.compile.CAMERA_MOVES; validate and the compiler are pinned to the same table by test, because a move that validates and then raises is the failure _check_renderable exists to prevent.

class an.ir.schema.CameraFollow(**data)[source]#

Bases: _IRModel

A camera that follows an entity (an#445): the frame keeps target where it stood at the shot’s start.

>>> CameraFollow(target="bob").axes
'x'

target is an entity id or one of its nodes (bob/head); axes is which way the camera tracks it (x: a side-scrolling walk, xy: both). Resolved at compile time from the compiled motion (the target’s position at every frame), so planes parallax with it like any camera 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].

class an.ir.schema.CameraKey(**data)[source]#

Bases: _IRModel

One camera pose at one time — the explicit door behind the named moves.

>>> CameraKey(at=1.0, x=-160.0, zoom=1.25).x
-160.0

Sign convention, stated because every surveyed tool disagrees: +x moves the CAMERA right, which moves the content left. zoom is on-screen magnification, so 1.25 means “everything 25% bigger”, and it composes through the pivot — a push-in during a pan zooms toward what the camera is looking at rather than toward a fixed frame centre. rotation is camera roll in radians.

easing defaults to `None`, not `”ease_in_out”`, and that is not a style choice: today’s emitter puts "ease_in_out" on the first keyframe and null on the terminal one, so a per-key default of "ease_in_out" would put it on both and move every camera scene’s contract hash. The named moves supply the easing they have always supplied.

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

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

rotation: float#

Camera roll, radians.

x: float#

Camera position in scene pixels. +x moves the camera right.

zoom: float#

On-screen magnification. Must be > 0 — a zero or negative zoom is not a camera, and the compiler would emit a degenerate root scale.

class an.ir.schema.CameraShake(**data)[source]#

Bases: _IRModel

A jolt of the frame inside a shot (an#429): seeded screen-space jitter.

>>> CameraShake(at=1.0).duration
0.4

Layered on whatever the camera does (a move, keys, or nothing): the pan and zoom are the root’s pivot and scale, a shake is its screen position, so the two add without either knowing the other. The jitter is deterministic (seed) and, with decay, falls linearly to rest by the end; the frame is exactly at rest before at and after it.

amplitude: float#

How far the frame jumps at most, as a fraction of the frame height.

at: float#

When the jolt starts, in shot seconds.

decay: bool#

Fall linearly to rest over the duration (an impact); off, it holds its amplitude to the end (a rumble).

duration: float#

How long it lasts.

frequency: float#

Jumps per second.

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

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

seed: int#

the same seed draws the same jolt on every machine.

Type:

Which jitter

class an.ir.schema.Captions(**data)[source]#

Bases: _IRModel

Captions for the whole film, built from the dialogue’s word timings.

Present = on; meta.captions unset (the default) is no captions and no trace in any document. One cue list (an.captions.caption_pages()) feeds BOTH outputs, so the picture and the sidecar cannot disagree:

  • burn: each page is drawn as an overlay text block (camera-immune, placed at anchor in the title-safe area), shown for exactly the frames the sidecar says;

  • sidecar: a SubRip .srt written through the captions store, next to the delivered mp4, in FILM time (a dissolve shortens the film, and every later cue moves with it).

>>> Captions().max_chars, Captions().anchor
(42, 'bottom')
>>> Captions(highlight="#ffcc00").highlight
'#ffcc00'
anchor: str#

One of tituli’s nine title-safe anchors.

font: str | None#

None = the embedded face; else an ABSOLUTE font file path, or one relative to the project directory.

highlight: str | None#

the word being spoken is drawn in this colour (karaoke); None draws every word in color.

Type:

#rrggbb

max_chars: int#

Line breaks are made HERE, by character count, and written into both the burned block and the sidecar — the same lines in both. 42 fits the title-safe width of a 16:9 or 4:3 frame at the default size; a square or portrait frame needs fewer (about 32 at 1:1) or a smaller size — a line that does not fit is REFUSED before the render, never clipped.

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

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

strict: bool#

A line with no word timings is captioned with its words spread evenly over its duration, with a warning; strict makes that an error.

class an.ir.schema.CueAnchor(**data)[source]#

Bases: _IRModel

A sound cue’s time, taken from the picture (an#317): when a node reaches a frame row or column.

when is a node path in a stage shot (crawl/line_7: a text block’s unit, a prop, a plane); reaches is {"y": px} or {"x": px} in the frame’s pixels (0 at the top or left), crossed in either direction by the node’s on-screen centre — the camera, parallax and a crawl’s tilt included. Resolved when the film is laid out, never written back into the scene: a layout change moves the sound with the picture. offset is added after. shot names the shot to look in, for a cue in meta.sounds (default: the first shot that has the node).

>>> CueAnchor(when="crawl/line_7", reaches={"y": 840}).axis
('y', 840.0)
property axis: tuple[str, float]#

(axis, pixels).

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.ir.schema.CueUntil(**data)[source]#

Bases: _IRModel

Where a sound cue ends: at another cue’s start, plus offset (an#317).

cue is the sound key of a cue in the same list (the shot’s, or meta.sounds); the first such cue is meant.

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.ir.schema.DEFAULT_CAPTION_MAX_CHARS: int = 42#

the broadcast convention (BBC / Netflix timed-text guidance: 42 characters, two lines).

Type:

Characters per caption line and lines per caption page

an.ir.schema.DEFAULT_CAPTION_SIZE: float = 0.05#

Caption type size as a fraction of frame height — a little under the title default, as captions are read while something else is watched.

an.ir.schema.DFLT_SHAKE_DURATION: float = 0.4#

how long a jolt lasts, how far the frame jumps (a fraction of the frame HEIGHT, so it reads the same at any resolution: 0.015 is about 11 px at 720p), and how many times a second it jumps. Art direction, chosen to read as an impact rather than a tremor.

Type:

A camera shake’s defaults (an#429)

class an.ir.schema.DelayAction(**data)[source]#

Bases: _ActionBase

Composition: an empty span that consumes 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.ir.schema.Dialogue(**data)[source]#

Bases: _IRModel

One line of spoken dialogue.

start and duration are None until the audio pipeline runs (TTS gives us a real duration); the pipeline stamps them then, deriving start from the author’s pause / at (planned_start()).

at: float | None#

Where this line starts, in SHOT seconds, whatever came before it — (at 3.0) in scene.md. start is what the audio pipeline DERIVES from at/pause on every pass; these two are what the author wrote (an#187).

direction: list[str] | None#

How the line is DELIVERED — cues such as ["excited"] or ["sighs", "annoyed"], {excited} in scene.md (an#209). A TTS model that takes inline audio tags (ElevenLabs v3/v4) receives them as [excited] Hi!; others ignore them. Never part of text, so captions and lip-sync alignment never see a cue.

emotion_intensity: float | None#

maya [angry 0.4]: … in scene.md, a typed parameter on the (b-name) emotion. None is full strength, and is omitted from JSON.

Type:

How strongly emotion shows, 0..1 (an#253)

leveled: dict | None#

<the synthesized line’s audio_ref>, “gain_db”: <its voice’s gain>}`` when audio_ref is the leveled audio. None — unleveled — is omitted from JSON.

Type:

Stamped by voice leveling (an#315)

Type:

``{“source”

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

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

pause: float | None#

Seconds of silence before this line, after the previous line’s speech ends (the shot start, for the first line) — (pause 1.5) in scene.md.

planned_start(cursor)[source]#

Where this line starts, given the previous line ends at cursor.

The one rule the audio pipeline stamps into start and an validate lays lines out by: at if set, else cursor + pause. A start on a line never synthesized (no audio_ref) was authored — the spelling of at before an#187 — and counts as one; a synthesized line’s start is the pipeline’s own stamp, re-derived here.

Return type:

float

>>> Dialogue(speaker="a", text="bye", pause=1.5).planned_start(0.8)
2.3
>>> Dialogue(speaker="a", text="bye", at=4.0).planned_start(0.8)
4.0
>>> Dialogue(speaker="a", text="bye").planned_start(0.8)
0.8
spoken: float | None#

seconds from the line’s start to the end of its AUDIBLE speech — its take’s own trailing silence left out — stamped with duration from the line’s audio. The next line’s pause counts from here (a line with no pause still follows the whole take). None (silent audio, a line its voice’s trim_silence already cut to the tail the author keeps, a line stamped before an#397) counts from the end of duration, as before. Never written to JSON: it is read off the line’s stored audio on every pass (the audio pipeline, and a render that skips synthesis), so scene.json and its fixtures do not change.

Type:

DERIVED (an#397)

word_timings: list[WordTimingIR] | None#

The provider’s word timings, line-relative; None when the provider has none (offline, Rhubarb) or the line was stamped before an#96.

an.ir.schema.EXTENSION_TAG: str = 'extension'#

The union tag of the open member.

class an.ir.schema.ExtensionAction(**data)[source]#

Bases: _ActionBase

An action of a kind the core does not define: the IR’s one open member.

The schema’s Action union selects this for any kind other than the core’s (ADR 0001 decision 2). When a genre has REGISTERED that kind (an.genres.registry.register_action_kind()), validating a document yields the registered model instead — a PlayAction for kind: play — so code downstream sees typed actions. Before registration the action stays an ExtensionAction: its fields are kept as extras and round-trip byte for byte, and resolved() (called by flatten, an validate and the compiler) refuses it naming the genre that provides it.

Every genre’s action model subclasses this, which is what lets a typed instance sit in the union and serialize with its own fields.

>>> ExtensionAction(kind="wave", target="flag").model_dump()
{'name': None, 'kind': 'wave', 'target': 'flag'}
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(*, strict=True)[source]#

This action as its registered model.

A typed instance is returned as is. A bare ExtensionAction is validated by the model its kind registered — or, unregistered, raises UnregisteredKindError (strict) or comes back unchanged (strict=False).

Return type:

ExtensionAction

class an.ir.schema.LoopAction(**data)[source]#

Bases: _ActionBase

Composition: repeat child count times.

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.ir.schema.Meta(**data)[source]#

Bases: _IRModel

Scene metadata.

captions: Captions | None#

Captions from the dialogue’s word timings (Captions, an#175); None — the default — is none, omitted from JSON like style_pack.

closing_transition: Transition | None#

a fade to its color over its duration, on the last shot’s last frames (the film’s length is unchanged, and its final frame is the colour; the sound fades with it). None (or a cut) ends on the last frame as drawn. A dissolve has nothing to dissolve into, so it is refused.

Type:

How the film ENDS (an#389)

default_easing: str | tuple[float, float, float, float] | list[float] | None#

The easing every authored tween that names none is drawn with (an#166) — "linear" for a snappy South Park cadence, an overshooting cubic-Bezier for a bouncy one. Precedence is **tween > this > the built-in "ease_in_out"``** (:meth:`TweenAction.resolved_easing`). ``None — the default and what every existing document has — changes nothing, and is omitted from JSON like style_pack, so no committed scene and no compiled document moves. It reaches authored tweens ONLY: a motion preset writes its own easings, the camera’s named moves supply theirs, and blinks, play clips and swap channels have none to inherit. There is no per-shot override yet — style is a scene’s.

default_renderer: str#

a registered renderer’s name.

Type:

Like Shot.renderer

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

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

sounds: list[SoundCue]#

Sound cues in FILM time — a music bed, an ambience under every shot (SoundCue). Empty, the default, is no sound layer at all.

step_hz: float | None#

Stepped timing for AUTHORED TWEENS, in pose updates per second; None (the default) leaves every tween smooth. At 30 fps, 15 is “on twos” and 10 “on threes” — the character-animation practice Spider-Verse made famous (characters on twos, simulation on ones). The camera is exempt by construction, as are swap channels (already stepped by format), compiled blinks and play clips: only tween curves are resampled — sample-and-hold of the eased curve on a SHOT-wide grid (every tween in a shot shares it; it restarts at a cut), not a retiming into holds and fast transitions. A tween’s own START and END are always pose changes too, so a tween that begins or ends off-grid changes pose on that frame as well as on the grid (a set at an off-grid at likewise lands where it was authored). A shot’s own step_hz overrides this. Must be positive (schema) and <= fps (validate + compile), an#89.

style_pack: str | None#

The StylePack in the project’s styles store this scene is drawn under, by key. None — the default and what every existing document has — leaves every colour exactly where it is, which is why adding this moved no corpus hash (an#112).

A pack changes what the COMPILER decides: the character palette, the leg and pupil colours, the environment presets’ sky and ground — and SVG art whose descriptor tags its colours by role (colour_roles, written by an character new). Untagged art is never inferred (inferring a role from a pixel is what produced an#99’s wrong-tone lid); a rig a pack cannot reach is WARNED about by name at compile.

voice_loudness: VoiceLoudness | None#

One loudness for every voice (VoiceLoudness, an#315); None — the default — levels nothing, and is omitted from JSON.

class an.ir.schema.Narration(**data)[source]#

Bases: _IRModel

Off-screen narration. Same shape as Dialogue minus the speaker pin.

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.ir.schema.ParallelAction(**data)[source]#

Bases: _ActionBase

Composition: run all children simultaneously starting at the same 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.ir.schema.Resolution(**data)[source]#

Bases: _IRModel

Pixel dimensions of the rendered output.

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.ir.schema.SPECIMEN_DURATION: float = 0.5#

How long a specimen shot runs (an library sheet draws its first frame).

an.ir.schema.SPECIMEN_SHOT_ID: str = 'specimen'#

The id of a specimen shot.

an.ir.schema.STAGE_NODE_SPACE: str = 'stage.node'#

The property space a 2D stage engine’s node lives in (an.timing.spaces).

class an.ir.schema.SceneIR(**data)[source]#

Bases: _IRModel

Top-level Scene IR document. The SSOT.

A document is portable, diffable, and renderer-agnostic. Persisted as JSON at ir/scene.json inside an an project.

>>> from an.base import SCHEMA_VERSION
>>> doc = SceneIR(meta=Meta(title="Hello"))
>>> doc.version == SCHEMA_VERSION
True
>>> round_tripped = SceneIR.model_validate_json(doc.model_dump_json())
>>> round_tripped.meta.title
'Hello'
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.ir.schema.SequenceAction(**data)[source]#

Bases: _ActionBase

Composition: run children one after the other.

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.ir.schema.SetAction(**data)[source]#

Bases: _ActionBase

Set a property to a value at a specific time. Discrete, no tween.

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.ir.schema.Shot(**data)[source]#

Bases: _IRModel

A single rendered unit. A scene is a sequence of shots.

A shot’s renderer selects the backend that draws it. Every renderer must accept the same Shot fields; renderer-specific options go under options.

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

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

policy: dict[str, Any] | None#

per aspect, the methods it prefers in order, over the style pack’s (StylePack.policy) and under an author’s request — {"locomotion": ["loco.glide"]}. None: the style’s.

Type:

This shot’s policy (an#348)

renderer: str#

Which RENDERER draws this shot — not art direction. The field was called style until an#106, colliding with the styles store (which holds art direction) and with AssetRef(kind="style"); one word for two meanings is how a scene came to declare a “style” that selected a renderer while the thing that actually styles it went unread. A str in the schema (ADR 0001 decision 2): any name a renderer registered (an.adapters.register_renderer), checked by an validate. cutout stays the persisted default (decision 9).

sounds: list[SoundCue]#

Sound cues in SHOT-local time (SoundCue).

step_hz: float | None#

Per-shot override of Meta.step_hz (None = inherit).

transition: Transition | None#

How this shot is entered (Transition); None is a hard cut.

class an.ir.schema.SoundCue(**data)[source]#

Bases: _IRModel

One sound placed on the timeline: an SFX hit, an ambience, a music bed.

sound is a key in the project’s sounds store (an.sounds), where the bytes and their licence live — the IR never inlines audio.

at is seconds from the start of whatever holds the cue: a shot’s sounds are SHOT-local (they move with the shot, across transitions and re-orderings), meta.sounds are FILM time (a music bed under the whole thing).

>>> SoundCue(sound="hit", at=1.2).gain_db
0.0
>>> SoundCue(sound="bed", loop=True, duck_db=-12).duck_db
-12.0
at: Annotated[float, FieldInfo(annotation=NoneType, required=True, metadata=[Ge(ge=0)])] | CueAnchor#

Seconds, or a CueAnchor resolved from the picture when the film is laid out (an#317).

duck_db: float | None#

Attenuation, in dB, while any dialogue line plays; None never ducks. A music bed usually wants DEFAULT_DUCK_DB; an SFX hit wants none.

duration: float | None#

the asset’s own length, or — when loop — to the end of its shot (shot cue) or of the film (meta cue).

Type:

How long it plays. None

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

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

until: CueUntil | None#

Ends the cue at another cue’s start (CueUntil), instead of a fixed duration.

class an.ir.schema.StagePlacement(**data)[source]#

Bases: _IRModel

Where an entity stands on the stage, and how big it is.

>>> StagePlacement(at=(120.0, -40.0), scale=0.5).at
(120.0, -40.0)
>>> StagePlacement().at is None
True

at is in SCENE pixels relative to the stage centre — the same space the compiler already places characters in — so an author reads it off the same ruler as a camera pivot. scale multiplies the rig’s own uniform scale.

`after` landed in an#344 (a studio set’s sky disc between a sky plane and a holed wall plate): it names the anchor the entity is drawn right after. depth is still not a field: an entity takes the depth of the plane it is placed after. The paragraphs below are the record of why both were deferred, kept because depth’s reason still holds.

**Deliberately only two fields, and the reason has been re-stated because the first one expired.** #108 sketched depth and after as well, deferred on the grounds that they belong to a stage vocabulary that had not arrived. It arrived the same day: #109 landed the translating camera and #110 landed plane environments, and nothing revisited this paragraph — a deferral citing a condition outlives the condition silently, which is why the reason below cites a state instead (an#126).

The current reason is simply that **nothing reads them and nothing has asked to.** Shipping either now would put a knob in the schema that a scene can set, that renders identically, and that says nothing — which is worse than an absent one, and is the same defect an.styles.UNREACHABLE_ROLES refuses by construction on the other side of the compiler.

What each would cost, so the next reader does not re-derive it:

  • depth would place a prop on a parallax plane, which means parenting the prop into that plane’s subtree — and _track_root_of makes entity identity the first path segment, so every animation target on that prop changes shape.

  • after would order a prop against planes, which is characters_after’s problem a second time; that one needed the environment and character builders interleaved before any ordering could be expressed at all.

Neither is hard. Both are unmotivated, and an unmotivated knob in a versioned schema is a migration you owe later for a feature nobody used.

after: str | None#

an environment plane ("set/skyline") or another placed entity ("grid"). None = the default band, where the environment’s characters_after cuts. A plane’s parallax carries an entity placed after it; an entity placed after an entity rides the default depth.

Type:

What this entity is drawn immediately after (an#344)

at: tuple[Annotated[float, FieldInfo(annotation=NoneType, required=True, metadata=[AllowInfNan(allow_inf_nan=False)])], Annotated[float, FieldInfo(annotation=NoneType, required=True, metadata=[AllowInfNan(allow_inf_nan=False)])]] | None#

(x, y) in scene pixels from the stage centre. None = default layout.

allow_inf_nan=False on both fields, and it is not pedantry: pydantic serializes inf and nan to JSON null, and re-validating that JSON raises — so a scene file written with either is corrupt one way, and the author finds out on the next load rather than at the edit that did it (an#108 review, M-1). gt=0 already refuses scale=0 and scale=-1; it does not refuse inf.

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

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

scale: float#

Uniform scale multiplier on the built rig. 1.0 = the rig’s own size.

class an.ir.schema.Transition(**data)[source]#

Bases: _IRModel

How a shot is ENTERED — from the previous shot, or (for the first shot) from nothing.

>>> Transition(kind="dissolve", duration=0.5).duration
0.5
>>> Transition(kind="fade").color
'#000000'
  • cut — the default, and what a shot with no transition means.

  • fade — through color: the previous shot’s last duration / 2 fades to the colour and this shot’s first duration / 2 fades up from it. On the FIRST shot the whole duration is a fade up from the colour. Holds the film’s length: nothing overlaps.

  • dissolve — the previous shot’s last duration seconds and this shot’s first duration seconds are seen through each other. **The film gets duration shorter** than the sum of its shots: both shots play in full, overlapped (the editor’s convention — the overlapped seconds are each shot’s “handle”). Dialogue stays in sync with its own shot’s picture; audio from both shots is heard in the overlap. Not allowed on the first shot.

  • wipe — overlaps like a dissolve, but a hard edge sweeps across the frame in direction (the way the edge travels: left brings this shot in from the right), this shot on the side it has passed (an#390). The edge stands where a dissolve’s mix would: (j + 1) / (k + 1) of the way across on overlap frame j of k. Not on the first shot.

direction: Literal['left', 'right', 'up', 'down']#

A wipe’s direction; omitted from a dump for every other 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].

class an.ir.schema.TweenAction(**data)[source]#

Bases: _ActionBase

Animate a property from a start value to an end value over a duration.

easing: str | tuple[float, float, float, float] | list[float] | None#

a tween that does not name an easing takes the scene’s Meta.default_easing, and only when that is unset too the built-in "ease_in_out" this default spells. “Unset” is "easing" not in model_fields_set — the default stays the literal so every reader of .easing still sees the curve a scene without a default draws — and the serializer below omits an unset easing, so the distinction survives scene.json. None is an explicit LINEAR ramp (the evaluators’ reading of a null easing), not “unset”.

Type:

The curve. **Unset is not the same as ``”ease_in_out”``** (an#166)

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_easing(default=None)[source]#

The easing this tween draws with under a scene default of default — the ONE statement of the precedence **tween > scene (Meta.default_easing) > built-in ``”ease_in_out”``** (an#166).

>>> TweenAction(target="a", property="x", to_value=1).resolved_easing("linear")
'linear'
>>> TweenAction(target="a", property="x", to_value=1, easing="ease_in_out").resolved_easing("linear")
'ease_in_out'
>>> TweenAction(target="a", property="x", to_value=1).resolved_easing(None)
'ease_in_out'
Return type:

Any

class an.ir.schema.VisemeKeyframe(**data)[source]#

Bases: _IRModel

A single mouth-shape keyframe in a viseme track.

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.ir.schema.VisemeTrack(**data)[source]#

Bases: _IRModel

Aligned viseme track produced by the lip-sync stage. Optional in P1.

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.ir.schema.VoiceLoudness(**data)[source]#

Bases: _IRModel

One loudness for every voice of the film (an#315).

TTS voices arrive at very different levels (a 19 dB spread measured on one episode). Set, the render levels each voice: its integrated loudness over ALL its lines (EBU R128), one gain to target_lufs (plus the voice document’s loudness_offset_db), peaks held at peak_db dBFS by a lookahead limiter — derived audio, content-keyed, never re-synthesised (an.audio.loudness). Unset, the default, levels nothing.

>>> VoiceLoudness().target_lufs, VoiceLoudness().peak_db
(-16.0, -1.5)
>>> VoiceLoudness.model_validate(-20).target_lufs  # a bare number is the target
-20.0
model_config: ClassVar[ConfigDict] = {'extra': 'allow', 'populate_by_name': True}#

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

peak_db: float#

The highest a sample may reach after the gain, dBFS.

target_lufs: float#

speech for the web; -23: EBU R128 broadcast).

Type:

The level every voice is brought to, LUFS (-16

class an.ir.schema.WordTimingIR(**data)[source]#

Bases: _IRModel

One word of a line and when it was spoken, in seconds from the line’s start (like VisemeKeyframe, never absolute). Stamped by the audio pipeline from the provider’s word timings when it has them (an#96); JSON only — scene.md never carries it, the way it never carries visemes.

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.ir.schema.resolve_step_hz(shot, scene_step_hz)[source]#

The stepped-timing policy shot renders under: its own step_hz when it declares one, else the scene’s, else None (smooth). The ONE statement of the shot-over-scene rule — the cutout renderer, the preview and the project renderer all call it (an#89 review: three copies).

Return type:

float | None

>>> resolve_step_hz(Shot(id="s", step_hz=10.0), 15.0)
10.0
>>> resolve_step_hz(Shot(id="s"), 15.0)
15.0
>>> resolve_step_hz(Shot(id="s"), None) is None
True
an.ir.schema.stage_specimen(ref)[source]#

A short shot showing the one stage entity ref casts, on its own (an#347).

Return type:

Shot

>>> stage_specimen(AssetRef(kind="prop", id="lamp", store="props", ref="lamp")).entities[0].id
'lamp'
an.ir.schema.unregistered_action_kind(kind, *, where='')[source]#

The error for an action kind no loaded genre registered.

It names the installed genres whose declaration provides the kind, read without loading them (an.genres.providers_of()).

Return type:

Exception