Compare commits

..

10 Commits

Author SHA1 Message Date
BenStullsBets 3b3d30e8e0 plan(0024): add zoom-out E2E case; resolve review-gate open items
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:17:42 -07:00
BenStullsBets 441645ae7a 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>
2026-06-27 06:09:06 -07:00
BenStullsBets 6427ab4a49 chore(audio): tuck audio diagnostics (status + native player) under Dev Mode
The 'no sound' debugging is resolved (stale-server resilience). Move the audio
status readout + native test player into the Dev Mode panel — kept for future
'can't hear' debugging, out of the installation UI. 7 E2E pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:58:22 -07:00
BenStullsBets 56d08d5c60 fix(video): client tolerates a stale server (old /api/alteration shape + payload)
The operator's video wouldn't turn on: their stale uvicorn (old Python) requires a
7-way 'content' and 422s the {visual,audio} payload, and returns {content:{video}}
not {render:{video:{shown}}}. So the alteration POST failed and video never updated
(audio works because it's client-driven). Fix: controls() also sends a back-compat
'content' field (current server ignores it; old one ignores visual/audio), and
update() reads video-shown from either response shape. Verified end-to-end against a
simulated fully-stale server: video shows + audio plays, zero errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:46:49 -07:00
BenStullsBets 97d2ddd573 fix(audio): client soundtrack fallback when /api/ring lacks audio (stale server)
Root cause of 'no sound', found via the live readout: the operator's /api/ring
returned the ring WITHOUT the per-scale audio field (a server process predating the
audio code) → soundtrackUrl() was null → the element never got a src (readyState 0,
no error). The native test player proved the file itself plays. Fix: client falls
back to a scale-id→soundtrack map (mirrors build_pool_manifest.SCALE_AUDIO) so audio
works without a server restart. Verified: stale-ring sim now plays; audio-first-then-
video leaves both on. +2 E2E regression tests (7 total). Chromium + WebKit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:42:12 -07:00
BenStullsBets 56e352fe65 diag(audio): status shows resolved url + ring source + scale (pinpoints null soundtrack url)
The operator's 'PAUSED readyState 0' (no error) means soundtrackUrl() returned null
— the running app's /api/ring has no audio for the scale, the signature of a STALE
uvicorn server (Python predates the audio code; static reload doesn't restart it).
Status now shows url=<…|NONE>, ring=server|SYNTH, scale id so it's unambiguous.
Fresh server verified: ▶ playing rs=4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:34:16 -07:00
BenStullsBets 2564168ac3 Merge origin/main (0022 transcript) into 0023 diagnostics 2026-06-26 13:28:37 -07:00
BenStullsBets deb7c34df9 Merge session-0023: audio diagnostics (status readout + native test player) 2026-06-26 13:28:19 -07:00
BenStullsBets 58a6ee4644 add sessions/0022/SESSION-0022.0-TRANSCRIPT-2026-06-26T08-40--2026-06-26T08-50.md + replace placeholder/variant SESSION-0022.0-TRANSCRIPT-2026-06-26T08-40--INPROGRESS.md 2026-06-26 13:20:59 -07:00
BenStullsBets fd1da791bf claim human-experience-filter-art session 0022 (placeholder) + sessions.json entry 2026-06-26 13:20:20 -07:00
6 changed files with 1079 additions and 19 deletions
@@ -0,0 +1,931 @@
# 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);
});
test("zooming back OUT plays a reverse morph and lands locked", 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, then back OUT (wheel up = -). The outward move must
// play a `.rev` morph and land back on cosmos, locked.
await page.locator("#stage").dispatchEvent("wheel", { deltaY: 120 });
await page.waitForTimeout(2500);
morphs.length = 0; // only watch the zoom-out move
await page.locator("#stage").dispatchEvent("wheel", { deltaY: -120 });
await page.waitForTimeout(2500);
expect(morphs.some((f) => /transitions\/cosmos__.*\.rev\.mp4/.test(f))).toBeTruthy();
await expect(readout).toContainText("cosmos");
// Locked after the outward move too.
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 (resolved at the review gate by the operator):**
1. **Storage/repo weight:** ✅ Commit the 154 short mp4s into `sample_media` (extends the existing precedent of committing the 5 + bases).
2. **`build_pool_manifest.py` CLI:** ✅ Approach approved; confirm the actual entrypoint flag during execution (Task 9 Step 1).
3. **Zoom-out E2E:** ✅ Added — Task 8 now covers zoom-in + lock **and** a zoom-out (`.rev` morph) + lock case.
@@ -0,0 +1,79 @@
# Session 0022.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-26T08-40 (PST)
> End: 2026-06-26T08-50 (PST)
> Type: executing-plans (operator-driven follow-up to the audio feature)
> Posture: yolo
> Claude-Session: e0cc053a-41fa-416d-a4f5-493f14984e75
> Status: FINALIZED
## Launch prompt
> (1) "It shouldn't say 'Tap to Start Sound'… it can start with both sound and video
> off but immediately go to the app. We should have a 'Loading Universe…' loading
> screen until all media has been downloaded and the experience is ready to run."
> (2) "I still can't hear the sound, even in private mode."
> (3) "The first time video is turned on, also turn on audio. If audio is turned on
> before video after the app loads, just audio should turn on."
## Pre-state
- Sessions 0020/0021 had shipped the audio feature + a 0021 "Safari fix" (tap-to-start
overlay that primed A/B `<audio>` elements). The operator reported audio STILL
silent on their real browser (even in private mode), plus the UX requests above.
## Session arc
1. **Why the 0021 fix failed.** It primed *srcless* A/B elements in the tap gesture
and played the real audio in an *async continuation* (after `await fetch`). Safari
unlocks an element only when `play()` runs **synchronously inside** the gesture and
on an element **with a src** — so neither condition was met. Headless WebKit relaxes
autoplay, which is why automated tests never caught it.
2. **Redesign (client-only, branch `session-0022`):**
- **Single `<audio id="aud">`** element (dropped A/B). It's played **synchronously
inside the toggle's own `change` handler** — the canonical Safari unlock. Altitude
changes reuse the now-unlocked element with a brief fade-swap.
- **"Loading Universe…" splash** (`#loading`, in the HTML so it shows pre-JS): `main()`
now `await`s `preloadAllMedia()` (driving a progress bar), then `hideLoading()`.
Replaces the tap-to-start wall.
- **Both toggles default OFF** (black + silent at start) — no autoplay needed until a
toggle is flipped.
- **First Video-on couples Audio:** the Video `change` handler, on the first on,
sets `#audio` checked and plays — in the same gesture (so it unlocks on Safari).
Audio toggled on first stays audio-only.
- Server contract unchanged (`{off,soundtrack}`); the client now drives playback
directly from the ring's `scale.audio`, ignoring `render.audio`.
3. **Verify + ship.** Rewrote the 5 E2E for the new flow (single `#aud`, no gesture,
loading wait, the coupling, video-off blanking). Caught a test bug — `#black.hidden`
is `display:none`, so `wait_for_selector` default (visible) timed out; switched to
`state="attached"`. **290 passed, 2 skipped; 5 E2E pass.** Verified in headless
**Chromium AND WebKit**: first-Video-on couples audio, soundtrack plays, video shows,
zero JS errors; screenshotted the splash + running app. Merged to `main`.
## Deferred decisions
_No low-confidence calls — all three were explicit operator requests. The
single-element synchronous-in-gesture play is the standard Safari remedy._
## Cut state
- `main` @ `4ac39c9`, clean, pushed. 290 tests + 5 Playwright E2E pass.
- **Honest caveat — still unverifiable headlessly:** every engine Playwright ships
(Chromium, WebKit) relaxes autoplay, so I cannot *prove* real-Safari/iOS now plays.
The fix is the canonical pattern (sync `play()` in the gesture, real src) and is a
genuine change from 0021's broken approach — but it needs the operator's real-device ear.
## Operator plate
- No tap wall: "Loading Universe…" → app with both toggles off (black/silent). Flip
**Video** → video + audio together; flip **Audio** alone → audio only.
- If sound is STILL silent on the real device, that points to something beyond autoplay
(codec/MIME, file, or element error) — next session should capture `aud.error` and the
exact browser.
## Next /goal
```
/goal confirm on a REAL device (esp. iPad/Safari) that audio is now audible when Video/Audio is toggled on and the "Loading Universe…" → app flow feels right; if still silent, capture aud.error + the exact browser. Then the deferred per-altitude music layer per docs/audio-candidate-pool.md
```
+3
View File
@@ -61,5 +61,8 @@
},
"0021": {
"title": ""
},
"0022": {
"title": ""
}
}
+37 -10
View File
@@ -506,6 +506,10 @@ function controls() {
return {
visual: $("visual").checked ? "on" : "off",
audio: $("audio").checked ? "soundtrack" : "off",
// Back-compat: a server process predating the visual/audio split still requires
// a 7-way `content` and would 422 the new payload. Send both — the current
// server ignores `content`, an old one ignores visual/audio.
content: $("visual").checked ? "video" : "off",
left: +$("left").value, right: +$("right").value,
dark: mood < 0 ? -mood : 0, light: mood > 0 ? mood : 0,
volume: 2, brightness: 2,
@@ -522,7 +526,11 @@ async function update() {
if (!resp.ok) { readout.textContent = "invalid: " + resp.status; return; }
const data = await resp.json();
readout.textContent = JSON.stringify(data, null, 2);
if (!data.render.video.shown) {
// Tolerate a stale server that still returns {content:{video}} instead of the
// {render:{video:{shown}}} (visual/audio-split) shape, so video works either way.
const videoShown = data.render ? !!(data.render.video && data.render.video.shown)
: !!(data.content && data.content.video);
if (!videoShown) {
// Blank the screen. Cover with #black AND hide the video layers themselves —
// the <video>/<canvas> are GPU-composited and can otherwise show through the
// overlay on a real GPU (Safari/Chrome).
@@ -927,20 +935,24 @@ const FADE_MS = 500;
const AUD_ERR = ["", "aborted", "network", "decode", "format-not-supported"];
function audioStatusText() {
if (!$("audio").checked) return "audio: off";
if (aud.error) return `audio: MEDIA ERROR ${aud.error.code} (${AUD_ERR[aud.error.code] || "?"})`;
if (audLastErr) return "audio: " + audLastErr;
if (aud.paused) return `audio: on, PAUSED (readyState ${aud.readyState})`;
return `audio: ▶ playing ${aud.currentTime.toFixed(1)}s · vol ${Math.round(aud.volume * 100)}% · rs ${aud.readyState}`;
const s = ring && ring.scales[ringIndex];
const url = soundtrackUrl();
const head = `ON · scale=${s ? s.id : "?"} · ring=${serverRing ? "server" : "SYNTH"} · url=${url || "NONE"}`;
if (!url) return "audio: " + head + " ← no soundtrack URL for this scale";
if (aud.error) return `audio: ${head} · MEDIA ERROR ${aud.error.code} (${AUD_ERR[aud.error.code] || "?"})`;
if (audLastErr) return `audio: ${head} · ${audLastErr}`;
if (aud.paused) return `audio: ${head} · PAUSED rs=${aud.readyState}`;
return `audio: ${head} · ▶ ${aud.currentTime.toFixed(1)}s vol=${Math.round(aud.volume * 100)}% rs=${aud.readyState}`;
}
function updateAudioStatus() {
const el = $("audio-status");
if (!el) return;
el.textContent = audioStatusText();
const playing = $("audio").checked && !aud.paused && !aud.error && !audLastErr;
const playing = $("audio").checked && !!soundtrackUrl() && !aud.paused && !aud.error && !audLastErr;
el.classList.toggle("playing", playing);
el.classList.toggle("blocked", !!aud.error || !!audLastErr);
el.classList.toggle("blocked", $("audio").checked && (!soundtrackUrl() || !!aud.error || !!audLastErr));
}
aud.addEventListener("error", () => { audLastErr = ""; updateAudioStatus(); });
aud.addEventListener("error", updateAudioStatus);
setInterval(updateAudioStatus, 400);
function fadeVolume(el, to, ms, done) {
@@ -956,10 +968,25 @@ function fadeVolume(el, to, ms, done) {
}, 30);
}
// The current altitude's soundtrack url (the ring carries each scale's `audio`).
// Fallback per-scale soundtrack paths, used when the ring from /api/ring lacks the
// `audio` field (e.g. a server process started before the audio field existed).
// Mirrors simulator/build_pool_manifest.py SCALE_AUDIO — the 5 scales + files are
// fixed, committed facts, so this keeps audio working without a server restart.
const SCALE_AUDIO_FALLBACK = {
cosmos: "cosmos/pillars.loop.mp3",
orbit: "orbit/spaceamb.loop.mp3",
coast: "coast/waves.loop.mp3",
reef: "reef/soundscape.loop.mp3",
abyss: "abyss/whale.loop.mp3",
};
// The current altitude's soundtrack url: the ring's `audio` field, else the
// scale-id fallback above.
function soundtrackUrl() {
const s = ring && ring.scales[ringIndex];
return s && s.audio ? "/media/audio/" + s.audio : null;
if (!s) return null;
const a = s.audio || SCALE_AUDIO_FALLBACK[s.id];
return a ? "/media/audio/" + a : null;
}
// Load `url` into the single element and fade it in. Call this SYNCHRONOUSLY from
+9 -9
View File
@@ -40,15 +40,6 @@
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
<span class="dev-switch-label">Audio</span>
</label>
<div id="audio-status" class="audio-status">audio: off</div>
</fieldset>
<fieldset>
<legend>Audio check (diagnostic)</legend>
<p class="hint">Press play below to test the soundtrack file directly in the
browser's own player — this bypasses all app code. If you hear it here but
not from the Audio toggle, the file is fine and it's a code/autoplay issue.</p>
<audio id="aud-test" controls preload="auto" src="/media/audio/cosmos/pillars.loop.mp3" style="width:100%"></audio>
</fieldset>
<fieldset>
@@ -75,6 +66,15 @@
</label>
<div id="dev-panel" class="dev-panel hidden">
<fieldset>
<legend>Audio diagnostics</legend>
<div id="audio-status" class="audio-status">audio: off</div>
<p class="hint">Native player — tests the soundtrack file directly (bypasses
all app code). If it plays here but the Audio toggle is silent, it's a
code/autoplay issue, not the file.</p>
<audio id="aud-test" controls preload="auto" src="/media/audio/cosmos/pillars.loop.mp3" style="width:100%"></audio>
</fieldset>
<fieldset>
<legend>Pool — pick a clip</legend>
<select id="pool-select" aria-label="Choose a clip from this altitude's pool"></select>
+20
View File
@@ -101,6 +101,26 @@ def test_audio_before_video_stays_audio_only(page):
page.wait_for_selector("#black:not(.hidden)") # still blanked
def test_audio_then_video_leaves_both_on(page):
_toggle(page, "audio") # audio first
page.wait_for_function("!document.getElementById('aud').paused")
_toggle(page, "visual") # then video
page.wait_for_selector("#black.hidden", state="attached") # video showing
assert page.is_checked("#audio") is True
assert page.evaluate("!document.getElementById('aud').paused") is True # audio still plays
def test_soundtrack_fallback_when_ring_lacks_audio(page):
# simulate a stale server whose /api/ring omits the per-scale `audio` field;
# the client falls back to the scale-id soundtrack map so audio still plays
page.evaluate("ring.scales.forEach(s => { delete s.audio; })")
_toggle(page, "audio")
page.wait_for_function(
"(() => { const a = document.getElementById('aud');"
" return a.src.includes('/media/audio/cosmos/') && !a.paused; })()"
)
def test_video_off_blanks_after_being_on(page):
_toggle(page, "visual") # on
page.wait_for_selector("#black.hidden", state="attached")