From 6adb93407ba1d857090cabdebb5200544a0b3440 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Mon, 22 Jun 2026 23:11:02 -0700 Subject: [PATCH 1/3] feat(sim): analytical Left HUD redesign + affect channel + no-cache Left-brain HUD look-and-feel pass (judged by eye in the sim): - corner-bracket targeting reticles (capped arms) replace plain boxes - translucent label chips, legible over any frame - two sensor channels: cyan detections (with deterministic confidence scores) vs amber measurements (center crosshair) - bbox-sized "ANALYSIS . L{n} . {k} OBJ" status tag, escalating with Left Affect channel (emotions in the HUD), per docs/superpowers/specs/2026-06-22-affect-channel-hud-design.md: - surfaces only when BOTH knobs up; strength = min(left, right) - stable per-scene palette, more words appear with combined strength - soft glowing violet words on their own opacity layer (the dream leaking into the machine's read); math in player/alteration.py - forest/cosmos/abyss palettes in the manifest; 5 new unit tests Simulator no-cache middleware so by-eye iteration never serves a stale app.js / api response (fixes the latent "plan has affect but clip.affect empty" stale-/api/clips bug). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-22-affect-channel-hud-design.md | 85 +++++++++++++++ player/alteration.py | 31 +++++- simulator/app.py | 9 ++ simulator/clips.py | 3 + simulator/sample_media/manifest.json | 36 ++++++- simulator/static/app.js | 101 ++++++++++++++++-- simulator/static/index.html | 2 + simulator/static/style.css | 23 +++- tests/test_player_alteration.py | 41 +++++++ 9 files changed, 315 insertions(+), 16 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-22-affect-channel-hud-design.md diff --git a/docs/superpowers/specs/2026-06-22-affect-channel-hud-design.md b/docs/superpowers/specs/2026-06-22-affect-channel-hud-design.md new file mode 100644 index 0000000..87ce6fe --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-affect-channel-hud-design.md @@ -0,0 +1,85 @@ +# Affect channel — emotions in the Left-brain HUD + +**Status:** approved (session 0013, 2026-06-22) +**Surface:** simulator preview (`simulator/`, `player/alteration.py`) +**Builds on:** the machine-altered-perception design SPEC; the Left HUD look-and-feel +pass (same session). + +## Idea + +A third HUD channel beside the cyan *detections* and amber *measurements*: soft, +glowing **emotion-words** that surface the feelings the experience may invoke. +They appear only when *both* the analytical (Left) and dreamlike (Right) knobs are +up — the dream leaking into the machine's reading. This is the uncanny edge of the +thesis: the machine trying to quantify not just what is there, but how you are +meant to feel. + +## Behaviour + +- **Trigger / intensity.** Affect strength = `min(left, right)` (0–4). Either knob + at 0 → no emotions at all. Opacity scales with that strength × `overlay_gain`. +- **Escalation.** A **stable emotional palette per scene** — higher combined + strength reveals *more* words at higher opacity. Each word carries a `min_level` + (the same mechanic the detection channel uses), but compared against the + *combined* `min(left, right)` strength rather than Left alone. +- **Rendering.** Floating words at authored scene positions — **no boxes or + reticles**, lowercase, semi-transparent with a soft glow, in a distinct **violet** + register so they read as affective and clearly *softer* than the hard clinical + reticles. + +## Where the math lives + +Consistent with the existing split (alteration math in Python, label-selection in +the client): + +- `player/alteration.py` gains an `AffectOverlay(strength, intensity)` on the + `RenderPlan`. `strength = min(left, right)`; `intensity = clamp(overlay_gain * + min(left,right) / KNOB_MAX, 0, 1)`. Returned in `render_plan_to_dict` under + `"affect"`. Pure, unit-tested. +- The simulator client picks *which* words to show by comparing each affect + entry's `min_level` to `plan.affect.strength`, and renders them at + `plan.affect.intensity` opacity. + +`is_identity` is unaffected: `min(left,right) > 0` implies `left > 0`, which already +makes `overlay.level > 0`. + +## Data + +New optional per-clip `affect` list in the manifest, parallel to `annotations`: + +```json +"affect": [ + {"key": "feel.awe", "at": [0.5, 0.4], "min_level": 1}, + {"key": "feel.serenity", "at": [0.2, 0.62], "min_level": 2} +], +"strings": {"en": {"feel.awe": "awe", "feel.serenity": "serenity"}} +``` + +`at` is a normalized `[x, y]` anchor point (not a box — feelings are scene-level). +The words live in the existing `strings.en` map. `Clip` gains an `affect` field +(defaulting to `[]`) threaded through `_clip_from_dict` and `to_dict`. + +Starter palettes (words are art-direction, easily tuned): + +| scale | palette (min_level 1 → 4) | +|--------|-----------------------------------------------| +| forest | awe · serenity · reverence · stillness | +| cosmos | wonder · vastness · insignificance · longing | +| abyss | unease · fascination · isolation · dread | + +All three scales carry the data; forest is fully realized (it has the real Right +variants). + +## Testing + +- Python unit tests: affect `strength = min(left, right)` across knob combos; + intensity scaling and `overlay_gain`; `0` when either knob is 0; presence in the + serialized plan. +- By-eye in the live simulator (forest, Left & Right both up) — the established + judge-by-eye loop for this surface. + +## Out of scope + +- No drift-toward-unsettling arc (rejected in favour of a stable palette). +- No per-object emotion tags; no animated drift (words are statically placed for + v1; gentle motion can come later if wanted). diff --git a/player/alteration.py b/player/alteration.py index d343367..d79ce46 100644 --- a/player/alteration.py +++ b/player/alteration.py @@ -85,6 +85,18 @@ class AnalyticalOverlay: intensity: float +@dataclass(frozen=True) +class AffectOverlay: + """Affect channel (Left x Right): emotion-words surfaced only when BOTH the + analytical (Left) and dreamlike (Right) knobs are up. `strength` is + `min(left, right)` (0..4) — a runtime affect track picks which feeling words + appear by it; `intensity` 0..1 is their opacity. The dream leaking into the + machine's reading (affect-channel design, session 0013).""" + + strength: int + intensity: float + + @dataclass(frozen=True) class Restyle: """Right axis (§4.1): selects a pre-baked, flow-stabilized restyle variant. @@ -99,11 +111,14 @@ class RenderPlan: grade: ColorGrade overlay: AnalyticalOverlay + affect: AffectOverlay restyle: Restyle @property def is_identity(self) -> bool: - """True when the plan leaves the neutral base un-altered.""" + """True when the plan leaves the neutral base un-altered. Affect needs no + clause: `min(left,right) > 0` implies `left > 0`, so `overlay.level > 0` + already makes a plan with active affect non-identity.""" return ( self.grade.is_identity and self.overlay.level == 0 @@ -115,6 +130,15 @@ def _overlay_intensity(left: int, cal: Calibration) -> float: return _clamp(cal.overlay_gain * left / KNOB_MAX, 0.0, 1.0) +def _affect_strength(left: int, right: int) -> int: + """Affect surfaces only when BOTH knobs are up — gated by the smaller one.""" + return min(left, right) + + +def _affect_intensity(left: int, right: int, cal: Calibration) -> float: + return _clamp(cal.overlay_gain * _affect_strength(left, right) / KNOB_MAX, 0.0, 1.0) + + def _right_variant(right: int, cal: Calibration) -> int: return cal.right_variant_map[right] @@ -133,6 +157,10 @@ def plan_alteration( level=coord.left, intensity=_overlay_intensity(coord.left, calibration), ), + affect=AffectOverlay( + strength=_affect_strength(coord.left, coord.right), + intensity=_affect_intensity(coord.left, coord.right, calibration), + ), restyle=Restyle(variant=_right_variant(coord.right, calibration)), ) @@ -142,6 +170,7 @@ def render_plan_to_dict(plan: RenderPlan) -> dict: return { "grade": {"tone": plan.grade.tone}, "overlay": {"level": plan.overlay.level, "intensity": plan.overlay.intensity}, + "affect": {"strength": plan.affect.strength, "intensity": plan.affect.intensity}, "restyle": {"variant": plan.restyle.variant}, "is_identity": plan.is_identity, } diff --git a/simulator/app.py b/simulator/app.py index 27e95a5..b04fbdc 100644 --- a/simulator/app.py +++ b/simulator/app.py @@ -120,6 +120,15 @@ def create_app(manifest_path: Optional[Path] = None) -> FastAPI: ) return ring_move_to_dict(move, app.state.ring) + @app.middleware("http") + async def _no_cache(request, call_next): + # Dev preview server: never let a browser serve a stale app.js/style.css. + # The whole point is by-eye iteration, so a plain refresh must always get + # the latest assets (the heuristic cache otherwise hides JS/CSS edits). + response = await call_next(request) + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + return response + if MEDIA_DIR.exists(): app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") if STATIC_DIR.exists(): diff --git a/simulator/clips.py b/simulator/clips.py index ff38c92..cc5e449 100644 --- a/simulator/clips.py +++ b/simulator/clips.py @@ -26,6 +26,7 @@ class Clip: source: str right_variants: dict # {"1": {"file": ...}, "4": {...}} (no "0") annotations: list # [{"key", "box":[x,y,w,h], "min_level"}, ...] + affect: list # [{"key", "at":[x,y], "min_level"}, ...] (Left x Right) strings: dict # {"en": {key: text}} def variant_file(self, strength: int) -> str: @@ -46,6 +47,7 @@ class Clip: "source": self.source, "right_variants": variants, "annotations": self.annotations, + "affect": self.affect, "strings": self.strings, } @@ -59,6 +61,7 @@ def _clip_from_dict(d: dict[str, Any]) -> Clip: source=d.get("source", ""), right_variants=d.get("right_variants", {}), annotations=d.get("annotations", []), + affect=d.get("affect", []), strings=d.get("strings", {}), ) diff --git a/simulator/sample_media/manifest.json b/simulator/sample_media/manifest.json index d5c1ce7..3f79f74 100644 --- a/simulator/sample_media/manifest.json +++ b/simulator/sample_media/manifest.json @@ -11,10 +11,20 @@ {"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} ], + "affect": [ + {"key": "feel.wonder", "at": [0.50, 0.46], "min_level": 1}, + {"key": "feel.vastness", "at": [0.20, 0.70], "min_level": 2}, + {"key": "feel.insignificance", "at": [0.64, 0.60], "min_level": 3}, + {"key": "feel.longing", "at": [0.42, 0.84], "min_level": 4} + ], "strings": { "en": { "detected.galaxy": "spiral galaxy", - "measure.redshift": "z ≈ 0.04" + "measure.redshift": "z ≈ 0.04", + "feel.wonder": "wonder", + "feel.vastness": "vastness", + "feel.insignificance": "insignificance", + "feel.longing": "longing" } } }, @@ -36,12 +46,22 @@ {"key": "detected.conifer", "box": [0.70, 0.20, 0.22, 0.45], "min_level": 3}, {"key": "measure.flow_rate", "box": [0.34, 0.55, 0.14, 0.08], "min_level": 4} ], + "affect": [ + {"key": "feel.awe", "at": [0.48, 0.44], "min_level": 1}, + {"key": "feel.serenity", "at": [0.18, 0.66], "min_level": 2}, + {"key": "feel.reverence", "at": [0.66, 0.56], "min_level": 3}, + {"key": "feel.stillness", "at": [0.42, 0.82], "min_level": 4} + ], "strings": { "en": { "detected.water": "flowing water", "detected.rock_face": "granite face", "detected.conifer": "conifer stand", - "measure.flow_rate": "~2.1 m³/s" + "measure.flow_rate": "~2.1 m³/s", + "feel.awe": "awe", + "feel.serenity": "serenity", + "feel.reverence": "reverence", + "feel.stillness": "stillness" } } }, @@ -56,10 +76,20 @@ {"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} ], + "affect": [ + {"key": "feel.unease", "at": [0.50, 0.46], "min_level": 1}, + {"key": "feel.fascination", "at": [0.22, 0.68], "min_level": 2}, + {"key": "feel.isolation", "at": [0.66, 0.58], "min_level": 3}, + {"key": "feel.dread", "at": [0.42, 0.82], "min_level": 4} + ], "strings": { "en": { "detected.organism": "siphonophore", - "measure.depth": "−2,140 m" + "measure.depth": "−2,140 m", + "feel.unease": "unease", + "feel.fascination": "fascination", + "feel.isolation": "isolation", + "feel.dread": "dread" } } } diff --git a/simulator/static/app.js b/simulator/static/app.js index 922b6b0..669f755 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -5,6 +5,7 @@ // 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"); +const affectLayer = $("affect"); let clipsById = {}; // id -> clip manifest entry let ring = null; // {scales:[{id,clip_id,title}], transitions:[...]} or null @@ -63,25 +64,104 @@ function loadVariant(strength) { }, 150); } +const SVGNS = "http://www.w3.org/2000/svg"; +function svg(tag, attrs, parent) { + const el = document.createElementNS(SVGNS, tag); + for (const k in attrs) el.setAttribute(k, attrs[k]); + if (parent) parent.appendChild(el); + return el; +} + +// Deterministic per-key "confidence" in [0.78, 0.99] — stable across renders so +// a detection always reports the same score (looks like a real model, not noise). +function confidence(key) { + let h = 0; + for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0; + return (0.78 + (h % 2200) / 10000).toFixed(2); +} + +// Four L-shaped corner brackets framing [x,y,w,h] — a targeting reticle rather +// than a plain rectangle. Arm length scales to the box but is clamped legible. +function reticle(x, y, w, h, cls, parent) { + const L = Math.min(Math.max(Math.min(w, h) * 0.28, 1.4), 4); + const corners = [ + [[x + L, y], [x, y], [x, y + L]], + [[x + w - L, y], [x + w, y], [x + w, y + L]], + [[x + L, y + h], [x, y + h], [x, y + h - L]], + [[x + w - L, y + h], [x + w, y + h], [x + w, y + h - L]], + ]; + for (const pts of corners) { + svg("polyline", { points: pts.map((p) => p.join(",")).join(" "), class: cls }, parent); + } +} + +// A label chip: translucent plate + monospace text, anchored top-left of the box. +// `conf` (detections only) is appended as a dimmer score tspan. +function chip(x, y, label, conf, kind, parent) { + const fs = 2.4, pad = 0.6; + const chars = label.length + (conf ? conf.length + 1 : 0); + const w = chars * fs * 0.6 + pad * 2, h = fs + pad * 1.4; + const cy = Math.max(y - h - 0.6, 0.4); + svg("rect", { x, y: cy, width: w, height: h, rx: 0.4, class: "hud-chip-bg " + kind }, parent); + const t = svg("text", { x: x + pad, y: cy + h - pad * 1.1, class: "hud-chip-text " + kind }, parent); + t.appendChild(document.createTextNode(label)); + if (conf) { + const sp = svg("tspan", { class: "hud-conf" }, t); + sp.textContent = " " + conf; + } +} + function renderOverlay(level, intensity) { const clip = activeClip(); overlay.innerHTML = ""; if (!clip || level <= 0) { overlay.style.opacity = "0"; return; } overlay.style.opacity = String(intensity); const strings = (clip.strings && clip.strings.en) || {}; + let shown = 0; for (const a of clip.annotations) { if (a.min_level > level) continue; + shown++; const [x, y, w, h] = a.box.map((n) => n * 100); - const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); - rect.setAttribute("x", x); rect.setAttribute("y", y); - rect.setAttribute("width", w); rect.setAttribute("height", h); - rect.setAttribute("class", "anno-box"); - overlay.appendChild(rect); - const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); - text.setAttribute("x", x + 0.5); text.setAttribute("y", Math.max(y - 0.5, 2)); - text.setAttribute("class", "anno-label"); - text.textContent = strings[a.key] || a.key; - overlay.appendChild(text); + const measure = a.key.startsWith("measure."); + const kind = measure ? "measure" : "detect"; + reticle(x, y, w, h, "hud-reticle " + kind, overlay); + if (measure) { + // Point measurement: a center crosshair tick to read as "sampled here". + const cx = x + w / 2, cyc = y + h / 2; + svg("line", { x1: cx - 1, y1: cyc, x2: cx + 1, y2: cyc, class: "hud-reticle measure" }, overlay); + svg("line", { x1: cx, y1: cyc - 1, x2: cx, y2: cyc + 1, class: "hud-reticle measure" }, overlay); + } + chip(x, y, strings[a.key] || a.key, measure ? null : confidence(a.key), kind, overlay); + } + // Global status tag — the machine announcing it is analyzing, escalating with Left. + // Right-anchored at the frame edge; the plate is sized from the text's real bbox + // (the ◉/· glyphs are wider than monospace, so an estimate clips). + if (shown) { + const pad = 0.8; + const st = svg("text", { x: 98, y: 5, "text-anchor": "end", class: "hud-status" }, overlay); + st.textContent = "◉ ANALYSIS · L" + level + " · " + shown + " OBJ"; + const b = st.getBBox(); + const bg = svg("rect", { x: b.x - pad, y: b.y - pad * 0.6, width: b.width + pad * 2, + height: b.height + pad * 1.2, rx: 0.4, class: "hud-chip-bg detect" }); + overlay.insertBefore(bg, st); + } +} + +// Affect channel: soft, glowing emotion-words surfaced only when BOTH knobs are +// up. `strength` = min(left, right); `intensity` is the layer opacity. Words are +// placed at authored scene points (no boxes — feelings are scene-level) and read +// softer than the clinical reticles — the dream leaking into the machine's read. +function renderAffect(strength, intensity) { + const clip = activeClip(); + affectLayer.innerHTML = ""; + if (!clip || !clip.affect || strength <= 0) { affectLayer.style.opacity = "0"; return; } + affectLayer.style.opacity = String(intensity); + const strings = (clip.strings && clip.strings.en) || {}; + for (const f of clip.affect) { + if (f.min_level > strength) continue; + const [x, y] = f.at.map((n) => n * 100); + const t = svg("text", { x, y, "text-anchor": "middle", class: "hud-affect" }, affectLayer); + t.textContent = strings[f.key] || f.key; } } @@ -120,6 +200,7 @@ async function update() { applyGrade(data.plan.grade.tone); loadVariant(data.plan.restyle.variant); renderOverlay(data.plan.overlay.level, data.plan.overlay.intensity); + renderAffect(data.plan.affect.strength, data.plan.affect.intensity); } function debounced() { clearTimeout(timer); timer = setTimeout(update, 80); } diff --git a/simulator/static/index.html b/simulator/static/index.html index 2852acc..02515de 100644 --- a/simulator/static/index.html +++ b/simulator/static/index.html @@ -14,6 +14,7 @@
+ @@ -54,6 +55,7 @@ Calibration +

Gains, not effects: mood gain scales the Dark/Light grade, overlay gain the Left HUD & affect words. Turn those knobs up first or these do nothing.

diff --git a/simulator/static/style.css b/simulator/static/style.css index d5d0fc9..f37a7cd 100644 --- a/simulator/static/style.css +++ b/simulator/static/style.css @@ -12,8 +12,27 @@ main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; } transition: opacity 0.2s ease; } #overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; transition: opacity 0.2s ease; } -.anno-box { fill: none; stroke: #6cf; stroke-width: 0.4; vector-effect: non-scaling-stroke; } -.anno-label { fill: #6cf; font-size: 3px; font-family: monospace; } +/* Affect channel — its own layer so its opacity tracks min(left,right), not Left. + Soft violet glow, distinct from the clinical reticles (the dream leaking in). */ +#affect { position: absolute; inset: 0; width: 100%; height: 100%; + pointer-events: none; transition: opacity 0.35s ease; + filter: drop-shadow(0 0 0.9px rgba(176, 120, 255, 0.95)); } +.hud-affect { fill: #e3cfff; font: italic 600 3.6px Georgia, "Times New Roman", serif; + letter-spacing: 0.06px; } +/* Left-brain analytical HUD. Two sensor channels: detection (cyan) + measurement + (amber). Corner-bracket reticles, translucent label chips, a status tag. */ +.hud-reticle { fill: none; stroke-width: 0.6; vector-effect: non-scaling-stroke; + stroke-linecap: square; } +.hud-reticle.detect { stroke: #6cf; } +.hud-reticle.measure { stroke: #ffc46b; } +.hud-chip-bg { fill-opacity: 0.72; } +.hud-chip-bg.detect { fill: #001722; } +.hud-chip-bg.measure { fill: #1c1200; } +.hud-chip-text { font: 2.4px monospace; letter-spacing: 0.04px; } +.hud-chip-text.detect { fill: #aee6ff; } +.hud-chip-text.measure { fill: #ffd79a; } +.hud-conf { fill: #6cf; opacity: 0.8; } +.hud-status { fill: #8fdcff; font: 2.4px monospace; opacity: 0.9; letter-spacing: 0.15px; } .black { position: absolute; inset: 0; background: #000; } .hidden { display: none; } .panel { flex: 0 0 280px; display: flex; flex-direction: column; gap: 0.8rem; } diff --git a/tests/test_player_alteration.py b/tests/test_player_alteration.py index 051916d..5e91628 100644 --- a/tests/test_player_alteration.py +++ b/tests/test_player_alteration.py @@ -3,6 +3,7 @@ import pytest from hef.selection import Coordinate from player.alteration import ( DEFAULT_CALIBRATION, + AffectOverlay, AnalyticalOverlay, Calibration, ColorGrade, @@ -48,6 +49,45 @@ def test_left_and_right_stack_not_cancel(): assert plan.restyle.variant == 4 +def test_affect_needs_both_knobs_up(): + # affect-channel design (session 0013): emotion-words surface only when BOTH + # the Left and Right knobs are up — either at 0 means no affect at all. + assert plan_alteration(_coord(left=4, right=0)).affect.strength == 0 + assert plan_alteration(_coord(left=0, right=4)).affect.strength == 0 + assert plan_alteration(_coord(left=4, right=0)).affect.intensity == 0.0 + assert plan_alteration(_coord(left=0, right=4)).affect.intensity == 0.0 + + +def test_affect_strength_is_the_smaller_knob(): + # strength = min(left, right) — the smaller knob gates the channel. + assert plan_alteration(_coord(left=2, right=4)).affect.strength == 2 + assert plan_alteration(_coord(left=4, right=2)).affect.strength == 2 + assert plan_alteration(_coord(left=4, right=4)).affect.strength == 4 + assert plan_alteration(_coord(left=3, right=1)).affect.strength == 1 + + +def test_affect_intensity_scales_with_strength_over_full_scale(): + for lo in range(5): + plan = plan_alteration(_coord(left=lo, right=4)) + assert plan.affect.intensity == pytest.approx(lo / 4) + assert plan_alteration(_coord(left=4, right=4)).affect.intensity == 1.0 + + +def test_affect_intensity_honors_overlay_gain(): + # affect rides the same overlay_gain as the analytical HUD it belongs to. + cal = Calibration(overlay_gain=0.5) + assert plan_alteration(_coord(left=4, right=4), cal).affect.intensity == pytest.approx(0.5) + clamp = Calibration(overlay_gain=10.0) + assert plan_alteration(_coord(left=4, right=4), clamp).affect.intensity == 1.0 + + +def test_affect_active_implies_non_identity(): + # min(left,right) > 0 implies left > 0, so an active affect plan is never identity. + plan = plan_alteration(_coord(left=2, right=3)) + assert plan.affect.strength == 2 + assert not plan.is_identity + + def test_light_pole_grades_warm_positive_tone(): plan = plan_alteration(_coord(light=4)) assert plan.grade.tone == 1.0 @@ -117,6 +157,7 @@ def test_render_plan_to_dict_round_trips_the_numbers(): assert d == { "grade": {"tone": -1.0}, "overlay": {"level": 4, "intensity": 1.0}, + "affect": {"strength": 2, "intensity": 0.5}, "restyle": {"variant": 2}, "is_identity": False, } From cbb7c9f53d412c305bd70bb5db0158d1b95f7f90 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Tue, 23 Jun 2026 09:55:35 -0700 Subject: [PATCH 2/3] feat(sim): Right axis = deterministic real-time painterly dream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the pre-baked SD restyle variants (too fluid — the painting re-rolled every keyframe and the flow-warp melted frames) with a deterministic, real-time dream that holds STILL across the loop; only the footage's own motion moves. Design: docs/superpowers/specs/2026-06-22-right-axis-deterministic-dream-design.md Engine (player/alteration.py): Restyle(variant) -> Dream(strength, intensity); Calibration.right_variant_map -> dream_gain. player/state.py: a Right change is a LIVE_UPDATE now, not a crossfade (only a clip swap crossfades). Plan dict restyle -> dream. Tests migrated; 233 green. (No new Python behavior for the client-render swap below.) Renderer (simulator front-end): the Right dream is a WebGL2 Kuwahara shader on a over the live video — edge-preserving, so motion/structure stay crisp; the dream is the same footage, just stylized (no blur). Ramped by the knob; max goes "trippy" (vivid saturation + ~45deg hue drift + posterize, concentrated near max via t=intensity^2). Phase-1 luminous-haze (CSS+bloom) was built then retired by eye (blur read as out-of-focus video). Mood grade rides as a CSS filter on the canvas; bloom layer removed. Dev ergonomics that fell out of the iteration: - /dev/version + a 1s client poll = live-reload (open tab never runs stale renderer code; reloads on my edits AND server restarts). - no-cache on the dev server; an on-screen error banner + crash-proof render so a silent throw can't masquerade as "nothing is changing". Co-Authored-By: Claude Opus 4.8 (1M context) --- ...2-right-axis-deterministic-dream-design.md | 109 +++++++++ player/alteration.py | 66 +++--- player/state.py | 12 +- simulator/app.py | 31 ++- simulator/static/app.js | 215 +++++++++++++++--- simulator/static/index.html | 1 + simulator/static/style.css | 6 + tests/test_player_alteration.py | 41 ++-- tests/test_player_state.py | 10 +- tests/test_simulator_api.py | 5 +- 10 files changed, 404 insertions(+), 92 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-22-right-axis-deterministic-dream-design.md diff --git a/docs/superpowers/specs/2026-06-22-right-axis-deterministic-dream-design.md b/docs/superpowers/specs/2026-06-22-right-axis-deterministic-dream-design.md new file mode 100644 index 0000000..2ac9a89 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-right-axis-deterministic-dream-design.md @@ -0,0 +1,109 @@ +# Right axis as a real-time deterministic dream + +**Status:** built (session 0013, 2026-06-22) — Phase 1 superseded by Phase 2 in the +same session after by-eye review (the haze "looked like blurred video, not stylized"). +The shipped Right dream is the **Phase 2 painterly** path. +**Surface:** simulator preview (`simulator/`, `player/alteration.py`) +**Supersedes:** the "discrete pre-baked, flow-stabilized restyle variant" definition +of the Right axis in the machine-altered-perception design SPEC §4.1 — a deliberate +change to a core decision (see Why). + +## Why + +The pre-baked SD img2img variants (`bake_right_variants.py`) are **too fluid**: the +painting re-rolls every keyframe (~2 s) and the optical-flow tween warps frames into +each other, so across the loop the dream churns and melts. The right brain should be +a **still altered state** — a calm, holistic counterpart to the busy, accreting +analytical Left — not a restless morph. + +## Concept + +The Right knob no longer selects a pre-baked variant. It drives a **deterministic, +real-time filter** applied live to the base footage, scaled 0→4. The same filter runs +every frame, so the look is **stable by construction** — locked to scene content, +moving only where the footage already moves. Bonus: no baked variant videos to ship, +which simplifies the eventual hardware (Pi) story. + +Staged delivery (both built this session; Phase 2 is what shipped): +- **Phase 1: luminous haze** — soft-focus, blooming highlights, pastel desaturation. + CSS + a bloom layer. Built, then **retired** by eye: blur reads as *out-of-focus + video*, not stylized, and softens the base's crisp motion. +- **Phase 2: painterly flatten (shipped)** — a WebGL2 **Kuwahara** shader on a + `` restyling the live video frames. Edge-preserving, so motion/structure + stay as crisp as the source — the dream is the same footage, just stylized. No blur. + +## Phase 2 — the shipped painterly dream + +- **Renderer:** `` over `
-
- Calibration - - -

Gains, not effects: mood gain scales the Dark/Light grade, overlay gain the Left HUD & affect words. Turn those knobs up first or these do nothing.

-
RenderPlan readout