"""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 ResolvedMove, RingMove, Scale, ScaleRing, Transition @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"}, ...] affect: list # [{"key", "at":[x,y], "min_level"}, ...] (Left x Right) 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, "affect": self.affect, "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", []), affect=d.get("affect", []), 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 _scale_from_dict(s: dict[str, Any]) -> Scale: """A ring scale node, with its rotating clip pool (content-pipeline §11.1). Accepts a `pool` list (the rotating model) and/or a legacy single `clip_id`. The primary `clip_id` is the explicit one if given, else the first pool member; `pool` defaults to `(clip_id,)` for a pre-pool manifest. """ pool = tuple(s.get("pool", [])) clip_id = s.get("clip_id") or (pool[0] if pool else "") return Scale(id=s["id"], clip_id=clip_id, pool=pool, audio=s.get("audio", "")) 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_from_dict(s) for s in ring.get("scales", [])) transitions = tuple( Transition( from_clip=t.get("from", ""), to_clip=t.get("to", ""), 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. Each scale carries its full rotating `pool` (clip id + title per member) plus the primary `clip_id`/`title` for back-compat. The client lands on a member chosen at the API boundary (`/api/ring/advance` → `target_clip_id`).""" 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), "audio": s.audio, "pool": [ {"clip_id": cid, "title": titles.get(cid, cid)} for cid in s.members ], } for s in ring.scales ], "transitions": [ {"from": t.from_clip, "to": t.to_clip, "file": t.file, "model": t.model} for t in ring.transitions ], } def resolved_move_to_dict(move: RingMove, resolved: ResolvedMove) -> dict: """JSON form of a RESOLVED encoder move: where it lands, the locked clip, and the ordered chained morphs. Each step carries the clip it morphs FROM (the clip currently shown), the chosen clip it morphs TO, and the matching morph file (None if no morph was baked — the renderer plain-cuts). `fast` marks a fast spin; each step then carries `blended` so the renderer accelerates it. `target_clip_id` is the final clip the viewer locks onto.""" return { "from_index": move.from_index, "to_index": move.to_index, "wrapped": move.wrapped, "fast": move.fast, "target_clip_id": resolved.target_clip_id, "steps": [ { "from_clip": s.from_clip, "to_clip": s.to_clip, "file": s.file, "to_index": s.to_index, "blended": s.blended, } for s in resolved.steps ], }