From 441645ae7ad358a6d7ee112991d2042703f41201 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Sat, 27 Jun 2026 06:09:06 -0700 Subject: [PATCH] plan(0024): altitude clip-pair morph + lock per altitude Choose the destination clip before transitioning, play the matching member->member morph (154 clips, all adjacent video pairs, both directions), chain through each crossed altitude, and lock the clip until the altitude is changed (no secondary swap, no re-roll). Session 0024 (writing-plans). Stops at the review gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-06-27-altitude-clip-pair-morph-lock.md | 904 ++++++++++++++++++ 1 file changed, 904 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-27-altitude-clip-pair-morph-lock.md diff --git a/docs/superpowers/plans/2026-06-27-altitude-clip-pair-morph-lock.md b/docs/superpowers/plans/2026-06-27-altitude-clip-pair-morph-lock.md new file mode 100644 index 0000000..6b3bd2a --- /dev/null +++ b/docs/superpowers/plans/2026-06-27-altitude-clip-pair-morph-lock.md @@ -0,0 +1,904 @@ +# Altitude Clip-Pair Morph + Lock Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When the viewer changes altitude, choose the destination clip *first*, play the morph footage that goes from the currently-shown clip to that chosen clip, then lock the clip in place so it never changes again until the altitude changes. + +**Architecture:** Today the ring carries **5** per-edge morphs (each `scale_a → scale_b`, primary→primary), the server picks a random pool member only for the *landed* scale, and `advance()` plays the edge morph (which ends on the scale's *primary*) then does a *second*, jarring `fadeThroughBlack` swap to the randomly-chosen member. We replace the per-edge model with a **per-clip-pair** model: every pair of clips in adjacent altitudes gets its own morph, in both directions (**154** clips total). The server picks the destination member(s) *before* selecting the morph, threads the currently-shown clip in as the source, and chains morphs through each crossed altitude on a multi-detent jump. The renderer plays those exact morphs and locks on the final clip — no secondary swap, no re-roll while parked. + +**Tech Stack:** Python 3.13 / FastAPI (`simulator/app.py`), pure ring logic (`player/ring.py`), pure manifest/schema (`simulator/clips.py`), media baking via ffmpeg `xfade=zoomin` (`simulator/build_pool_manifest.py`), vanilla JS renderer (`simulator/static/app.js`), pytest, and a new Playwright E2E tier. + +## Global Constraints + +- **Morph file naming:** `transitions/__.mp4` (double-underscore separator — clip ids themselves use single `_`, e.g. `cosmos_miri`). The zoom-out companion is `__.rev.mp4` and represents the **reverse** direction (`dst → src`). +- **Direction convention:** a morph is *baked* descending (higher altitude → lower altitude = zoom IN). The `.rev.mp4` companion is the ascending (zoom OUT) playback. The manifest lists **both** directions as explicit, separately-keyed entries so the renderer needs no reverse logic at play time. +- **Ring order (descending, wraps):** `["cosmos", "orbit", "coast", "reef", "abyss"]`; `abyss → cosmos` is the wrap edge. +- **Pool sizes (current):** cosmos 5, orbit 3, coast 4, reef 5, abyss 3 → **77** adjacent ordered-pair morphs descending + **77** reverse = **154** total transition clips. +- **Lock invariant:** the displayed base clip changes **only** inside `advance()` (a deliberate altitude change) and the one initial `landScale()`. Nothing else may reload base media. The Dev-Mode re-roll button stays as an explicit dev affordance. +- **Randomness stays injected:** `player/ring.py` is a pure module; the impure `random.random()` draw happens once at the API boundary (`simulator/app.py`). Pure functions take injected `float` picks in `[0,1)`. +- **Back-compat:** a manifest with no `pool` (pool of one) and the synthesized single-clip fallback ring must still work. A pool-of-one scale needs no morphs to itself. + +--- + +### Task 1: Manifest emits clip-pair transition entries + +Re-key the manifest builder from 5 per-edge entries to 154 per-clip-pair entries (both directions), each carrying `from`/`to` clip ids. Pure dict assembly — no ffmpeg. + +**Files:** +- Modify: `simulator/build_pool_manifest.py:374-379` (`build_manifest()` transitions block) +- Test: `tests/test_pipeline_manifest.py` + +**Interfaces:** +- Consumes: module constants `RING_ORDER: list[str]`, `POOLS: dict[str, list[str]]`. +- Produces: each transition dict shaped `{"from": , "to": , "file": "transitions/__.mp4", "model": "placeholder-zoom"}`. Forward entries are descending (`H→L`, `transitions/H__L.mp4`); reverse entries are ascending (`L→H`, `transitions/H__L.rev.mp4`). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_pipeline_manifest.py (add) +def test_transitions_are_per_clip_pair_both_directions(): + import simulator.build_pool_manifest as b + m = b.build_manifest() + trans = m["ring"]["transitions"] + order = b.RING_ORDER + n = len(order) + # Every adjacent (higher H, lower L) edge, every member pair, BOTH directions. + expected = set() + for i in range(n): + H, L = order[i], order[(i + 1) % n] + for h in b.POOLS[H]: + for l in b.POOLS[L]: + expected.add((h, l, f"transitions/{h}__{l}.mp4")) # zoom in + expected.add((l, h, f"transitions/{h}__{l}.rev.mp4")) # zoom out + got = {(t["from"], t["to"], t["file"]) for t in trans} + assert got == expected + assert len(trans) == len(expected) == 154 + assert all(t["model"] == "placeholder-zoom" for t in trans) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_pipeline_manifest.py::test_transitions_are_per_clip_pair_both_directions -v` +Expected: FAIL — current builder emits 5 `scale-scale` entries with no `from`/`to`. + +- [ ] **Step 3: Implement the per-clip-pair transitions block** + +```python +# simulator/build_pool_manifest.py — replace the transitions loop in build_manifest() + transitions = [] + n = len(RING_ORDER) + for i in range(n): + H, L = RING_ORDER[i], RING_ORDER[(i + 1) % n] # H higher altitude, L lower + for h in POOLS[H]: + for l in POOLS[L]: + fwd = f"transitions/{h}__{l}.mp4" # zoom IN (descend H->L) + transitions.append({"from": h, "to": l, "file": fwd, + "model": "placeholder-zoom"}) + transitions.append({"from": l, "to": h, "file": f"transitions/{h}__{l}.rev.mp4", + "model": "placeholder-zoom"}) # zoom OUT (ascend L->H) + return {"clips": clips, "ring": {"scales": scales, "transitions": transitions}} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_pipeline_manifest.py::test_transitions_are_per_clip_pair_both_directions -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add simulator/build_pool_manifest.py tests/test_pipeline_manifest.py +git commit -m "feat(pipeline): manifest emits per-clip-pair morph transitions (both directions)" +``` + +--- + +### Task 2: Bake the member-pair morph clips + +Generalize the ffmpeg morph recipe from scale-primary→scale-primary to any member→member, and have `generate_media()` bake every adjacent member pair forward + reverse. Tested with a stubbed ffmpeg runner so the test is fast and deterministic (the real bake runs as a media step at execution time). + +**Files:** +- Modify: `simulator/build_pool_manifest.py:391-431` (`_make_transition`, `generate_media`) +- Test: `tests/test_pipeline_manifest.py` + +**Interfaces:** +- Consumes: `RING_ORDER`, `POOLS`, `MEDIA` (Path), member base footage at `MEDIA//base.mp4`. +- Produces: `_make_transition(ff, src_clip, dst_clip) -> Path` writing `transitions/__.mp4` from the two members' `base.mp4`; `_make_reverse(ff, forward)` unchanged (writes `.rev.mp4`). `generate_media()` loops every adjacent ordered member pair. + +- [ ] **Step 1: Write the failing test (stubbed ffmpeg)** + +```python +# tests/test_pipeline_manifest.py (add) +def test_generate_media_bakes_every_member_pair(monkeypatch, tmp_path): + import simulator.build_pool_manifest as b + calls = [] + def fake_run(args, **kw): + # record the output path (last positional arg of each ffmpeg call) + calls.append(args[-1]) + from pathlib import Path + Path(args[-1]).parent.mkdir(parents=True, exist_ok=True) + Path(args[-1]).write_bytes(b"x") + class R: returncode = 0 + return R() + monkeypatch.setattr(b, "MEDIA", tmp_path) + monkeypatch.setattr(b.subprocess, "run", fake_run) + monkeypatch.setattr(b, "_ffmpeg", lambda: "ffmpeg") + b.generate_media() + outs = {str(p).split("transitions/")[-1] for p in calls} + # one forward + one reverse per adjacent member pair + fwd = {f"{h}__{l}.mp4" for H, L in b._adjacent_edges() + for h in b.POOLS[H] for l in b.POOLS[L]} + rev = {f"{h}__{l}.rev.mp4" for H, L in b._adjacent_edges() + for h in b.POOLS[H] for l in b.POOLS[L]} + assert fwd <= outs and rev <= outs + assert len(fwd) + len(rev) == 154 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_pipeline_manifest.py::test_generate_media_bakes_every_member_pair -v` +Expected: FAIL — `_adjacent_edges` undefined and `generate_media` loops scales, not members. + +- [ ] **Step 3: Implement member-pair baking** + +```python +# simulator/build_pool_manifest.py + +def _adjacent_edges() -> list[tuple[str, str]]: + """Ordered (higher, lower) altitude edges around the ring, including the wrap.""" + n = len(RING_ORDER) + return [(RING_ORDER[i], RING_ORDER[(i + 1) % n]) for i in range(n)] + + +def _make_transition(ff: str, src_clip: str, dst_clip: str) -> Path: + """A zoom/warp morph between two CLIP MEMBERS' base footage (the well-liked + orbit-coast recipe), keyed by clip id so every adjacent member pair gets its + own morph. Forward = zoom IN (descend src->dst).""" + a = MEDIA / src_clip / "base.mp4" + b = MEDIA / dst_clip / "base.mp4" + out = MEDIA / "transitions" / f"{src_clip}__{dst_clip}.mp4" + out.parent.mkdir(parents=True, exist_ok=True) + norm = "trim=0:3,setpts=PTS-STARTPTS,scale=1280:720,fps=25,setsar=1,format=yuv420p" + subprocess.run([ + ff, "-y", "-i", str(a), "-i", str(b), "-filter_complex", + f"[0:v]{norm}[a];[1:v]{norm}[b];" + "[a][b]xfade=transition=zoomin:duration=1.5:offset=0.75,format=yuv420p[v]", + "-map", "[v]", "-an", str(out), + ], check=True, capture_output=True) + return out + + +def generate_media() -> None: + """Bake EVERY adjacent member-pair morph from real bases — forward (zoom in, + descend) plus its reversed companion (zoom out, ascend).""" + ff = _ffmpeg() + for H, L in _adjacent_edges(): + for h in POOLS[H]: + for l in POOLS[L]: + fwd = _make_transition(ff, h, l) + _make_reverse(ff, fwd) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_pipeline_manifest.py::test_generate_media_bakes_every_member_pair -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add simulator/build_pool_manifest.py tests/test_pipeline_manifest.py +git commit -m "feat(pipeline): bake member-pair zoom morphs for every adjacent video combination" +``` + +--- + +### Task 3: Clip-pair transition schema + morph lookup + +Change the `Transition` dataclass and ring loader to carry `from_clip`/`to_clip`, build a `(from, to) → file` lookup on the ring, and update the structural invariant. Touches both `player/ring.py` (dataclass + ring) and `simulator/clips.py` (parse + serialize). + +**Files:** +- Modify: `player/ring.py:75-113` (`Transition`, `ScaleRing.__post_init__`, add `morph_for`) +- Modify: `simulator/clips.py:88-131` (`load_ring` transition parse, `ring_to_dict` transitions block) +- Test: `tests/test_player_ring.py`, `tests/test_clips.py` + +**Interfaces:** +- Produces: `Transition(from_clip: str, to_clip: str, file: str, model: str = "")`. +- Produces: `ScaleRing.morph_for(from_clip: str, to_clip: str) -> str | None` — the morph file for that directed pair, or `None` if absent. +- `ScaleRing.__post_init__`: drop the "N edges == N transitions" rule (no longer per-edge); keep "≥1 scale, single-scale ring has no transitions". Build the lookup dict in `__post_init__` (store on a private attr via `object.__setattr__`, since the dataclass is frozen). +- `ring_to_dict` transitions become `[{"from","to","file","model"}, ...]`. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_player_ring.py (add) +from player.ring import Scale, Transition, ScaleRing + +def _two_member_ring(): + scales = (Scale(id="cosmos", clip_id="c1", pool=("c1", "c2")), + Scale(id="abyss", clip_id="a1", pool=("a1",))) + # adjacency wraps: cosmos<->abyss; members c1,c2 x a1 -> 2 pairs x2 dirs = 4 + trans = (Transition("c1", "a1", "transitions/c1__a1.mp4"), + Transition("a1", "c1", "transitions/c1__a1.rev.mp4"), + Transition("c2", "a1", "transitions/c2__a1.mp4"), + Transition("a1", "c2", "transitions/c2__a1.rev.mp4")) + return ScaleRing(scales=scales, transitions=trans) + +def test_morph_for_returns_directed_file(): + r = _two_member_ring() + assert r.morph_for("c1", "a1") == "transitions/c1__a1.mp4" + assert r.morph_for("a1", "c2") == "transitions/c2__a1.rev.mp4" + assert r.morph_for("c1", "c2") is None # not an adjacent-altitude pair +``` + +```python +# tests/test_clips.py (add) +def test_load_ring_parses_clip_pair_transitions(tmp_path): + import json + from simulator.clips import load_ring + m = {"ring": {"scales": [{"id": "x", "clip_id": "x1", "pool": ["x1"]}, + {"id": "y", "clip_id": "y1", "pool": ["y1"]}], + "transitions": [{"from": "x1", "to": "y1", "file": "transitions/x1__y1.mp4"}, + {"from": "y1", "to": "x1", "file": "transitions/x1__y1.rev.mp4"}]}} + p = tmp_path / "manifest.json"; p.write_text(json.dumps(m)) + ring = load_ring(p) + assert ring.morph_for("x1", "y1") == "transitions/x1__y1.mp4" + assert ring.morph_for("y1", "x1") == "transitions/x1__y1.rev.mp4" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_player_ring.py::test_morph_for_returns_directed_file tests/test_clips.py::test_load_ring_parses_clip_pair_transitions -v` +Expected: FAIL — `Transition` has no `from_clip`/`to_clip`; no `morph_for`. + +- [ ] **Step 3: Implement the schema + lookup** + +```python +# player/ring.py — replace the Transition dataclass +@dataclass(frozen=True) +class Transition: + """A pre-baked zoom/warp morph between TWO specific clips in adjacent + altitudes. `file` plays as-is (the manifest holds both directions as + separate, directed entries — forward zoom-in and `.rev` zoom-out).""" + from_clip: str + to_clip: str + file: str + model: str = "" +``` + +```python +# player/ring.py — ScaleRing: relax the invariant, build the lookup +@dataclass(frozen=True) +class ScaleRing: + scales: tuple[Scale, ...] + transitions: tuple[Transition, ...] + + def __post_init__(self) -> None: + if len(self.scales) == 0: + raise RingError("a ScaleRing needs at least one scale") + lookup = {(t.from_clip, t.to_clip): t.file for t in self.transitions} + object.__setattr__(self, "_morphs", lookup) + + def __len__(self) -> int: + return len(self.scales) + + def morph_for(self, from_clip: str, to_clip: str) -> str | None: + """The morph file for the directed clip pair, or None if none baked.""" + return self._morphs.get((from_clip, to_clip)) +``` + +```python +# simulator/clips.py — load_ring transition parse (replace lines 101-104) + 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", []) + ) +``` + +```python +# simulator/clips.py — ring_to_dict transitions block (replace line 130) + "transitions": [ + {"from": t.from_clip, "to": t.to_clip, "file": t.file, "model": t.model} + for t in ring.transitions + ], +``` + +- [ ] **Step 4: Run the tests + the existing ring/clips suites** + +Run: `pytest tests/test_player_ring.py tests/test_clips.py -v` +Expected: PASS — fix any existing test that constructed `Transition(file=..., model=...)` positionally to the new 3-arg form (`Transition(from_clip, to_clip, file)`), and any test asserting the old `N == N` invariant. + +- [ ] **Step 5: Commit** + +```bash +git add player/ring.py simulator/clips.py tests/test_player_ring.py tests/test_clips.py +git commit -m "feat(ring): clip-pair Transition schema + morph_for lookup" +``` + +--- + +### Task 4: Resolve chained member→member morphs (pick before transition) + +Add a pure function that, given a structural `RingMove`, the currently-shown clip, and one injected pick per step, walks the steps — choosing a destination member for each crossed altitude and resolving the morph from the *previous* clip to it — and reports the final locked clip. This is the heart of "choose the clip first, then play the matching morph," chained through each altitude. + +**Files:** +- Modify: `player/ring.py` (add `MorphStep`, `ResolvedMove`, `resolve_move`) +- Test: `tests/test_player_ring.py` + +**Interfaces:** +- Consumes: `RingMove` (from `advance_ring`), `pick_clip_id`, `scale_at`, `ScaleRing.morph_for`. +- Produces: + - `MorphStep(from_clip: str, to_clip: str, file: str | None, to_index: int, blended: bool)` + - `ResolvedMove(steps: tuple[MorphStep, ...], target_clip_id: str)` + - `resolve_move(ring: ScaleRing, move: RingMove, from_clip_id: str, picks: tuple[float, ...]) -> ResolvedMove` — `len(picks)` must equal `len(move.steps)`; each `picks[k]` chooses the member of the scale that step `k` lands on. `from_clip` of step 0 is `from_clip_id`; each subsequent step's `from_clip` is the prior step's `to_clip`. `target_clip_id` is the last step's `to_clip` (or `from_clip_id` for an empty move). + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_player_ring.py (add) +from player.ring import advance_ring, resolve_move + +def _chain_ring(): + # 3 scales, pools of 2/1/2; descending edges cosmos->coast->abyss + wrap abyss->cosmos + scales = (Scale("cosmos", "c1", ("c1", "c2")), + Scale("coast", "o1", ("o1",)), + Scale("abyss", "a1", ("a1", "a2"))) + t = [] + def pair(h, l): + t.append(Transition(h, l, f"transitions/{h}__{l}.mp4")) + t.append(Transition(l, h, f"transitions/{h}__{l}.rev.mp4")) + for h in ("c1", "c2"): + pair(h, "o1") # cosmos<->coast + for h in ("o1",): + pair(h, "a1"); pair(h, "a2") # coast<->abyss + for h in ("a1", "a2"): + pair(h, "c1"); pair(h, "c2") # abyss<->cosmos (wrap) + return ScaleRing(scales=scales, transitions=tuple(t)) + +def test_resolve_single_step_zoom_in(): + r = _chain_ring() + move = advance_ring(r, from_index=0, delta=1) # cosmos -> coast + res = resolve_move(r, move, from_clip_id="c2", picks=(0.0,)) + assert len(res.steps) == 1 + s = res.steps[0] + assert (s.from_clip, s.to_clip) == ("c2", "o1") + assert s.file == "transitions/c2__o1.mp4" + assert res.target_clip_id == "o1" + +def test_resolve_chains_through_intermediate_altitude(): + r = _chain_ring() + move = advance_ring(r, from_index=0, delta=2) # cosmos -> coast -> abyss + # pick coast member (only o1) then abyss member: 0.6*2 -> index 1 -> a2 + res = resolve_move(r, move, from_clip_id="c1", picks=(0.0, 0.6)) + files = [s.file for s in res.steps] + assert files == ["transitions/c1__o1.mp4", "transitions/o1__a2.mp4"] + assert res.target_clip_id == "a2" + +def test_resolve_zoom_out_uses_reverse_file(): + r = _chain_ring() + move = advance_ring(r, from_index=2, delta=-1) # abyss -> coast (ascend) + res = resolve_move(r, move, from_clip_id="a1", picks=(0.0,)) + assert res.steps[0].file == "transitions/o1__a1.rev.mp4" + assert res.target_clip_id == "o1" + +def test_resolve_empty_move_keeps_source(): + r = _chain_ring() + move = advance_ring(r, from_index=0, delta=0) + res = resolve_move(r, move, from_clip_id="c2", picks=()) + assert res.steps == () and res.target_clip_id == "c2" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_player_ring.py -k resolve -v` +Expected: FAIL — `resolve_move` undefined. + +- [ ] **Step 3: Implement `resolve_move`** + +```python +# player/ring.py (add near the bottom) + +@dataclass(frozen=True) +class MorphStep: + """One morph to play while advancing: from the clip currently shown to the + chosen clip of the next altitude, the morph file, the index it lands on, and + whether it plays fast (a fast-spin pass).""" + from_clip: str + to_clip: str + file: str | None + to_index: int + blended: bool + + +@dataclass(frozen=True) +class ResolvedMove: + steps: tuple[MorphStep, ...] + target_clip_id: str + + +def resolve_move( + ring: ScaleRing, + move: RingMove, + from_clip_id: str, + picks: tuple[float, ...], +) -> ResolvedMove: + """Choose the destination clip for each crossed altitude (injected `picks`, + one per step) and resolve the morph from the previous clip to it. The morph + is chosen AFTER the destination clip, so the footage matches what we land on. + `file` is None if no morph was baked for a pair (renderer falls back to a + plain cut); `target_clip_id` is the final locked clip.""" + if len(picks) != len(move.steps): + raise ValueError(f"need one pick per step: {len(picks)} != {len(move.steps)}") + steps: list[MorphStep] = [] + src = from_clip_id + for st, r in zip(move.steps, picks): + dst_scale = scale_at(ring, st.to_index) + dst = pick_clip_id(dst_scale, r) + steps.append(MorphStep( + from_clip=src, + to_clip=dst, + file=ring.morph_for(src, dst), + to_index=st.to_index, + blended=st.blended, + )) + src = dst + return ResolvedMove(steps=tuple(steps), target_clip_id=src) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_player_ring.py -k resolve -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add player/ring.py tests/test_player_ring.py +git commit -m "feat(ring): resolve_move picks destination clip then matching morph, chained" +``` + +--- + +### Task 5: Fast spin plays the whole chain quickly (no collapse) + +The operator chose "chain through each altitude," with fast spins playing that chain *quickly* rather than collapsing to a single arrival pass. Change `advance_ring`'s fast-spin branch to keep every step but mark each `blended=True` (so the renderer plays each morph at `FAST_BLEND_RATE`) and still set `RingMove.fast=True`. + +**Files:** +- Modify: `player/ring.py:219-241` (fast-spin branch in `advance_ring`) +- Test: `tests/test_player_ring.py` + +**Interfaces:** +- Unchanged signature. Behavior change: a fast spin returns **all** steps (each `blended=True`), not one collapsed step. + +- [ ] **Step 1: Write the failing test (and update any existing fast-spin assertions)** + +```python +# tests/test_player_ring.py (add) +def test_fast_spin_keeps_all_steps_blended(): + r = _chain_ring() # 3-scale ring from Task 4 + move = advance_ring(r, from_index=0, delta=3, fast_spin_threshold=3) + assert move.fast is True + assert len(move.steps) == 3 # NOT collapsed to 1 + assert all(s.blended for s in move.steps) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_player_ring.py::test_fast_spin_keeps_all_steps_blended -v` +Expected: FAIL — current branch collapses to a single `(blended,)` step. + +- [ ] **Step 3: Implement the chain-fast branch** + +```python +# player/ring.py — replace the fast-spin collapse block (lines ~219-237) + if fast_spin_threshold >= 2 and abs(delta) >= fast_spin_threshold: + fast_steps = tuple( + TransitionStep(edge=s.edge, reversed=s.reversed, file=s.file, + to_index=s.to_index, blended=True) + for s in steps + ) + return RingMove(from_index=start, to_index=index, steps=fast_steps, + wrapped=wrapped, fast=True) +``` + +- [ ] **Step 4: Run the full ring suite (catch the old collapse test)** + +Run: `pytest tests/test_player_ring.py -v` +Expected: PASS — update/replace any prior test that asserted fast spin yields exactly one step. + +- [ ] **Step 5: Commit** + +```bash +git add player/ring.py tests/test_player_ring.py +git commit -m "feat(ring): fast spin plays the full morph chain quickly instead of collapsing" +``` + +--- + +### Task 6: API picks before transitioning and serializes the resolved chain + +`/api/ring/advance` accepts the currently-shown clip (`from_clip_id`), draws one random pick per step, resolves the chained morphs, and returns the resolved steps + locked `target_clip_id`. Also extend `media-versions` to cover every transition file. + +**Files:** +- Modify: `simulator/app.py:119-121` (`RingAdvanceRequest`), `:237-252` (`api_ring_advance`), `:213-229` (`api_media_versions`) +- Modify: `simulator/clips.py:134-160` (`ring_move_to_dict` → resolved-move serializer) +- Test: `tests/test_simulator_api.py` + +**Interfaces:** +- `RingAdvanceRequest`: add `from_clip_id: str = ""`. +- New serializer `resolved_move_to_dict(move: RingMove, resolved: ResolvedMove) -> dict`: + ```json + {"from_index": int, "to_index": int, "wrapped": bool, "fast": bool, + "target_clip_id": str, + "steps": [{"from_clip": str, "to_clip": str, "file": str|null, + "to_index": int, "blended": bool}, ...]} + ``` +- `media-versions`: iterate `t.file` for every transition (no `.rev` derivation — both directions are explicit entries now). + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_simulator_api.py (add) +def test_advance_picks_then_returns_matching_morph(client): + # client fixture serves the sample_media manifest + r = client.post("/api/ring/advance", + json={"from_index": 0, "delta": 1, "from_clip_id": "cosmos"}) + assert r.status_code == 200 + body = r.json() + assert body["to_index"] == 1 + target = body["target_clip_id"] + step = body["steps"][-1] + assert step["from_clip"] == "cosmos" # threaded current clip + assert step["to_clip"] == target # morph lands on the locked clip + # file matches the directed pair (zoom-in descend, forward file) + assert step["file"] == f"transitions/cosmos__{target}.mp4" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_simulator_api.py::test_advance_picks_then_returns_matching_morph -v` +Expected: FAIL — response has no `from_clip`/`to_clip`, file is the old per-edge name. + +- [ ] **Step 3: Implement request field, endpoint, serializer, media-versions** + +```python +# simulator/app.py +class RingAdvanceRequest(BaseModel): + from_index: int = 0 + delta: int + from_clip_id: str = "" +``` + +```python +# simulator/app.py — api_ring_advance + @app.post("/api/ring/advance") + def api_ring_advance(req: RingAdvanceRequest): + if app.state.ring is None: + raise HTTPException(status_code=404, detail="no scale ring in manifest") + move = advance_ring(app.state.ring, req.from_index, req.delta, + fast_spin_threshold=DEFAULT_FAST_SPIN_THRESHOLD) + # Choose the destination member of each crossed altitude FIRST (one random + # draw per step), then resolve the morph from the prior clip to it. + src = req.from_clip_id or scale_at(app.state.ring, move.from_index).clip_id + picks = tuple(random.random() for _ in move.steps) + resolved = resolve_move(app.state.ring, move, src, picks) + return resolved_move_to_dict(move, resolved) +``` + +```python +# simulator/clips.py — replace ring_move_to_dict with resolved_move_to_dict +def resolved_move_to_dict(move, resolved) -> dict: + """JSON form of a resolved encoder move: where it lands, the locked clip, and + the ordered chained morphs (each from the prior clip to its chosen + destination, with the matching morph file).""" + 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 + ], + } +``` + +```python +# simulator/app.py — api_media_versions: cover every transition file explicitly + files = {c.base_file for c in app.state.clips} + if app.state.ring is not None: + for t in app.state.ring.transitions: + files.add(t.file) +``` + +Update imports in `simulator/app.py` (`resolve_move`, `scale_at` already imported; add `resolve_move`) and `simulator/clips.py` (drop `ring_move_to_dict`, add `resolved_move_to_dict`; remove the now-unused `_rev_file` if nothing else uses it — grep first). + +- [ ] **Step 4: Run the API suite** + +Run: `pytest tests/test_simulator_api.py -v` +Expected: PASS — fix any test referencing the removed `ring_move_to_dict` / old response shape. + +- [ ] **Step 5: Commit** + +```bash +git add simulator/app.py simulator/clips.py tests/test_simulator_api.py +git commit -m "feat(api): /api/ring/advance picks clip then returns matching chained morphs" +``` + +--- + +### Task 7: Renderer plays the chosen morph and locks the clip (no secondary swap) + +Rewire the frontend: send the currently-shown clip, play each resolved morph straight (the manifest already encodes direction, so drop the `reversed`/`reverseFile` logic), land + lock on `target_clip_id` with a plain (re)load, and **delete the secondary `fadeThroughBlack` swap**. Build a `(from,to)→file` lookup from the ring and preload only the morphs reachable from the current locked clip, refreshed on each landing (154 clips is too many to preload eagerly). + +**Files:** +- Modify: `simulator/static/app.js` — `advance` (620-652), `playTransition` (568-595), `onload`/ring load (33-57), `preloadOrder`/`preloadList`/`reverseFile` (104-131, 566), remove `fadeThroughBlack` (600-618) call. +- Verified by: Task 8 (E2E) + manual run. + +**Interfaces:** +- Consumes the Task 6 response: `move.steps[k] = {from_clip, to_clip, file, to_index, blended}`, `move.target_clip_id`. +- New module state: `morphByPair = {}` — `"from→to"` → file string, built from `ring.transitions`. + +- [ ] **Step 1: Build the morph lookup when the ring loads** + +```javascript +// simulator/static/app.js — in loadRing(), after `ring = ...` is set: + morphByPair = {}; + if (ring && ring.transitions) + for (const t of ring.transitions) morphByPair[`${t.from}→${t.to}`] = t.file; +``` + +Add `let morphByPair = {};` near the other module state (around line 38). The synthesized single-clip fallback ring has no transitions → empty lookup → advance is a no-op (pool of one), which is correct. + +- [ ] **Step 2: Simplify `playTransition` (no reverse logic)** + +```javascript +// simulator/static/app.js — replace playTransition +function playTransition(file, blended) { + // Play one resolved morph start-to-finish with alteration layers hidden. The + // manifest already encodes direction (forward zoom-in vs `.rev` zoom-out), so + // the file is played as-is. A fast-spin step runs at FAST_BLEND_RATE. If no + // morph exists for the pair (file null), resolve immediately (plain cut). + return new Promise((resolve) => { + if (!file) return resolve(); + overlay.style.opacity = "0"; affectLayer.style.opacity = "0"; tint.style.opacity = "0"; + vid.style.filter = "none"; vid.loop = false; vid.style.opacity = "1"; + vid.src = mediaUrl(file); + vid.playbackRate = blended ? FAST_BLEND_RATE : 1; + let done = false; + const finish = () => { + if (done) return; done = true; + vid.removeEventListener("ended", finish); vid.playbackRate = 1; resolve(); + }; + vid.addEventListener("ended", finish); + vid.play().catch(finish); + setTimeout(finish, blended ? 3000 : 6000); + }); +} +``` + +- [ ] **Step 3: Rewrite `advance` — pick first (via server), play morphs, lock, no swap** + +```javascript +// simulator/static/app.js — replace advance +async function advance(delta) { + if (busy || !ring || ring.scales.length < 2) return; + busy = true; + try { + const resp = await fetch("/api/ring/advance", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ from_index: ringIndex, delta, from_clip_id: activeClipId }), + }); + if (!resp.ok) return; + const move = await resp.json(); + for (const step of move.steps) { + await playTransition(step.file, step.blended); + } + ringIndex = move.to_index; + activeClipId = move.target_clip_id; // the locked clip — the morph ended on it + currentClipId = null; // force ensureClipMedia to (re)load + loop it + renderScaleReadout(); + } finally { + busy = false; + update(); // ensureClipMedia loads the locked clip; nothing re-rolls + applyAudio(); + refreshReachablePreload(); + } +} +``` + +Then **delete** `fadeThroughBlack` (now unused) and `reverseFile` (the `.rev` companion is an explicit manifest entry, no longer derived). Grep to confirm no other callers before deleting. + +- [ ] **Step 4: Preload only the reachable morphs, refreshed per landing** + +```javascript +// simulator/static/app.js — replace the eager transition preload in preloadOrder()/preloadList() +// Reachable morphs from the CURRENT locked clip: to/from every member of the +// neighboring altitudes (the only clips one detent can reach). 154 total is too +// many to preload up front; we focus on what the next move can need. +function reachableMorphFiles() { + const files = new Set(); + if (!ring || ring.scales.length < 2 || !activeClipId) return files; + const n = ring.scales.length; + for (const sign of [1, -1]) { + const nb = ring.scales[((ringIndex + sign) % n + n) % n]; + const members = (nb.pool || [{ clip_id: nb.clip_id }]).map((m) => m.clip_id); + for (const m of members) { + const a = morphByPair[`${activeClipId}→${m}`]; + const b = morphByPair[`${m}→${activeClipId}`]; + if (a) files.add(a); + if (b) files.add(b); + } + } + return files; +} +function refreshReachablePreload() { + // Kick a background cache fill of the reachable morphs (bounded concurrency, + // same cacheClip helper used for bases). Idempotent — cached files are skipped. + for (const f of reachableMorphFiles()) cacheClip(f); +} +``` + +In `preloadOrder()` replace the `if (ring.transitions) for (const t ...) { add(t.file); add(reverseFile(t.file)); }` block with `for (const f of reachableMorphFiles()) add(f);`. Keep base-clip preloading (current + neighbor pools) as-is. (`cacheClip` = factor out the per-file fetch→blob step already inside the preload loop so `refreshReachablePreload` can reuse it.) + +- [ ] **Step 5: Manual smoke + commit** + +Run: `cd simulator && python -m uvicorn app:app --port 8011` then open `http://localhost:8011/`, turn the Altitude dial one detent each way and several detents; confirm: a zoom morph plays, it lands on a clip, and that clip **does not change** while parked (watch the Dev-Mode clip readout). No black-flash second swap. + +```bash +git add simulator/static/app.js +git commit -m "feat(sim): play chosen morph then lock clip per altitude; drop secondary swap" +``` + +--- + +### Task 8: Playwright E2E — lock + matching morph (new tier) + +Stand up a minimal Playwright tier (none exists) and add one spec that proves the two behaviors the operator asked for: after zooming, the displayed clip is stable (locked) until the next altitude change, and the morph that plays matches `from→to`. §9 requires an E2E browser tier for this UI change. + +**Files:** +- Create: `simulator/e2e/package.json`, `simulator/e2e/playwright.config.ts`, `simulator/e2e/tests/altitude-lock.spec.ts` +- Create: `simulator/e2e/README.md` (how to run; how it boots uvicorn) + +**Interfaces:** +- The spec drives the real app (uvicorn on a fixed port via Playwright `webServer`), reads the Dev-Mode clip readout (enable Dev Mode via localStorage before load), and intercepts `/media/transitions/*` requests to assert the morph file played. + +- [ ] **Step 1: Scaffold the tier (failing because no test yet)** + +```jsonc +// simulator/e2e/package.json +{ "name": "hef-e2e", "private": true, + "scripts": { "test": "playwright test" }, + "devDependencies": { "@playwright/test": "^1.45.0" } } +``` + +```ts +// simulator/e2e/playwright.config.ts +import { defineConfig } from "@playwright/test"; +export default defineConfig({ + testDir: "./tests", + webServer: { + command: "cd .. && python -m uvicorn app:app --port 8099", + url: "http://localhost:8099/api/clips", + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, + use: { baseURL: "http://localhost:8099" }, +}); +``` + +- [ ] **Step 2: Write the spec** + +```ts +// simulator/e2e/tests/altitude-lock.spec.ts +import { test, expect } from "@playwright/test"; + +test("zoom plays a morph, lands locked, and does not change while parked", async ({ page }) => { + const morphs: string[] = []; + page.on("request", (r) => { + const m = r.url().match(/\/media\/(transitions\/[^?]+)/); + if (m) morphs.push(m[1]); + }); + await page.addInitScript(() => localStorage.setItem("hef.devMode", "1")); + await page.goto("/"); + const readout = page.locator("#scale-name"); + await expect(readout).toContainText("cosmos"); + + // Zoom inward one altitude (wheel down = +). A morph must play. + await page.locator("#stage").dispatchEvent("wheel", { deltaY: 120 }); + await page.waitForTimeout(2500); + expect(morphs.some((f) => f.startsWith("transitions/cosmos__"))).toBeTruthy(); + + // The landed clip is now locked: capture it, wait through several alteration + // ticks, and assert it never changes. + const landed = (await readout.textContent())!; + await page.waitForTimeout(2000); + expect(await readout.textContent()).toBe(landed); +}); +``` + +- [ ] **Step 3: Install + run** + +Run: `cd simulator/e2e && npm install && npx playwright install chromium && npm test` +Expected: PASS (1 test). If the wheel handler needs a different target, adjust the selector to the element bound in `onWheel` setup. + +- [ ] **Step 4: Document the tier** + +`simulator/e2e/README.md`: prerequisites (`npm install`, `npx playwright install chromium`), how `webServer` boots uvicorn, and `npm test`. + +- [ ] **Step 5: Commit** + +```bash +git add simulator/e2e +git commit -m "test(e2e): Playwright tier asserts altitude lock + matching morph" +``` + +--- + +### Task 9: Regenerate sample media + manifest, full suite green + +Bake the 154 morphs into `simulator/sample_media`, rebuild the manifest, and run the entire pytest suite + the E2E spec so the shipped sample media matches the new schema. + +**Files:** +- Modify (generated): `simulator/sample_media/manifest.json`, `simulator/sample_media/transitions/*.mp4` + +- [ ] **Step 1: Rebuild media + manifest** + +Run: `cd simulator && python build_pool_manifest.py --media` (or the script's generate+manifest entrypoint — confirm its CLI flags) to bake morphs and rewrite `manifest.json`. +Expected: `sample_media/transitions/` contains 154 files; `manifest.json` ring has 154 transition entries. + +- [ ] **Step 2: Verify every manifest transition file exists** + +```python +# one-off check (or add as tests/test_catalog_integrity.py case) +import json, pathlib +m = json.load(open("simulator/sample_media/manifest.json")) +base = pathlib.Path("simulator/sample_media") +missing = [t["file"] for t in m["ring"]["transitions"] if not (base / t["file"]).exists()] +assert not missing, missing +``` + +- [ ] **Step 3: Run the full suite** + +Run: `pytest -q` +Expected: PASS (all existing + new tests; ~270+). + +- [ ] **Step 4: Run the E2E spec against the regenerated media** + +Run: `cd simulator/e2e && npm test` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add simulator/sample_media +git commit -m "chore(media): regenerate 154 clip-pair morphs + manifest" +``` + +--- + +## Self-Review + +**Spec coverage (operator directives):** +- "Choose which clip plays in the next altitude *before* transitioning" → Task 4 (`resolve_move` picks dst then morph) + Task 6 (server draws picks before resolving). +- "Play the appropriate morph footage" → Task 7 (renderer plays the resolved `step.file`). +- "Morph footage for all transitions between all videos in adjacent altitudes" → Tasks 1–2 (manifest + bake 154, both directions) + Task 9 (regenerate). +- "Video must not change after we've zoomed to a new altitude" / "lock per altitude until the altitude is changed" → Task 7 (delete secondary swap; lock `activeClipId`; reload only on altitude change) + Task 8 (E2E asserts stability while parked). +- "Good transition for every video combination, in and out" → both directions baked (Task 1/2), reverse via explicit `.rev` entries (Task 3/4), E2E covers in + out (extend Task 8 spec with a zoom-out case if desired). +- Multi-altitude jump chains through each altitude → Task 4 (`resolve_move` threads src→dst per step) + Task 5 (fast spin plays the chain quickly). + +**Placeholder scan:** No TBD/TODO/"handle edge cases" — every code step shows real code; tests show real assertions. + +**Type consistency:** `Transition(from_clip, to_clip, file, model)` (T3) used by `morph_for` (T3), `load_ring` (T3), `resolve_move` (T4), `resolved_move_to_dict` (T6). `MorphStep`/`ResolvedMove` fields (T4) match the serializer (T6) and the renderer's `step.{from_clip,to_clip,file,to_index,blended}` + `move.target_clip_id` (T7). `morphByPair` key `"from→to"` consistent in build (T7 S1) and lookup (T7 S4). + +**Open items flagged for the review gate:** +1. **Storage/repo weight:** 154 short mp4s in `sample_media`. They're small, but confirm we're comfortable committing them (vs. a generate-on-setup step). Current repo already commits the 5 + bases, so this extends precedent. +2. **`build_pool_manifest.py` CLI:** Task 9 Step 1 assumes a `--media` flag; confirm the actual entrypoint flags during execution. +3. **Zoom-out E2E:** Task 8 covers zoom-in + lock; a zoom-out assertion is a cheap add if wanted.