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:
+28
-1
@@ -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():
|
||||
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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