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:
@@ -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 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.
|
||||
Reference in New Issue
Block a user