"""Stage 6 — assemble manifest entries (spec §3.6). Pure dict transforms over a loaded manifest dict; the caller does the json read/write. Idempotent by id so re-running earlier stages never clobbers authored labels.""" from __future__ import annotations import copy def upsert_clip(manifest: dict, clip: dict) -> dict: """Replace the clip with the same id, or append it. Returns a new manifest.""" m = copy.deepcopy(manifest) clips = m.setdefault("clips", []) for i, c in enumerate(clips): if c["id"] == clip["id"]: clips[i] = clip return m clips.append(clip) return m def add_ring_scale(manifest: dict, scale_id: str, clip_id: str, after: str | None = None) -> dict: """Insert a scale into ring.scales after the named scale (or append if `after` is None / not found). No-op if scale_id already present. Returns a new manifest.""" m = copy.deepcopy(manifest) ring = m.setdefault("ring", {"scales": [], "transitions": []}) scales = ring.setdefault("scales", []) if any(s["id"] == scale_id for s in scales): return m entry = {"id": scale_id, "clip_id": clip_id} idx = next((i for i, s in enumerate(scales) if s["id"] == after), None) if idx is None: scales.append(entry) else: scales.insert(idx + 1, entry) return m def rebuild_ring_edges(manifest: dict) -> dict: """Rebuild ring.transitions to one placeholder entry per ring edge (N scales -> N edges incl. the wrap), preserving any existing model string for an edge. Returns a new manifest.""" m = copy.deepcopy(manifest) ring = m.setdefault("ring", {"scales": [], "transitions": []}) scales = ring.get("scales", []) existing = {t["file"]: t.get("model", "") for t in ring.get("transitions", [])} edges = [] n = len(scales) for i in range(n): a, b = scales[i]["id"], scales[(i + 1) % n]["id"] file = f"transitions/{a}-{b}.mp4" edges.append({"file": file, "model": existing.get(file, "placeholder-zoom")}) ring["transitions"] = edges return m