an

an — AI-driven structured animation.

Public API surface (curated). See an.ir for the Scene IR, an.adapters for renderer plumbing, an.audio for TTS/lip-sync protocols, an.verify for verification, and an.stores for the project mall.

>>> import an
>>> 'SceneIR' in an.__all__
True
class an.AssetRef(*, kind: Literal['character', 'environment', 'voice', 'prop'], id: str, store: str, ref: str, overrides: dict[str, Any] | None = None, stage: StagePlacement | None = None, **extra_data: Any)[source]

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: Literal['character', 'environment', 'voice', 'prop']

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).

Type:

"style" was retired in an#106

model_config = {'extra': 'allow', 'populate_by_name': True, 'validate_by_alias': True, 'validate_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.

class an.Camera(*, move: str | None = None, keys: list[CameraKey] | None = None, **extra_data: Any)[source]

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 = {'extra': 'allow', 'populate_by_name': True, 'validate_by_alias': True, 'validate_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.adapters.cutout.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.Dialogue(*, speaker: str, text: str, voice_ref: str | None = None, start: float | None = None, duration: float | None = None, emotion: str | None = None, viseme_track: VisemeTrack | None = None, word_timings: list[WordTimingIR] | None = None, audio_ref: str | None = None, viseme_ref: str | None = None, **extra_data: Any)[source]

One line of spoken dialogue.

timing is None until the audio pipeline runs (TTS gives us a real duration); the orchestrator fills it in then.

model_config = {'extra': 'allow', 'populate_by_name': True, 'validate_by_alias': True, 'validate_by_name': True}

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

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.

class an.FlatAction(start: float, end: float, action: Annotated[SetAction | TweenAction | PlayAction | ExpressionAction | SequenceAction | ParallelAction | DelayAction | LoopAction, FieldInfo(annotation=NoneType, required=True, discriminator='kind')])[source]

A leaf action with its absolute start and end times.

The flat-form list is the canonical representation passed to renderers and verifiers. Composition nodes (sequence/parallel/delay/loop) do not appear in the flat form — they’re collapsed into time offsets.

class an.Meta(*, title: str = '', author: str = '', duration: float = 0.0, fps: int = 30, resolution: Resolution = <factory>, default_renderer: Literal['cutout', 'manim', 'motion_graphics', 'whiteboard']='cutout', notes: str = '', step_hz: Annotated[float | None, ~annotated_types.Gt(gt=0)] = None, style_pack: str | None = None, **extra_data: Any)[source]

Scene metadata.

model_config = {'extra': 'allow', 'populate_by_name': True, 'validate_by_alias': True, 'validate_by_name': True}

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

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. It does NOT recolour SVG art — that would need role tagging the descriptor does not have, and inferring a role from a pixel is what produced an#99’s wrong-tone lid. A rig whose art a pack cannot reach is WARNED about by name at compile.

class an.Project(root: Path, mall: Mapping[str, MutableMapping], scene: SceneIR)[source]

A loaded an project: directory + mall + current scene.

class an.Resolution(*, width: int = 1920, height: int = 1080, **extra_data: Any)[source]

Pixel dimensions of the rendered output.

model_config = {'extra': 'allow', 'populate_by_name': True, 'validate_by_alias': True, 'validate_by_name': True}

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

class an.SceneIR(*, version: str = '0.3.0', compatible_version: str = '0.3.0', kind: Literal['SceneIR'] = 'SceneIR', meta: Meta = <factory>, assets: list[AssetRef] = <factory>, timeline: list[Shot] = <factory>, **extra_data: Any)[source]

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 = {'extra': 'allow', 'populate_by_name': True, 'validate_by_alias': True, 'validate_by_name': True}

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

class an.Shot(*, id: str, renderer: Literal['cutout', 'manim', 'motion_graphics', 'whiteboard']='cutout', duration: float = 5.0, camera: Camera | None = None, entities: list[AssetRef] = <factory>, actions: list[~typing.Annotated[~an.ir.schema.SetAction | ~an.ir.schema.TweenAction | ~an.ir.schema.PlayAction | ~an.ir.schema.ExpressionAction | ~an.ir.schema.SequenceAction | ~an.ir.schema.ParallelAction | ~an.ir.schema.DelayAction | ~an.ir.schema.LoopAction, FieldInfo(annotation=NoneType, required=True, discriminator='kind')]] = <factory>, dialogue: list[Dialogue] = <factory>, narration: list[Narration] = <factory>, options: dict[str, ~typing.Any]=<factory>, step_hz: Annotated[float | None, ~annotated_types.Gt(gt=0)] = None, **extra_data: Any)[source]

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 = {'extra': 'allow', 'populate_by_name': True, 'validate_by_alias': True, 'validate_by_name': True}

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

renderer: RendererName

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.

step_hz: float | None

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

an.build_project_mall(project_dir: str | Path, *, ensure: bool = False, **overrides: MutableMapping) dict[str, MutableMapping][source]

Build the standard project mall over project_dir.

Pass ensure=True to create the per-store directories on disk if they don’t exist. Pass keyword overrides to swap in alternate stores (e.g. an in-memory dict for tests).

an.check_requirements() dict[str, dict][source]

Return a per-tool status dict.

The CLI subcommand an check pretty-prints this. Programmatic callers can inspect the dict directly.

an.delay(duration: float) DelayAction[source]

An empty span that consumes time. Useful inside sequence.

an.flatten(action: Annotated[SetAction | TweenAction | PlayAction | ExpressionAction | SequenceAction | ParallelAction | DelayAction | LoopAction, FieldInfo(annotation=NoneType, required=True, discriminator='kind')], *, start: float = 0.0) list[FlatAction][source]

Walk a composition tree, emitting leaf actions with absolute times.

Delays are absorbed into the timeline (they don’t appear in the output). Loops are unrolled by simple repetition — appropriate at v0.1; the cutout runtime can re-roll for efficiency later.

an.init(project_dir: str | Path, *, name: str | None = None, force: bool = False) Path[source]

Create a fresh an project at project_dir.

Idempotent unless the directory already contains a non-empty scene.md; pass force=True to overwrite. Returns the absolute project root.

an.ir_to_markdown(scene: SceneIR) str[source]

Render a SceneIR back into the structured Markdown form.

>>> from an.ir.schema import SceneIR, Meta, Shot
>>> scene = SceneIR(meta=Meta(title="Demo", duration=5.0),
...                 timeline=[Shot(id="s1", renderer="cutout", duration=5.0)])
>>> md = ir_to_markdown(scene)
>>> "# Demo" in md
True
>>> "## Shot s1 (cutout)" in md
True
an.load(project_dir: str | Path) Project[source]

Load an existing project. Reconciles scene.md / ir/scene.json first.

an.loop(action: Annotated[SetAction | TweenAction | PlayAction | ExpressionAction | SequenceAction | ParallelAction | DelayAction | LoopAction, FieldInfo(annotation=NoneType, required=True, discriminator='kind')], count: int) LoopAction[source]

Repeat action count times.

an.markdown_to_ir(md_text: str) SceneIR[source]

Parse the structured Markdown form of a scene into a SceneIR.

>>> md = '''# Demo
...
... ```yaml meta
... title: Demo
... duration: 5
... ```
...
... ## Shot s1 (cutout)
...
... ```yaml shot
... duration: 5
... ```
...
... ```dialogue
... charlie: hi
... ```
... '''
>>> scene = markdown_to_ir(md)
>>> scene.meta.title
'Demo'
>>> scene.timeline[0].id
's1'
>>> scene.timeline[0].dialogue[0].text
'hi'
an.parallel(*actions: Annotated[SetAction | TweenAction | PlayAction | ExpressionAction | SequenceAction | ParallelAction | DelayAction | LoopAction, FieldInfo(annotation=NoneType, required=True, discriminator='kind')]) ParallelAction[source]

Run all children at once. Total duration = max of child durations.

an.play(target: str, animation: str, *, duration: float | None = None, speed: float = 1.0, loop: bool | None = None) PlayAction[source]

Play a named animation of the target entity’s descriptor (an#7).

duration=None fills the animation’s natural length — or the shot’s remainder for a looping one — but counts as zero in a sequence, so a sibling placed after it starts at the same instant:

>>> [f.start for f in flatten(sequence(play("a", "idle_breath"), delay(1.0), play("a", "blink")))]
[0.0, 1.0]
>>> [f.start for f in flatten(sequence(play("a", "idle_breath", duration=2.0), play("a", "blink")))]
[0.0, 2.0]
an.save(project: Project) None[source]

Persist a Project’s current scene back to disk (md + json).

an.sequence(*actions: Annotated[SetAction | TweenAction | PlayAction | ExpressionAction | SequenceAction | ParallelAction | DelayAction | LoopAction, FieldInfo(annotation=NoneType, required=True, discriminator='kind')]) SequenceAction[source]

Run children one after the other. Total duration = sum of child durations.

an.set_(target: str, property: str, value: Any, *, at: float = 0.0) SetAction[source]

Discrete property set at time at (relative to its enclosing scope).

an.tween(target: str, property: str, to: Any, duration: float, *, from_: Any | None = None, easing: str | tuple[float, float, float, float] | list[float] | None = 'ease_in_out') TweenAction[source]

Animate a property from from_ (or its current value) to to.

an.validate_schema(doc: Any) ValidationReport[source]

Validate that doc (dict, JSON string, or SceneIR) conforms to the schema.

>>> validate_schema({"meta": {"title": "x"}, "timeline": []}).passed
True
>>> r = validate_schema({"meta": {"title": "x"}, "timeline": [{"id": "s", "duration": "not-a-number"}]})
>>> r.passed
False
an.validate_semantic(scene: SceneIR, *, available_voices: Mapping[str, Any] | None = None, available_characters: Mapping[str, Any] | None = None, available_props: Mapping[str, Any] | None = None, available_environments: Mapping[str, Any] | None = None) ValidationReport[source]

Cross-field semantic checks. Pass live stores in for cross-store checks.

Both available_voices and available_characters accept any mapping; available_props is the same thing for kind=”prop” entities (an#108), and available_environments lets the flat-pan warning read a stage’s planes (an#111) — without it a prop’s swaps are reported as having no descriptor, which is validate refusing what compile accepts. Voices are consulted via __contains__ only; characters additionally via __getitem__ (the swap-reference and play checks read descriptor dicts, an#87 / an#7). Pass None to skip those checks — and know that skipping them is what it sounds like: a play or a swap the compiler will refuse passes silently without the store (the CLI, an validate, always passes it).