feat(player): player state machine — controls stream to transitions

This commit is contained in:
Ben Stull
2026-06-05 18:08:34 -07:00
parent 79bb86c6a2
commit 9a13b01a41
2 changed files with 232 additions and 0 deletions
+120
View File
@@ -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)