2722949805
Operator-driven live-UI revision:
- Video + Audio are now Dev-Mode-style on/off toggles (Audio on = soundtrack);
white-noise deferred (removed from the live control; pure machinery kept).
- FIX video-off not blanking: the <video>/<canvas> are GPU-composited and could
paint above the auto-z #black on a real GPU — give #black z-index + its own
layer AND hide the video layers (opacity 0) on video-off.
- FIX no soundtrack on Safari/iOS: Safari unlocks <audio> only when play() runs
INSIDE the user gesture; the start gesture now primes both A/B elements
synchronously, so the async crossfades are allowed to play.
- Toggles wired on 'change' (matches the Dev Mode switch).
- player/audio.py AUDIO_SOURCES -> {off, soundtrack}; tests + E2E updated.
- Verified in headless Chromium AND WebKit: audio plays, video-off blanks both
layers, zero JS errors. 288 tests + 3 Playwright E2E pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
129 lines
4.3 KiB
Python
129 lines
4.3 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, 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)."""
|
|
|
|
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)
|
|
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)
|