121 lines
3.8 KiB
Python
121 lines
3.8 KiB
Python
"""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)
|