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:
+94
-7
@@ -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();
|
||||
|
||||
@@ -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 (0–4)</legend>
|
||||
<label>Left (analytical) <input type="range" id="left" min="0" max="4" value="0" /></label>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user