feat(sim+player): scale-ring navigation (endless encoder) + cosmos/abyss scales

Add scale-ring navigation to the simulator per the scales-library design §3:
an endless rotary-encoder control (relative, vs the absolute 0-4 experience
knobs) walks a closed ring of neutral "scales of nature" clips, with placeholder
AI zoom/warp transitions between adjacent scales and a small->large wrap (the
infinite-zoom payoff).

- player/ring.py (new, canonical pure logic): ScaleRing + advance_ring; one
  transition clip per edge, played forward zooming inward / reversed outward;
  modulo wrap both ways; chained steps for a multi-detent spin. Mirrors
  player/content.py conventions (frozen dataclasses, pure functions).
- simulator: load_ring + ring_to_dict/ring_move_to_dict; GET /api/ring and
  POST /api/ring/advance (Python owns step/wrap/transition math; the browser
  only plays the returned transition(s) then settles on the target scale).
- frontend: endless-encoder control (zoom in/out buttons + stage scroll),
  current-scale readout, multi-clip active-scale selection, transition playback.
- media: two cheap true-PD scales so the ring is demonstrable -- cosmos
  (NASA/Hubble) + abyss (NOAA Ocean Exploration), provenance recorded in the
  manifest, bytes generated as labelled placeholders by setup_scales_media.py.
  The expensive multi-strength flow-stabilized Right re-bake is DEFERRED (new
  scales carry a raw base only) until the ring is liked; Pi renderer +
  serial/firmware remain deferred (simulator-first).
- docs: ROADMAP slice-3 done; USER_GUIDE scale-ring gesture; plan doc.

Verified: pytest 215 passed / 2 skipped (+22 new); sim boots, /api/ring +
/api/ring/advance correct incl. wrap, media served, and a headless-Chrome CDP
drive walked cosmos -> forest -> abyss -> (wrap) -> cosmos with the active clip
reloading correctly.

Spec: docs/superpowers/specs/2026-06-07-scales-library-and-right-axis-pipeline-design.md (§3)
Plan: docs/superpowers/plans/2026-06-07-scale-ring-navigation.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-07 23:40:21 -07:00
parent 6cf1ae08ff
commit 7a50ae41bb
16 changed files with 951 additions and 25 deletions
+28 -1
View File
@@ -23,7 +23,8 @@ from player.alteration import (
)
from player.content import resolve_content
from player.controls import CONTENT_POSITIONS
from simulator.clips import load_manifest
from player.ring import advance_ring
from simulator.clips import load_manifest, load_ring, ring_move_to_dict, ring_to_dict
STATIC_DIR = Path(__file__).parent / "static"
MEDIA_DIR = Path(__file__).parent / "sample_media"
@@ -51,6 +52,11 @@ class AlterationRequest(BaseModel):
calibration: Optional[CalibrationModel] = None
class RingAdvanceRequest(BaseModel):
from_index: int = 0
delta: int
def _load_clips(manifest_path: Optional[Path]):
path = Path(manifest_path) if manifest_path else DEFAULT_MANIFEST
if path.exists():
@@ -58,9 +64,17 @@ def _load_clips(manifest_path: Optional[Path]):
return []
def _load_ring(manifest_path: Optional[Path]):
path = Path(manifest_path) if manifest_path else DEFAULT_MANIFEST
if path.exists():
return load_ring(path)
return None
def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
app = FastAPI(title="HEF Alteration Simulator")
app.state.clips = _load_clips(manifest_path)
app.state.ring = _load_ring(manifest_path)
@app.post("/api/alteration")
def api_alteration(req: AlterationRequest):
@@ -88,6 +102,19 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
def api_clips():
return {"clips": [c.to_dict() for c in app.state.clips]}
@app.get("/api/ring")
def api_ring():
if app.state.ring is None:
raise HTTPException(status_code=404, detail="no scale ring in manifest")
return ring_to_dict(app.state.ring, app.state.clips)
@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)
return ring_move_to_dict(move, app.state.ring)
if MEDIA_DIR.exists():
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
if STATIC_DIR.exists():
+57
View File
@@ -14,6 +14,8 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
from player.ring import RingMove, Scale, ScaleRing, Transition, scale_at
@dataclass(frozen=True)
class Clip:
@@ -66,3 +68,58 @@ def load_manifest(path: str | Path) -> list[Clip]:
path = Path(path)
data = json.loads(path.read_text())
return [_clip_from_dict(c) for c in data["clips"]]
def load_ring(path: str | Path) -> ScaleRing | None:
"""Load the optional `ring` section as a `ScaleRing`, or None if absent.
The ring closes the scales-of-nature library into the navigable loop the
endless encoder walks (scales design §3); the navigation math itself lives
in player.ring.
"""
path = Path(path)
data = json.loads(path.read_text())
ring = data.get("ring")
if not ring:
return None
scales = tuple(
Scale(id=s["id"], clip_id=s["clip_id"]) for s in ring.get("scales", [])
)
transitions = tuple(
Transition(file=t["file"], model=t.get("model", ""))
for t in ring.get("transitions", [])
)
return ScaleRing(scales=scales, transitions=transitions)
def ring_to_dict(ring: ScaleRing, clips: list[Clip]) -> dict:
"""JSON form of the ring for the API: ordered scales (with their clip title)
and the per-edge transitions."""
titles = {c.id: c.title for c in clips}
return {
"scales": [
{"id": s.id, "clip_id": s.clip_id, "title": titles.get(s.clip_id, s.id)}
for s in ring.scales
],
"transitions": [{"file": t.file, "model": t.model} for t in ring.transitions],
}
def ring_move_to_dict(move: RingMove, ring: ScaleRing) -> dict:
"""JSON form of an encoder move: the landing scale's clip and the ordered
transition clips to play (with direction)."""
return {
"from_index": move.from_index,
"to_index": move.to_index,
"wrapped": move.wrapped,
"target_clip_id": scale_at(ring, move.to_index).clip_id,
"steps": [
{
"edge": st.edge,
"reversed": st.reversed,
"file": st.file,
"to_index": st.to_index,
}
for st in move.steps
],
}
+13 -4
View File
@@ -3,10 +3,19 @@
`manifest.json` is committed; the `.mp4` binaries are **not** (gitignored). They
are look-tuning samples, not shipped installation content.
Populate them from the session-0008 POC artifacts:
Populate them in two steps:
python simulator/setup_sample_media.py
python simulator/setup_sample_media.py # forest (the real POC base + Right variants)
python simulator/setup_scales_media.py # cosmos + abyss scales + ring transitions
This copies `~/hef-poc/out/neutral.mp4``forest/base.mp4` and
`~/hef-poc/out/right_flow.mp4``forest/right4.mp4` (the real flow-stabilized
`setup_sample_media.py` copies `~/hef-poc/out/neutral.mp4``forest/base.mp4`
and `~/hef-poc/out/right_flow.mp4``forest/right4.mp4` (the real flow-stabilized
restyle), and generates placeholder strengths `forest/right1..3.mp4`.
`setup_scales_media.py` makes the scale **ring** demonstrable: cheap synthetic
placeholder bases for the two true-PD scales — `cosmos/base.mp4` (NASA/Hubble)
and `abyss/base.mp4` (NOAA Ocean Exploration) — plus the per-edge zoom/warp
**transition** clips under `transitions/`. The manifest records the real true-PD
provenance; these bytes are labelled placeholders for tuning ring navigation.
The new scales carry a raw base only — the expensive multi-strength
flow-stabilized Right re-bake is **deferred** until the ring is liked.
+49 -1
View File
@@ -1,5 +1,23 @@
{
"clips": [
{
"id": "cosmos",
"title": "Cosmos (NASA/Hubble, neutral base)",
"base_file": "cosmos/base.mp4",
"license": "public-domain (US Gov, 17 U.S.C. §105)",
"source": "NASA/Hubble — https://svs.gsfc.nasa.gov/ (true PD; placeholder bytes until ingest)",
"right_variants": {},
"annotations": [
{"key": "detected.galaxy", "box": [0.34, 0.30, 0.30, 0.34], "min_level": 1},
{"key": "measure.redshift", "box": [0.36, 0.66, 0.18, 0.08], "min_level": 4}
],
"strings": {
"en": {
"detected.galaxy": "spiral galaxy",
"measure.redshift": "z ≈ 0.04"
}
}
},
{
"id": "forest",
"title": "Yosemite Falls (neutral base, POC sample)",
@@ -26,6 +44,36 @@
"measure.flow_rate": "~2.1 m³/s"
}
}
},
{
"id": "abyss",
"title": "Deep sea (NOAA Ocean Exploration, neutral base)",
"base_file": "abyss/base.mp4",
"license": "public-domain (US Gov, 17 U.S.C. §105)",
"source": "NOAA Ocean Exploration — https://oceanexplorer.noaa.gov/ (true PD, worldwide; placeholder bytes until ingest)",
"right_variants": {},
"annotations": [
{"key": "detected.organism", "box": [0.40, 0.38, 0.22, 0.26], "min_level": 1},
{"key": "measure.depth", "box": [0.06, 0.06, 0.16, 0.08], "min_level": 2}
],
"strings": {
"en": {
"detected.organism": "siphonophore",
"measure.depth": "2,140 m"
}
}
}
]
],
"ring": {
"scales": [
{"id": "cosmos", "clip_id": "cosmos"},
{"id": "forest", "clip_id": "forest"},
{"id": "abyss", "clip_id": "abyss"}
],
"transitions": [
{"file": "transitions/cosmos-forest.mp4", "model": "placeholder-zoom"},
{"file": "transitions/forest-abyss.mp4", "model": "placeholder-zoom"},
{"file": "transitions/abyss-cosmos.mp4", "model": "placeholder-zoom"}
]
}
}
+108
View File
@@ -0,0 +1,108 @@
"""Generate cheap placeholder media for the scale RING (scales design §3).
The ring needs >=2 neutral scales to be demonstrable. This script produces
cheap, offline, synthetic placeholders for the two true-PD scales the ring adds
**cosmos** (NASA/Hubble) and **abyss** (NOAA Ocean Exploration) plus the short
zoom/warp **transition** clips between adjacent scales. Forest reuses the real
POC base from `setup_sample_media.py` (or a synthetic placeholder if absent).
This is deliberately the CHEAP path: the manifest records the real true-PD
provenance (NASA 17 U.S.C. §105 / NOAA PD), but the bytes here are labelled
synthetic placeholders for look-tuning the ring navigation. The expensive real
multi-strength flow-stabilized Right re-bake is DEFERRED until the ring is liked,
so the new scales carry a raw base only (no Right variants).
Usage: python simulator/setup_scales_media.py
Requires: ffmpeg on PATH (or `pip install imageio-ffmpeg`).
"""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
MEDIA = Path(__file__).parent / "sample_media"
DUR = 6 # base clip seconds
XDUR = 1.5 # transition seconds
SIZE = "1280x720"
FPS = 25
# Each scale: (dir, base color, on-screen placeholder label).
SCALES = {
"cosmos": ("0x05060f", "COSMOS — NASA/Hubble (PD placeholder)"),
"forest": ("0x12301a", "FOREST — Yosemite (POC/placeholder)"),
"abyss": ("0x021016", "ABYSS — NOAA Ocean Exploration (PD placeholder)"),
}
# Ring edges (adjacent pairs + the abyss->cosmos wrap), matching the manifest.
EDGES = [("cosmos", "forest"), ("forest", "abyss"), ("abyss", "cosmos")]
def _ffmpeg() -> str:
if shutil.which("ffmpeg"):
return "ffmpeg"
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
def _run(args: list[str]) -> None:
subprocess.run(args, check=True, capture_output=True)
def _make_base(ff: str, name: str, color: str, label: str) -> Path:
out = MEDIA / name / "base.mp4"
out.parent.mkdir(parents=True, exist_ok=True)
# A calm solid tint + faint noise grain (reads as stars/particles), with a
# placeholder label so it is never mistaken for shipped footage.
vf = (
"noise=alls=8:allf=t,"
"drawtext=text='" + label + "':fontcolor=white@0.55:fontsize=24:"
"x=(w-text_w)/2:y=h-48"
)
_run([
ff, "-y", "-f", "lavfi", "-i",
f"color=c={color}:s={SIZE}:d={DUR}:r={FPS}",
"-vf", vf, "-pix_fmt", "yuv420p", "-an", str(out),
])
return out
def _make_transition(ff: str, src: str, dst: str) -> Path:
a = MEDIA / src / "base.mp4"
b = MEDIA / dst / "base.mp4"
out = MEDIA / "transitions" / f"{src}-{dst}.mp4"
out.parent.mkdir(parents=True, exist_ok=True)
w, h = SIZE.split("x")
# A placeholder zoom/warp morph: zoom-in crossfade from src into dst. Both
# bases are normalized first (size/fps/sar/format) because the real forest
# POC base differs in resolution + fps from the synthetic scale placeholders,
# and xfade requires identical frame geometry on both inputs.
norm = f"trim=0:3,setpts=PTS-STARTPTS,scale={w}:{h},fps={FPS},setsar=1,format=yuv420p"
_run([
ff, "-y", "-i", str(a), "-i", str(b), "-filter_complex",
f"[0:v]{norm}[a];[1:v]{norm}[b];"
f"[a][b]xfade=transition=zoomin:duration={XDUR}:offset=0.75,"
"format=yuv420p[v]",
"-map", "[v]", "-an", str(out),
])
return out
def main() -> None:
ff = _ffmpeg()
for name, (color, label) in SCALES.items():
base = MEDIA / name / "base.mp4"
if name == "forest" and base.exists():
print(f"forest/base.mp4 present (real POC base) — keeping")
continue
_make_base(ff, name, color, label)
print(f"generated {name}/base.mp4 (synthetic placeholder)")
for src, dst in EDGES:
_make_transition(ff, src, dst)
print(f"generated transitions/{src}-{dst}.mp4 (placeholder zoom)")
print(f"scale-ring media ready in {MEDIA}")
if __name__ == "__main__":
main()
+94 -7
View File
@@ -1,19 +1,39 @@
// Thin renderer: post controls+calibration -> RenderPlan; render grade, Right
// variant crossfade, and the live Left overlay. All math stays in Python.
// variant crossfade, and the live Left overlay. All alteration math stays in
// Python. The scale RING (endless encoder) is navigated via /api/ring/advance —
// Python owns the step/wrap/transition-selection math; the browser only plays
// the returned transition clip(s) then settles on the target scale's clip.
const $ = (id) => document.getElementById(id);
const vid = $("vid"), tint = $("tint"), overlay = $("overlay"), black = $("black"), readout = $("readout");
let clip = null; // active clip manifest entry
let currentVariant = -1; // last loaded Right strength
let clipsById = {}; // id -> clip manifest entry
let ring = null; // {scales:[{id,clip_id,title}], transitions:[...]} or null
let ringIndex = 0; // current scale position on the ring
let currentVariant = -1; // last loaded Right strength (reset on clip change)
let busy = false; // true while a ring transition is playing
async function loadClips() {
const data = await (await fetch("/api/clips")).json();
clip = data.clips[0] || null;
async function loadData() {
const clips = (await (await fetch("/api/clips")).json()).clips || [];
clipsById = Object.fromEntries(clips.map((c) => [c.id, c]));
const r = await fetch("/api/ring");
ring = r.ok ? await r.json() : null;
if (!ring && clips.length) {
// No ring: single-clip mode — synthesize a 1-scale ring over clip 0.
ring = { scales: [{ id: clips[0].id, clip_id: clips[0].id, title: clips[0].title }], transitions: [] };
}
ringIndex = 0;
}
function activeClip() {
if (!ring) return null;
return clipsById[ring.scales[ringIndex].clip_id] || null;
}
function mediaUrl(file) { return "/media/" + file; }
function variantFile(strength) {
const clip = activeClip();
if (!clip) return "";
const v = clip.right_variants[String(strength)];
return v ? v.file : clip.base_file;
}
@@ -37,12 +57,14 @@ function loadVariant(strength) {
vid.style.opacity = "0";
setTimeout(() => {
vid.src = mediaUrl(variantFile(strength));
vid.loop = true;
vid.play().catch(() => {});
vid.style.opacity = "1";
}, 150);
}
function renderOverlay(level, intensity) {
const clip = activeClip();
overlay.innerHTML = "";
if (!clip || level <= 0) { overlay.style.opacity = "0"; return; }
overlay.style.opacity = String(intensity);
@@ -63,6 +85,12 @@ function renderOverlay(level, intensity) {
}
}
function renderScaleReadout() {
if (!ring) return;
const s = ring.scales[ringIndex];
$("scale-name").textContent = `${s.title} (${ringIndex + 1}/${ring.scales.length})`;
}
function controls() {
return {
content: $("content").value,
@@ -79,6 +107,7 @@ function calibration() {
let timer = null;
async function update() {
if (busy) return;
const resp = await fetch("/api/alteration", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ controls: controls(), calibration: calibration() }),
@@ -95,11 +124,69 @@ async function update() {
function debounced() { clearTimeout(timer); timer = setTimeout(update, 80); }
// --- Scale ring (endless encoder) ---
function playTransition(file) {
// Play one placeholder zoom/warp transition clip start-to-finish, with the
// alteration layers hidden. Resolves on 'ended' (or a safety timeout).
return new Promise((resolve) => {
overlay.style.opacity = "0";
tint.style.opacity = "0";
vid.style.filter = "none";
vid.loop = false;
vid.style.opacity = "1";
vid.src = mediaUrl(file);
let done = false;
const finish = () => { if (done) return; done = true; vid.removeEventListener("ended", finish); resolve(); };
vid.addEventListener("ended", finish);
vid.play().catch(finish);
setTimeout(finish, 6000);
});
}
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 }),
});
if (!resp.ok) return;
const move = await resp.json();
for (const step of move.steps) {
await playTransition(step.file);
}
ringIndex = move.to_index;
currentVariant = -1; // force the target clip's variant to (re)load
renderScaleReadout();
} finally {
busy = false;
update();
}
}
let wheelAccum = 0, wheelTimer = null;
function onWheel(e) {
e.preventDefault();
wheelAccum += e.deltaY;
clearTimeout(wheelTimer);
wheelTimer = setTimeout(() => {
const detents = Math.trunc(wheelAccum / 60) || (wheelAccum > 0 ? 1 : -1);
wheelAccum = 0;
if (detents) advance(detents); // wheel down = zoom inward (+)
}, 90);
}
async function main() {
await loadClips();
await loadData();
renderScaleReadout();
for (const id of ["content", "left", "right", "dark", "light", "mood_gain", "overlay_gain"]) {
$(id).addEventListener("input", debounced);
}
$("zoom-in").addEventListener("click", () => advance(1));
$("zoom-out").addEventListener("click", () => advance(-1));
$("stage").addEventListener("wheel", onWheel, { passive: false });
update();
}
main();
+11 -1
View File
@@ -9,7 +9,7 @@
<body>
<header><h1>Human Experience Filter — Alteration Preview</h1></header>
<main>
<section class="stage">
<section class="stage" id="stage">
<div class="screen">
<video id="vid" loop muted playsinline></video>
<div id="tint"></div>
@@ -32,6 +32,16 @@
</select>
</fieldset>
<fieldset>
<legend>Scale ring (endless encoder)</legend>
<div class="ring-control">
<button type="button" id="zoom-out" title="zoom out (toward cosmos)">⊖ out</button>
<span id="scale-name" class="scale-name"></span>
<button type="button" id="zoom-in" title="zoom in (toward microscopic)">in ⊕</button>
</div>
<p class="hint">Relative, endless — wraps past the smallest back to the largest. Scroll the stage too.</p>
</fieldset>
<fieldset>
<legend>Experience knobs (04)</legend>
<label>Left (analytical) <input type="range" id="left" min="0" max="4" value="0" /></label>
+7
View File
@@ -23,3 +23,10 @@ label { display: block; margin: 0.4rem 0; }
input[type=range], select { width: 100%; }
#readout { background: #000; padding: 0.5rem; border-radius: 4px; font-size: 12px;
white-space: pre-wrap; max-height: 240px; overflow: auto; }
.ring-control { display: flex; align-items: center; gap: 0.4rem; }
.ring-control button { flex: 0 0 auto; background: #1a2436; color: #9af;
border: 1px solid #345; border-radius: 4px; padding: 0.3rem 0.5rem;
cursor: pointer; font-size: 13px; }
.ring-control button:hover { background: #243352; }
.scale-name { flex: 1 1 auto; text-align: center; font-size: 12px; color: #cde; }
.hint { margin: 0.3rem 0 0; font-size: 11px; color: #789; }