diff --git a/player/state.py b/player/state.py new file mode 100644 index 0000000..6ffa6bc --- /dev/null +++ b/player/state.py @@ -0,0 +1,120 @@ +"""The player state machine: a stream of Controls -> playback transitions. + +Pure decision logic — no mpv, no audio, no serial. Each update() resolves the +desired Playback (which neutral base clip, how it is altered, what audio plays, +at what levels) and returns the Transition from the previous Playback. The +transition KIND encodes design §4.3: the Dark/Light grade and the Left overlay +are continuous runtime ops (LIVE_UPDATE), whereas swapping the clip or the +pre-baked Right v2v variant needs a CROSSFADE, and toggling video on/off +fades to/from black. + +Knobs no longer *select* a clip (the base library is neutral by construction); +they *transform* it. Which neutral base to show is an injected policy +(`choose_base`), defaulting to the first clip; richer rotation is a later slice. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Optional + +from hef.selection import Coordinate +from player.alteration import RenderPlan, plan_alteration +from player.content import ContentResolution, resolve_content +from player.controls import Controls + + +class TransitionKind: + NONE = "none" + LIVE_UPDATE = "live_update" + CROSSFADE = "crossfade" + FADE_TO_BLACK = "fade_to_black" + FADE_FROM_BLACK = "fade_from_black" + + +@dataclass(frozen=True) +class Playback: + """What should currently be playing.""" + + clip_id: Optional[str] # None = black walls + plan: Optional[RenderPlan] # None when black + content: ContentResolution + volume: int + brightness: int + + +@dataclass(frozen=True) +class Transition: + kind: str + playback: Playback + + +def _first(library): + if not library: + raise ValueError("no base clips available to play video") + return library[0] + + +_BLACK = Playback( + clip_id=None, + plan=None, + content=ContentResolution("none", False), + volume=0, + brightness=0, +) + + +class Player: + def __init__( + self, + base_library, + *, + choose_base: Callable = _first, + approved_only: bool = False, + ): + self._library = list(base_library) + self._choose_base = choose_base + self._approved_only = approved_only # reserved for catalog-backed libs + self._current = _BLACK + + def _resolve(self, controls: Controls) -> Playback: + content = resolve_content(controls.content) + if not content.video: + return Playback( + clip_id=None, + plan=None, + content=content, + volume=controls.volume, + brightness=controls.brightness, + ) + clip = self._choose_base(self._library) + coord = Coordinate(controls.left, controls.right, controls.dark, controls.light) + return Playback( + clip_id=clip.id, + plan=plan_alteration(coord), + content=content, + volume=controls.volume, + brightness=controls.brightness, + ) + + def _classify(self, prev: Playback, nxt: Playback) -> str: + prev_video = prev.clip_id is not None + next_video = nxt.clip_id is not None + if prev == nxt: + return TransitionKind.NONE + if prev_video and not next_video: + return TransitionKind.FADE_TO_BLACK + if next_video and not prev_video: + return TransitionKind.FADE_FROM_BLACK + if next_video and prev_video: + if nxt.clip_id != prev.clip_id or nxt.plan.restyle != prev.plan.restyle: + return TransitionKind.CROSSFADE + return TransitionKind.LIVE_UPDATE + # both black: only audio/levels could have changed + return TransitionKind.LIVE_UPDATE + + def update(self, controls: Controls) -> Transition: + nxt = self._resolve(controls) + kind = self._classify(self._current, nxt) + self._current = nxt + return Transition(kind=kind, playback=nxt) diff --git a/tests/test_player_state.py b/tests/test_player_state.py new file mode 100644 index 0000000..fe9b25b --- /dev/null +++ b/tests/test_player_state.py @@ -0,0 +1,112 @@ +from dataclasses import dataclass + +import pytest + +from player.controls import Controls +from player.state import Player, Playback, Transition, TransitionKind + + +@dataclass(frozen=True) +class FakeClip: + id: str + + +LIB = [FakeClip("base-a"), FakeClip("base-b")] + + +def _controls(content="video", left=0, right=0, dark=0, light=0, volume=2, brightness=2): + return Controls(content, left, right, dark, light, volume, brightness) + + +def test_first_update_to_video_fades_in_from_black(): + p = Player(LIB) + t = p.update(_controls(content="video")) + assert t.kind == TransitionKind.FADE_FROM_BLACK + assert t.playback.clip_id == "base-a" + assert t.playback.content.video is True + + +def test_off_from_video_fades_to_black_and_silences(): + p = Player(LIB) + p.update(_controls(content="video")) + t = p.update(_controls(content="off")) + assert t.kind == TransitionKind.FADE_TO_BLACK + assert t.playback.clip_id is None + assert t.playback.content.audio_source == "none" + + +def test_no_change_yields_none_transition(): + p = Player(LIB) + p.update(_controls(content="video", left=1)) + t = p.update(_controls(content="video", left=1)) + assert t.kind == TransitionKind.NONE + + +def test_grade_change_is_a_live_update_not_a_crossfade(): + # design §4.3: the Dark/Light grade is a continuous runtime op + p = Player(LIB) + p.update(_controls(content="video", dark=0, light=0)) + t = p.update(_controls(content="video", dark=4, light=0)) + assert t.kind == TransitionKind.LIVE_UPDATE + assert t.playback.plan.grade.tone == -1.0 + + +def test_overlay_change_is_a_live_update(): + p = Player(LIB) + p.update(_controls(content="video", left=0)) + t = p.update(_controls(content="video", left=4)) + assert t.kind == TransitionKind.LIVE_UPDATE + assert t.playback.plan.overlay.intensity == 1.0 + + +def test_restyle_change_crossfades_the_substrate(): + # design §4.3: the Right v2v substrate is a pre-baked variant swap + p = Player(LIB) + p.update(_controls(content="video", right=0)) + t = p.update(_controls(content="video", right=4)) + assert t.kind == TransitionKind.CROSSFADE + assert t.playback.plan.restyle.blend == 1.0 + + +def test_volume_only_change_is_a_live_update(): + p = Player(LIB) + p.update(_controls(content="video", volume=1)) + t = p.update(_controls(content="video", volume=4)) + assert t.kind == TransitionKind.LIVE_UPDATE + assert t.playback.volume == 4 + + +def test_audio_source_change_while_black_is_a_live_update(): + p = Player(LIB) + p.update(_controls(content="white_noise")) + t = p.update(_controls(content="music")) + assert t.kind == TransitionKind.LIVE_UPDATE + assert t.playback.content.audio_source == "music" + + +def test_injected_base_chooser_is_used(): + p = Player(LIB, choose_base=lambda lib: lib[1]) + t = p.update(_controls(content="video")) + assert t.playback.clip_id == "base-b" + + +def test_empty_library_with_video_raises(): + p = Player([]) + with pytest.raises(ValueError): + p.update(_controls(content="video")) + + +def test_off_with_empty_library_is_fine(): + p = Player([]) + # levels at 0 match the initial black state, so this is a no-op transition + t = p.update(_controls(content="off", volume=0, brightness=0)) + assert t.kind == TransitionKind.NONE # already black at init + assert t.playback.clip_id is None + + +def test_off_with_levels_from_black_is_a_live_update(): + p = Player([]) + t = p.update(_controls(content="off", volume=3, brightness=2)) + assert t.kind == TransitionKind.LIVE_UPDATE # black->black, levels set + assert t.playback.clip_id is None + assert t.playback.volume == 3