7855718f74
Tasks 3-6. Transition is now a directed clip-pair morph; ScaleRing.morph_for looks up (from,to)->file. advance_ring returns structural steps (no file); resolve_move picks each crossed altitude's clip THEN its morph, chained, and reports the locked target. Fast spin keeps the whole chain (blended) instead of collapsing. /api/ring/advance takes from_clip_id, draws picks, returns the resolved chain. Dropped ring_move_to_dict + _rev_file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
167 lines
5.9 KiB
Python
167 lines
5.9 KiB
Python
"""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
|
|
],
|
|
}
|