diff --git a/player/alteration.py b/player/alteration.py new file mode 100644 index 0000000..297f3d9 --- /dev/null +++ b/player/alteration.py @@ -0,0 +1,98 @@ +"""The alteration engine: a knob vector -> a layered RenderPlan (design §4, §5). + +Replaces the 2026-06-04 nearest-match *selection* with a *transformation* of a +neutral base clip. Given the four experience knobs, it produces three layers +that compose per §4.2: + + - Substrate transforms (alter pixels, blend with each other): + * Restyle -- the Right axis: a pre-baked generative v2v dreamlike restyle. + * ColorGrade -- the mood axis (Dark/Light): a deterministic color grade. + - Overlay (composited on top of the substrate): + * AnalyticalOverlay -- the Left axis: HUD/labels/measurement. + +Left and Right are NOT opposites; they live on different layers and stack +(§4.2). Dark and Light are the two poles of one mood grade whose center is the +identity (§5). + +Calibration note: the knob->strength curves below are the single source of +truth for how a 0..4 position maps to a transform strength. See the session +0006 transcript Deferred decisions for the §3-vs-§4.2/§5 reconciliation. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from hef.selection import Coordinate + +KNOB_MAX = 4 # knob full-scale (0..4) + + +@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/labels, composited on top. + `intensity` 0..1 (0 = no overlay).""" + + intensity: float + + +@dataclass(frozen=True) +class Restyle: + """Right axis (§4.1): pre-baked generative v2v dreamlike substrate. + `blend` 0..1 (0 = raw substrate, no restyle).""" + + blend: float + + +@dataclass(frozen=True) +class RenderPlan: + """The full layered alteration for one knob vector (§4.2).""" + + grade: ColorGrade + overlay: AnalyticalOverlay + restyle: Restyle + + @property + def is_identity(self) -> bool: + """True when the plan leaves the neutral base un-altered.""" + return ( + self.grade.is_identity + and self.overlay.intensity == 0.0 + and self.restyle.blend == 0.0 + ) + + +def _overlay_intensity(left: int) -> float: + """Left knob -> analytical-overlay intensity (0..1).""" + return left / KNOB_MAX + + +def _restyle_blend(right: int) -> float: + """Right knob -> v2v restyle blend (0..1).""" + return right / KNOB_MAX + + +def _mood_tone(dark: int, light: int) -> float: + """(dark, light) -> signed mood grade in [-1, 1]; equal -> 0 identity (§5).""" + return (light - dark) / KNOB_MAX + + +def plan_alteration(coord: Coordinate) -> RenderPlan: + """Map a knob vector to its layered RenderPlan (design §4).""" + return RenderPlan( + grade=ColorGrade(tone=_mood_tone(coord.dark, coord.light)), + overlay=AnalyticalOverlay(intensity=_overlay_intensity(coord.left)), + restyle=Restyle(blend=_restyle_blend(coord.right)), + ) diff --git a/tests/test_player_alteration.py b/tests/test_player_alteration.py new file mode 100644 index 0000000..0897f2b --- /dev/null +++ b/tests/test_player_alteration.py @@ -0,0 +1,80 @@ +import pytest + +from hef.selection import Coordinate +from player.alteration import ( + AnalyticalOverlay, + ColorGrade, + RenderPlan, + Restyle, + plan_alteration, +) + + +def _coord(left=0, right=0, dark=0, light=0): + return Coordinate(left=left, right=right, dark=dark, light=light) + + +def test_all_zero_knobs_is_the_unaltered_base(): + plan = plan_alteration(_coord()) + assert plan.is_identity + assert plan.overlay.intensity == 0.0 + assert plan.restyle.blend == 0.0 + assert plan.grade.tone == 0.0 + assert plan.grade.is_identity + + +def test_left_drives_the_analytical_overlay_only(): + plan = plan_alteration(_coord(left=4)) + assert plan.overlay.intensity == 1.0 + assert plan.restyle.blend == 0.0 # Left does not touch the substrate + assert plan.grade.tone == 0.0 + + +def test_right_drives_the_restyle_substrate_only(): + plan = plan_alteration(_coord(right=2)) + assert plan.restyle.blend == 0.5 + assert plan.overlay.intensity == 0.0 # Right does not add overlay + + +def test_left_and_right_stack_not_cancel(): + # design §4.2: whole-brain corner = dreamlike substrate WITH labels on top + plan = plan_alteration(_coord(left=4, right=4)) + assert plan.overlay.intensity == 1.0 + assert plan.restyle.blend == 1.0 + + +def test_light_pole_grades_warm_positive_tone(): + plan = plan_alteration(_coord(light=4)) + assert plan.grade.tone == 1.0 + assert not plan.grade.is_identity + + +def test_dark_pole_grades_cool_negative_tone(): + plan = plan_alteration(_coord(dark=4)) + assert plan.grade.tone == -1.0 + + +def test_equal_dark_and_light_is_identity_grade(): + # design §5: the mood center is the raw, ungraded footage + assert plan_alteration(_coord(dark=3, light=3)).grade.is_identity + assert plan_alteration(_coord(dark=2, light=2)).grade.is_identity + + +def test_dark_minus_light_sets_intermediate_tone(): + assert plan_alteration(_coord(dark=4, light=2)).grade.tone == pytest.approx(-0.5) + assert plan_alteration(_coord(dark=1, light=3)).grade.tone == pytest.approx(0.5) + + +def test_whole_brain_dark_corner_stacks_grade_substrate_and_overlay(): + # design §4.2 "Dark + analytical": cold measurement over a melancholy scene + plan = plan_alteration(_coord(left=4, right=2, dark=4, light=0)) + assert plan.overlay.intensity == 1.0 + assert plan.restyle.blend == 0.5 + assert plan.grade.tone == -1.0 + assert not plan.is_identity + + +def test_render_plan_is_frozen(): + plan = plan_alteration(_coord()) + with pytest.raises(Exception): + plan.grade.tone = 0.5 # type: ignore[misc]