an.ir#
Scene IR — the single source of truth for a scene.
Three layers, per the architectural spec:
Narrative (
scene.md) — human Markdown with structured fenced blocks.Scene Graph (
ir/scene.json) — Pydantic-validated JSON. The SSOT.Render Code — generated per-backend, disposable.
This subpackage owns layer 2 (the Pydantic schema), the Markdown↔JSON sync that keeps layer 1 and layer 2 in lock-step, the validators (schema + semantic), the version migration registry, and the composition combinators that flatten authoring-time DSL to canonical-form actions.
Functions
|
Discrete property set at time |
|
Animate a property from |
|
Run children one after the other. |
|
Run all children at once. |
|
Start each action |
|
|
|
A copy of |
|
An empty span that consumes time. |
|
Repeat |
|
Walk a composition tree, emitting leaf actions with absolute times. |
|
Validate that |
|
Cross-field semantic checks. |
|
Migrate a document to |
|
Decorator: register a migration for one kind in |
|
Register a document kind. |
|
Resolve a document's kind, by explicit name or from its |
|
Parse the structured Markdown form of a scene into a SceneIR. |
|
Render a SceneIR back into the structured Markdown form. |
|
Reconcile |
Classes
|
Top-level Scene IR document. |
|
Scene metadata. |
|
Reference to an entry in a project store. |
|
A single rendered unit. |
|
One line of spoken dialogue. |
|
Camera state for a shot: a named move, or explicit keys. |
|
Pixel dimensions of the rendered output. |
|
A leaf action with its absolute start and end times. |
|
Result of running one or more validators. |
|
A single validation issue with a path into the IR. |
|
A schema-versioned document kind. |
- class an.ir.AssetRef(**data)[source]#
Bases:
_IRModelReference 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.
overrideslets 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
strin the schema, not aLiteral(ADR 0001 decision 2): the values are the REGISTERED entity kinds (an.genres) — the core’senvironment,propandvoice, a genre’scharacter— andan validatechecks 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:vNNNorsha256:<prefix>, neverlatest).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, sorefstill 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 unsetlibraryleaves 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
AssetRefnever reaches it. So this field can grow without retiring a single ledger row.
- class an.ir.Camera(**data)[source]#
Bases:
_CameraShakesCamera 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 namedmovedesugars to a key list;keysis 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.keysdefaults toNone, not[], and that too is load-bearing: the markdown writer dumps the camera withexclude_none=True, which KEEPS empty lists — an empty default would writekeys: []into every camera block it regenerates.position,targetandfocal_lengthwere removed in an#109. They were written into everyscene.mdthis package ever generated and read by nothing; a registered migration drops them.
- class an.ir.Dialogue(**data)[source]#
Bases:
_IRModelOne line of spoken dialogue.
startanddurationare None until the audio pipeline runs (TTS gives us a real duration); the pipeline stamps them then, derivingstartfrom the author’spause/at(planned_start()).- at: float | None#
Where this line starts, in SHOT seconds, whatever came before it —
(at 3.0)inscene.md.startis what the audio pipeline DERIVES fromat/pauseon 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}inscene.md(an#209). A TTS model that takes inline audio tags (ElevenLabs v3/v4) receives them as[excited] Hi!; others ignore them. Never part oftext, so captions and lip-sync alignment never see a cue.
- emotion_intensity: float | None#
maya [angry 0.4]: …inscene.md, a typed parameter on the (b-name) emotion.Noneis full strength, and is omitted from JSON.- Type:
How strongly
emotionshows, 0..1 (an#253)
- leveled: dict | None#
<the synthesized line’s audio_ref>, “gain_db”: <its voice’s gain>}`` when
audio_refis 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)inscene.md.
- planned_start(cursor)[source]#
Where this line starts, given the previous line ends at
cursor.The one rule the audio pipeline stamps into
startandan validatelays lines out by:atif set, elsecursor + pause. Astarton a line never synthesized (noaudio_ref) was authored — the spelling ofatbefore an#187 — and counts as one; a synthesized line’sstartis the pipeline’s own stamp, re-derived here.- Return type:
>>> 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
durationfrom the line’s audio. The next line’spausecounts from here (a line with no pause still follows the whole take).None(silent audio, a line its voice’strim_silencealready cut to the tail the author keeps, a line stamped before an#397) counts from the end ofduration, 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), soscene.jsonand its fixtures do not change.- Type:
DERIVED (an#397)
- word_timings: list[WordTimingIR] | None#
The provider’s word timings, line-relative;
Nonewhen the provider has none (offline, Rhubarb) or the line was stamped before an#96.
- class an.ir.DocumentKind(name, version_field, current_version)[source]#
Bases:
objectA schema-versioned document kind.
version_fielddiffers between kinds (versionfor the scene IR,schema_versionfor the character descriptor), which is exactly why a migrator cannot simply reach fordoc["version"].
- class an.ir.FlatAction(start, end, action)[source]#
Bases:
objectA 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.ir.Meta(**data)[source]#
Bases:
_IRModelScene metadata.
- captions: Captions | None#
Captions from the dialogue’s word timings (
Captions, an#175);None— the default — is none, omitted from JSON likestyle_pack.
- closing_transition: Transition | None#
a
fadeto itscolorover itsduration, 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 acut) 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
tweenthat 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 likestyle_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,playclips 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,15is “on twos” and10“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 andplayclips: onlytweencurves 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 (asetat an off-gridatlikewise lands where it was authored). A shot’s ownstep_hzoverrides this. Must be positive (schema) and<= fps(validate + compile), an#89.
- style_pack: str | None#
The
StylePackin the project’sstylesstore 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 byan 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.SceneIR(**data)[source]#
Bases:
_IRModelTop-level Scene IR document. The SSOT.
A document is portable, diffable, and renderer-agnostic. Persisted as JSON at
ir/scene.jsoninside 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'
- class an.ir.Shot(**data)[source]#
Bases:
_IRModelA single rendered unit. A scene is a sequence of shots.
A shot’s
rendererselects the backend that draws it. Every renderer must accept the same Shot fields; renderer-specific options go underoptions.- 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
styleuntil an#106, colliding with the styles store (which holds art direction) and withAssetRef(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. Astrin the schema (ADR 0001 decision 2): any name a renderer registered (an.adapters.register_renderer), checked byan validate.cutoutstays the persisted default (decision 9).
- step_hz: float | None#
Per-shot override of
Meta.step_hz(None= inherit).
- transition: Transition | None#
How this shot is entered (
Transition);Noneis a hard cut.
- class an.ir.ValidationFinding(severity, ir_path, description, location=None)[source]#
Bases:
objectA single validation issue with a path into the IR.
- class an.ir.ValidationReport(passed=True, findings=<factory>)[source]#
Bases:
objectResult of running one or more validators.
passedis True iff there are no error-severity findings.
- an.ir.crowd(id, *, ref, count, kind='prop', store=None, layout='grid', area=(-480.0, -60.0, 480.0, 240.0), scale=1.0, scale_jitter=0.0, jitter=0.0, seed=0)[source]#
countentities of one asset (kind/store/ref) placed inarea.id: the crowd’s name; member
kis<id>_<k>(member_id()) ref: the asset every member draws count: how many (at least 1) kind: the entity kind (prop, or a genre’s, such ascharacter) store: the store the asset lives in (default: the kind’s registered store) layout:grid(rows filled from the back),roworscatterarea:(x0, y0, x1, y1)in scene pixels about the stage centre; amember’s stage point stands inside it
scale: every member’s stage scale scale_jitter: each member’s scale varies by up to this fraction (
0.1: ±10%) jitter: each grid or row member moves by up to this fraction of its cell seed: the scatter and the jitters are drawn from it>>> [m.stage.at for m in crowd("line", ref="dot", count=3, layout="row", area=(0, 0, 300, 100))] [(50.0, 50.0), (150.0, 50.0), (250.0, 50.0)] >>> crowd("x", ref="dot", count=0) Traceback (most recent call last): ... ValueError: a crowd needs at least one member; got count=0
- an.ir.delay(duration)[source]#
An empty span that consumes time. Useful inside
sequence.- Return type:
- an.ir.fan_out(action, members, *, crowd_id=None)[source]#
A copy of
actionper member, its targets moved from the crowd to the member.- action: any action, leaf or composite; every target naming the crowd
(
army, or a node of it,army/arm_l) is rewritten
members: the crowd’s members (
AssetRefs or ids), in order crowd_id: the crowd’s name (default: read off the first member’s id,army_0->army)Pair it with
an.ir.compose.stagger()for a ripple through the ranks, orparallel()for the crowd moving as one.>>> from an.ir.compose import tween >>> [a.target for a in fan_out(tween("army/arm_l", "rotation", to=1.0, duration=0.5), ["army_0", "army_1"])] ['army_0/arm_l', 'army_1/arm_l']
- an.ir.flatten(action, *, start=0.0, play_extent=None)[source]#
Walk a composition tree, emitting leaf actions with absolute times.
A
playwithoutdurationadvances asequencebyplay_extent(defaultdefault_play_extent()): its natural length, or zero for a looping animation, which runs to the shot end.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.
Every node is dispatched through its registered kind (
ActionKind), so a genre’s kind flattens without an edit here; a node whose kind nobody registered raises, naming the genre that provides it, and anExtensionActionread before its genre loaded is validated by the registered model on the way through.- Return type:
- an.ir.ir_to_markdown(scene)[source]#
Render a SceneIR back into the structured Markdown form.
- Return type:
>>> 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
This writes the WHOLE document in the writer’s own formatting, and keeps no prose but
meta.notes. Updating an existingscene.mdgoes throughmerge_markdown(), which keeps the author’s text wherever the content did not change.
- an.ir.kind_of(doc, *, kind=None)[source]#
Resolve a document’s kind, by explicit name or from its
kindtag.Raises
ValueErrorfor an unregistered kind rather than guessing — a document whose kind nobody declared has no known version field, so any answer would be a fabrication.- Return type:
- an.ir.markdown_to_ir(md_text)[source]#
Parse the structured Markdown form of a scene into a SceneIR.
- Return type:
>>> 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.ir.migrate(doc, target_version=None, *, kind=None)[source]#
Migrate a document to
target_version(default: its kind’s current).Walks the migration registry one step at a time, considering only the migrations registered for this document’s kind. Raises
ValueErrorif no path exists between the source and target versions.>>> from an.base import SCHEMA_VERSION >>> migrate({"version": SCHEMA_VERSION, "kind": "SceneIR"})["version"] == SCHEMA_VERSION True >>> migrate({"version": "0.1.0", "kind": "SceneIR"})["version"] == SCHEMA_VERSION True
- an.ir.parallel(*actions)[source]#
Run all children at once. Total duration = max of child durations.
- Return type:
- an.ir.register_kind(kind)[source]#
Register a document kind. Returns it, so callers can bind the result.
- Return type:
- an.ir.register_migration(kind, from_version, to_version)[source]#
Decorator: register a migration for one kind in
MIGRATIONS.Registered against the throwaway
Widgetkind from the module docstring, deliberately: this registry is process-wide, so a doctest that registered a step for a REAL kind would leave a second path through the ladder for every test that ran afterwards — which is exactly what happened once, and it presented as one unrelated test failing only in a full run (an#106).- Return type:
Callable[[Callable[[dict[str,Any]],dict[str,Any]]],Callable[[dict[str,Any]],dict[str,Any]]]
>>> @register_migration("Widget", "1.0", "2.0") ... def _bump(doc): ... doc["widget_version"] = "2.0" ... return doc >>> ("Widget", "1.0", "2.0") in MIGRATIONS True
- an.ir.sequence(*actions)[source]#
Run children one after the other. Total duration = sum of child durations.
- Return type:
- an.ir.set_(target, property, value, *, at=0.0)[source]#
Discrete property set at time
at(relative to its enclosing scope).- Return type:
- an.ir.stagger(lag, *actions)[source]#
Start each action
lagseconds after the previous one STARTS.The stagger (Manim’s
LaggedStart,previz’s compose, a crowd entering one by one): the children run in parallel, thei-th delayed byi * lag. It is authoring sugar, not a new kind — it builds theparallelofsequence(delay(i * lag), action)it means, so the scene document,scene.mdand every renderer see only core kinds. Total duration: the latest child’s end.scene.mdholds it verbatim (akind: parallelentry), so it round-trips. (an.stage.text.reveal_units—an.stage.text.staggerbefore an#241 — is the text-block preset: a LIST of per-unit actions with holds, not a combinator.)- Return type:
>>> flat = flatten(stagger(0.25, tween("a", "x", to=1.0, duration=1.0), ... tween("b", "x", to=1.0, duration=1.0), ... tween("c", "x", to=1.0, duration=1.0))) >>> [(f.action.target, f.start, f.end) for f in flat] [('a', 0.0, 1.0), ('b', 0.25, 1.25), ('c', 0.5, 1.5)] >>> duration_of(stagger(0.5, delay(1.0), delay(1.0))) 1.5 >>> stagger(0.1).children [] >>> stagger(-1.0, delay(1.0)) Traceback (most recent call last): ... ValueError: stagger lag must be >= 0, got -1.0
- an.ir.sync(project_dir)[source]#
Reconcile
scene.mdandir/scene.jsoninside a project directory.Strategy in v0.1: Markdown is the human SSOT; if both exist, the JSON is regenerated from the Markdown unless mtimes show JSON is newer (which the user is told never to do — but we warn instead of silently overwriting).
- Return type:
SyncResult
- an.ir.tween(target, property, to, duration, *, from_=None, easing=INHERIT)[source]#
Animate a property from
from_(or its current value) toto.easingleft out inherits the scene’smeta.default_easing(else"ease_in_out"); naming one —"ease_in_out"included — pins it.- Return type:
>>> "easing" in tween("a", "x", to=1.0, duration=1.0).model_fields_set False >>> tween("a", "x", to=1.0, duration=1.0, easing="linear").easing 'linear'
- an.ir.validate_schema(doc)[source]#
Validate that
doc(dict, JSON string, or SceneIR) conforms to the schema.- Return type:
>>> 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.ir.validate_semantic(scene, *, available_voices=None, available_characters=None, available_props=None, available_environments=None, available_sounds=None, available_library_lock=None, available_styles=None, only=None, fps=None)[source]#
Cross-field semantic checks. Pass live stores in for cross-store checks.
Both
available_voicesandavailable_charactersaccept any mapping;available_propsis the same thing forkind="prop"entities (an#108), andavailable_environmentslets 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 andplaychecks read descriptor dicts, an#87 / an#7). PassNoneto skip those checks — and know that skipping them is what it sounds like: aplayor a swap the compiler will refuse passes silently without the store (the CLI,an validate, always passes it).available_stylesis the project’sstylesstore: a check reads the StylePack the scene names (meta.style_pack) fromctx.stores["styles"](a style’spolicy, an#348).available_library_lockis the project’s asset-library lockfile (mall["library_lock"]): with it, every scenelibrary:pin is checked against the lockfile (warningon disagreement) and every pinned check-out against its library version (infowhen it has been edited).The checks are a REGISTRY (
an.genres.registry.register_check()): the core’s own register below, a genre’s when it is loaded (the cut-out genre’splay,expression, turn and view checks), and they run in stages —scene, thenshotonce per shot, thenfinish— each by itsorder. An action or entity kind no loaded genre registered is one error naming the genre that provides it; checks that would trip over it skip that shot rather than crash.onlyruns just the registered checks of those names (whatan renderdoes after synthesis,post_synthesis_findings());fpsis the one the film is assembled at when it is not the scene’s (an render --fps), which decides how long a dissolve’s overlap is.- Return type:
Modules
Where a third-party asset came from, and what its licence obliges. |
|
Camera semantics: the named moves, and the one resolver that expands them. |
|
Composition combinators for authoring action trees, plus a flattener. |
|
Pydantic v2 models for the Scene IR. |
|
Schema and semantic validation for SceneIR documents. |