"""The base-clip + variant + annotation manifest the simulator renders. Replaces simulator/fixtures.py (the selection-era synthetic catalog). Each base clip carries: the raw base file, a map of pre-baked Right-strength variant files (strength 0 is always the raw base), an authored Left annotation track (box + label key + the minimum Left level at which it appears), and per-language string tables. See the reconciled-simulator-alteration-slice design §3.2. """ from __future__ import annotations import json from dataclasses import dataclass from pathlib import Path from typing import Any from player.ring import RingMove, Scale, ScaleRing, Transition, scale_at @dataclass(frozen=True) class Clip: id: str title: str base_file: str license: str source: str right_variants: dict # {"1": {"file": ...}, "4": {...}} (no "0") annotations: list # [{"key", "box":[x,y,w,h], "min_level"}, ...] strings: dict # {"en": {key: text}} def variant_file(self, strength: int) -> str: """The video file for a Right strength; 0 and any unauthored strength fall back to the raw base file.""" entry = self.right_variants.get(str(strength)) return entry["file"] if entry else self.base_file def to_dict(self) -> dict: variants = {"0": {"file": self.base_file, "raw": True}} for k, v in self.right_variants.items(): variants[k] = v return { "id": self.id, "title": self.title, "base_file": self.base_file, "license": self.license, "source": self.source, "right_variants": variants, "annotations": self.annotations, "strings": self.strings, } def _clip_from_dict(d: dict[str, Any]) -> Clip: return Clip( id=d["id"], title=d["title"], base_file=d["base_file"], license=d.get("license", ""), source=d.get("source", ""), right_variants=d.get("right_variants", {}), annotations=d.get("annotations", []), strings=d.get("strings", {}), ) def load_manifest(path: str | Path) -> list[Clip]: """Load the base-clip manifest. Raises FileNotFoundError if missing.""" path = Path(path) data = json.loads(path.read_text()) return [_clip_from_dict(c) for c in data["clips"]] def load_ring(path: str | Path) -> ScaleRing | None: """Load the optional `ring` section as a `ScaleRing`, or None if absent. The ring closes the scales-of-nature library into the navigable loop the endless encoder walks (scales design §3); the navigation math itself lives in player.ring. """ path = Path(path) data = json.loads(path.read_text()) ring = data.get("ring") if not ring: return None scales = tuple( Scale(id=s["id"], clip_id=s["clip_id"]) for s in ring.get("scales", []) ) transitions = tuple( Transition(file=t["file"], model=t.get("model", "")) for t in ring.get("transitions", []) ) return ScaleRing(scales=scales, transitions=transitions) def ring_to_dict(ring: ScaleRing, clips: list[Clip]) -> dict: """JSON form of the ring for the API: ordered scales (with their clip title) and the per-edge transitions.""" titles = {c.id: c.title for c in clips} return { "scales": [ {"id": s.id, "clip_id": s.clip_id, "title": titles.get(s.clip_id, s.id)} for s in ring.scales ], "transitions": [{"file": t.file, "model": t.model} for t in ring.transitions], } def ring_move_to_dict(move: RingMove, ring: ScaleRing) -> dict: """JSON form of an encoder move: the landing scale's clip and the ordered transition clips to play (with direction). `fast` flags a collapsed fast-spin pass; the single step then carries `blended` so the renderer plays it quick.""" return { "from_index": move.from_index, "to_index": move.to_index, "wrapped": move.wrapped, "fast": move.fast, "target_clip_id": scale_at(ring, move.to_index).clip_id, "steps": [ { "edge": st.edge, "reversed": st.reversed, "file": st.file, "to_index": st.to_index, "blended": st.blended, } for st in move.steps ], }