an.ir.sync
Bidirectional sync between scene.md (Narrative Layer) and ir/scene.json (Scene Graph Layer).
The Markdown form is what humans edit. The JSON form is what the agent and verifiers operate on. They must round-trip cleanly.
Markdown convention (v0.1, kept simple — extended in P5):
# <title>
Optional prose intro (saved to meta.notes).
`yaml meta title: Park Bench duration: 45 fps: 30 `## Shot s1 (cutout)
Optional prose direction for this shot.
```yaml shot duration: 15 camera:
move: push_in
`dialogue charlie: Did you ever wonder why we always meet here? maya: Because the pigeons trust us. `
A shot heading is ## Shot <id> (<renderer>) — the parenthesised word names
the RENDERER, and is captured positionally, so the heading is unchanged by the
an#106 rename. Fenced blocks attach to the
nearest enclosing scope. Unknown blocks are preserved as options so
agent extensions don’t get clobbered on round-trip.
- exception an.ir.sync.SceneMarkdownError[source]
scene.md says something this build cannot read — a refusal, not a crash.
Every parse refusal in this module raises it, so the CLI can tell “the human’s file needs one edit” apart from “something broke”. That distinction is the whole point of naming it: an#106’s first pass widened the CLI’s catch to bare
ValueErrorto print these cleanly, which also swallowedjson.JSONDecodeErrorandCutoutCompileError— bothValueErrorsubclasses — and turned a failed render into exit 0.
- exception an.ir.sync.SceneValidationError(message: str, validation_error=None)[source]
A stored scene document is not a valid scene — named, with its source.
The underlying
pydantic.ValidationErroris kept as__cause__(and as.validation_error) so a caller that reports per field —an.validate_schemabuilds one Finding per error, each with its ownloc— does not have to choose between naming the document and naming the field.
- class an.ir.sync.SyncResult(wrote_json: bool = False, wrote_md: bool = False, drift_warning: str | None = None)[source]
Outcome of a sync operation.
- an.ir.sync.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.ir.sync.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.ir.sync.scene_from_json_doc(doc: dict, *, source: str | Path | None = None) SceneIR[source]
Validate a stored scene document, migrating it first (an#105).
Every path from stored bytes to a
SceneIRgoes through here — the store (read and write),sync()’s two json-wins branches, andan.validate_schema, which is a read path too because a dict or a JSON string handed to it is a stored document. A test walks the package’s AST and fails on any other one. Before it existed, migrate() was called with kind=”CharacterDescriptor” at every call site in the tree and with a scene at none of them — so a registered scene migration never ran, and because SceneIR isextra="allow", a renamed field would have landed as a silent default on every document already on disk. Registering a migration and never running it is worse than not registering one, because the registry reads as a promise.Three outcomes, three different repairs, so they get three messages:
a version this build reads (at or above
COMPATIBLE_VERSION, at or belowSCHEMA_VERSION) is taken as-is when no migration is registered for it — that is exactly whatan/base.pypromises, and a loader that demanded an exact match would refuse every stored project the day the version is bumped;a version from the future is refused as written by a newer build, because nobody will ever register a downgrade;
anything else — an old version with no path, or a malformed field — is refused naming the document.
Migration happens on read, with no write-back: the document on disk keeps its old version until something saves the scene, so a migration must stay registered for as long as any project might hold that version. That is deliberate — a loader that rewrote every file it opened would turn an validate into a mutation — but it means the registry only ever grows.
>>> scene_from_json_doc({"version": "0.1.0", "meta": {"title": "t"}}).meta.title 't' >>> scene_from_json_doc({"version": "0.0.1"}, source="ir/scene.json") ... Traceback (most recent call last): ... DocumentMigrationError
- an.ir.sync.sync(project_dir: str | Path) SyncResult[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).