"""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, the Left overlay AND the Right dream (a deterministic, live luminous haze since session 0013) are all continuous runtime ops (LIVE_UPDATE); only swapping the clip 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.audio import resolve_visual 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. Visual and audio are orthogonal (audio spec §2): `video` is whether the projector shows footage, `audio_source` is the independent audio dial (off / soundtrack / white_noise).""" clip_id: Optional[str] # None = black walls plan: Optional[RenderPlan] # None when black video: bool # whether footage is shown (Visual on/off) audio_source: str # the Audio dial source (off/soundtrack/white_noise) 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, video=False, audio_source="off", 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: video = resolve_visual(controls.visual) if not video: return Playback( clip_id=None, plan=None, video=False, audio_source=controls.audio, 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), video=True, audio_source=controls.audio, 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: # The Right dream is a live filter now (not a substrate swap), so it # no longer forces a crossfade — only a clip (scale) change does. if nxt.clip_id != prev.clip_id: 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)