Files
human-experience-filter-art/player/alteration.py
Ben Stull cbb7c9f53d feat(sim): Right axis = deterministic real-time painterly dream
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
<canvas> 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) <noreply@anthropic.com>
2026-06-23 09:55:35 -07:00

185 lines
6.8 KiB
Python

"""The alteration engine: a knob vector -> a layered RenderPlan (design §4, §5).
Right axis (reframed session 0013): a DETERMINISTIC, real-time dream applied live
to the base footage — a STILL altered state, not the old pre-baked SD variant that
churned across the loop (Right-axis dream design,
docs/superpowers/specs/2026-06-22-right-axis-deterministic-dream-design.md). The
Left axis carries its knob LEVEL so a runtime annotation track can pick which
labels show, and a frozen `Calibration` parameterizes the knob->strength gains so
they can be tuned by eye in the simulator and baked into DEFAULT_CALIBRATION.
Layers compose per §4.2:
- Substrate: ColorGrade (Dark/Light mood, center = identity §5) + the Right Dream
(deterministic luminous haze, applied client-side from `dream.intensity`).
- Overlay: AnalyticalOverlay (Left) + the affect channel, composited on top.
Left and Right stack (different layers); Dark/Light are the two poles of one
mood grade.
"""
from __future__ import annotations
from dataclasses import dataclass
from hef.selection import Coordinate
KNOB_MAX = 4 # knob full-scale (0..4)
def _clamp(x: float, lo: float, hi: float) -> float:
return max(lo, min(hi, x))
@dataclass(frozen=True)
class Calibration:
"""Tunable knob->strength curves (settled by eye in the sim, then baked).
- mood_gain: scales the signed Dark/Light tone (result clamped to [-1, 1]).
- overlay_gain: scales the Left overlay (and affect) intensity (clamped [0, 1]).
- dream_gain: scales the Right deterministic-dream intensity (clamped [0, 1]).
"""
mood_gain: float = 1.0
overlay_gain: float = 1.0
dream_gain: float = 1.0
# The LOCKED calibration (session 0010, 2026-06-07) — settled by eye in the
# simulator, closing the open session-0006 knob->strength decision. Convention:
# every experience knob runs 0 = off .. 4 = max, and equal Dark/Light = identity
# (raw footage); there is no "centered at 2 = no push" coordinate.
# - mood_gain = 1.0: full Dark/Light knob reaches the full mood grade. By-eye
# evidence (POC renders + sim, after the dark-grade fix this session) shows
# full tilt is peaceful on every axis, so no softening is warranted.
# - overlay_gain = 1.0: full Left = opacity 1.0; the HUD is legible, not
# overwhelming, sitting above the grade.
# - dream_gain = 1.0: full Right = dream intensity 1.0; the deterministic
# luminous haze (Right-axis dream design, session 0013) reaches full strength.
# These are unity by deliberate choice, not as placeholders. The gains stay
# parameterized so a different feel is one edit away; test_default_calibration_is_locked
# guards the values from drifting silently.
DEFAULT_CALIBRATION = Calibration(
mood_gain=1.0,
overlay_gain=1.0,
dream_gain=1.0,
)
@dataclass(frozen=True)
class ColorGrade:
"""Mood-axis grade (§5). `tone` is signed: >0 warm yellow->white (light),
<0 cool blue->black (dark), 0 = identity (raw, ungraded)."""
tone: float
@property
def is_identity(self) -> bool:
return self.tone == 0.0
@dataclass(frozen=True)
class AnalyticalOverlay:
"""Left axis (§4.1): analytical HUD/annotation, composited on top at runtime.
`level` is the Left knob (0..4); a runtime annotation track uses it to pick
which labels appear. `intensity` 0..1 is the overlay opacity/strength."""
level: int
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 Dream:
"""Right axis: a deterministic, real-time dream applied live to the base
footage — a STILL altered state (Right-axis dream design, session 0013),
NOT the old pre-baked SD variant that churned across the loop. `strength` is
the Right knob (0..4); `intensity` 0..1 is the dream's strength (the client
maps it to the luminous-haze look, the way it maps `grade.tone` to a filter)."""
strength: int
intensity: float
@dataclass(frozen=True)
class RenderPlan:
"""The full layered alteration for one knob vector (§4.2)."""
grade: ColorGrade
overlay: AnalyticalOverlay
affect: AffectOverlay
dream: Dream
@property
def is_identity(self) -> bool:
"""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
and self.dream.strength == 0
)
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 _dream_intensity(right: int, cal: Calibration) -> float:
return _clamp(cal.dream_gain * right / KNOB_MAX, 0.0, 1.0)
def _mood_tone(dark: int, light: int, cal: Calibration) -> float:
return _clamp(cal.mood_gain * (light - dark) / KNOB_MAX, -1.0, 1.0)
def plan_alteration(
coord: Coordinate, calibration: Calibration = DEFAULT_CALIBRATION
) -> RenderPlan:
"""Map a knob vector to its layered RenderPlan (design §4)."""
return RenderPlan(
grade=ColorGrade(tone=_mood_tone(coord.dark, coord.light, calibration)),
overlay=AnalyticalOverlay(
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),
),
dream=Dream(
strength=coord.right,
intensity=_dream_intensity(coord.right, calibration),
),
)
def render_plan_to_dict(plan: RenderPlan) -> dict:
"""JSON-serializable form for the simulator API."""
return {
"grade": {"tone": plan.grade.tone},
"overlay": {"level": plan.overlay.level, "intensity": plan.overlay.intensity},
"affect": {"strength": plan.affect.strength, "intensity": plan.affect.intensity},
"dream": {"strength": plan.dream.strength, "intensity": plan.dream.intensity},
"is_identity": plan.is_identity,
}