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) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-27 06:09:06 -07:00
parent 6427ab4a49
commit 441645ae7a
@@ -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/<src_clip_id>__<dst_clip_id>.mp4` (double-underscore separator — clip ids themselves use single `_`, e.g. `cosmos_miri`). The zoom-out companion is `<src>__<dst>.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": <src_id>, "to": <dst_id>, "file": "transitions/<src>__<dst>.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/<clip_id>/base.mp4`.
- Produces: `_make_transition(ff, src_clip, dst_clip) -> Path` writing `transitions/<src>__<dst>.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 12 (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.