Files
human-experience-filter-art/player/state.py
BenStullsBets 2722949805 fix(audio): off/on Audio + Video toggles; fix Safari autoplay + GPU video-off
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>
2026-06-26 12:56:07 -07:00

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)