From 01bcf2be85d1374dc8bc739a1978acaeb53286e1 Mon Sep 17 00:00:00 2001 From: "Impy[bot]" <314283863+ii-impy@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:55:57 -0500 Subject: [PATCH] Playlist player: shader Setups and full-screen videos, shuffled together playlist_app.py plays one shuffle bag of Setups and videos from playlist.json. Setups play setup_duration_s (300 s) each, overriding their own duration_s; videos play to the end of their file, silent and letterboxed, decoded with PyAV on a background thread. Missing or broken items are skipped; the camera runs throughout so calibration happens once. shader_app.py's main() is split into open_display / start_camera / drain_camera / ShaderStage so both apps share them; behaviour unchanged. Video files live in the gitignored videos/ folder. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 4 + ShowControl/FundingCAPTCHA/playlist.json | 6 + ShowControl/FundingCAPTCHA/playlist_app.py | 421 ++++++++++++++++++ ShowControl/FundingCAPTCHA/requirements.txt | 1 + ShowControl/FundingCAPTCHA/shader_app.py | 274 +++++++----- .../FundingCAPTCHA/test_playlist_app.py | 203 +++++++++ ShowControl/FundingCAPTCHA/videos/README.md | 8 + docs/FundingCAPTCHA.md | 13 + 8 files changed, 816 insertions(+), 114 deletions(-) create mode 100644 ShowControl/FundingCAPTCHA/playlist.json create mode 100644 ShowControl/FundingCAPTCHA/playlist_app.py create mode 100644 ShowControl/FundingCAPTCHA/test_playlist_app.py create mode 100644 ShowControl/FundingCAPTCHA/videos/README.md diff --git a/.gitignore b/.gitignore index 436a3ac..678c42b 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ ShowControl/FundingCAPTCHA/distribution/build_work/ # Private show content — a clone of the private assets repo lives here. # Never commit anything under this path; scripts/git-hooks/pre-commit enforces it. ShowControl/FundingCAPTCHA/images/private/ + +# playlist_app.py videos — copied onto the show PC, not committed +ShowControl/FundingCAPTCHA/videos/* +!ShowControl/FundingCAPTCHA/videos/README.md diff --git a/ShowControl/FundingCAPTCHA/playlist.json b/ShowControl/FundingCAPTCHA/playlist.json new file mode 100644 index 0000000..d6296c1 --- /dev/null +++ b/ShowControl/FundingCAPTCHA/playlist.json @@ -0,0 +1,6 @@ +{ + "_comment": "playlist_app.py: one shuffle bag of shader Setups (shaders//) and videos (files in videos/, which is gitignored — copy them onto the show PC). Each Setup plays setup_duration_s, overriding its own duration_s; each video plays to the end of its file, silent and letterboxed. A missing or unreadable item is skipped.", + "setup_duration_s": 300, + "setups": ["ripples", "endless_sunset", "hunter_bird", "watering_can", "rainbow_poop"], + "videos": [] +} diff --git a/ShowControl/FundingCAPTCHA/playlist_app.py b/ShowControl/FundingCAPTCHA/playlist_app.py new file mode 100644 index 0000000..1cfec29 --- /dev/null +++ b/ShowControl/FundingCAPTCHA/playlist_app.py @@ -0,0 +1,421 @@ +"""Playlist player — shader Setups and full-screen videos, shuffled together. + +playlist.json lists Setups (shaders//) and videos (videos/, +gitignored: copy them onto the show PC). One shuffle bag holds both, so +every item plays once before any repeats. A Setup plays for the playlist's +setup_duration_s (overriding its own duration_s); a video plays to the end +of its file, silent and letterboxed. + +The camera runs throughout, so calibration happens once and Setups find the +Body live whenever they come round. + + python3 playlist_app.py --camera # show + python3 playlist_app.py --mock-camera --windowed # drifting fake bodies + python3 playlist_app.py --test-input --windowed # mouse paints the Body + +Keys: N / → next item · R reload current Setup from disk · Esc quit. +""" +from __future__ import annotations + +import argparse +import json +import logging +import math +import queue +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +import numpy as np + +from app import DIR, load_settings +from arc import ShuffleBag +from shader_app import (SHADER_DIR, ShaderStage, add_mode_args, drain_camera, + open_display, start_camera) +from shadertoy import Renderer, load_setup + +log = logging.getLogger("playlist_app") + +PLAYLIST_PATH = DIR / "playlist.json" +VIDEO_DIR = DIR / "videos" +RETRY_S = 5.0 # nothing would play: try the bag again after this +QUEUE_FRAMES = 8 # decoded frames buffered ahead of the screen + + +# ── Playlist file ───────────────────────────────────────────────────────────── + +@dataclass(frozen=True) +class Item: + kind: str # "setup" | "video" + name: str # Setup folder name, or video path (relative to videos/) + + def __str__(self) -> str: + return f"{self.kind} {self.name}" + + +@dataclass +class PlaylistConfig: + items: list[Item] + setup_duration_s: float + + +def load_playlist(path: Path) -> PlaylistConfig: + cfg = json.loads(Path(path).read_text()) + items = ([Item("setup", n) for n in cfg.get("setups", [])] + + [Item("video", n) for n in cfg.get("videos", [])]) + return PlaylistConfig(items, float(cfg.get("setup_duration_s", 300.0))) + + +# ── Video ───────────────────────────────────────────────────────────────────── + +class VideoPlayer: + """Decodes one file on a thread; frame() hands out the frame due now. + + Time starts at the first decoded frame, so opening the file doesn't eat + into the picture. If decoding falls behind, every frame is still shown — + the video runs long rather than skipping. + """ + + def __init__(self, path: Path, clock: Callable[[], float] = time.monotonic) -> None: + import av + self.path = Path(path) + self._clock = clock + self._container = av.open(str(path)) + try: + self._stream = self._container.streams.video[0] + except IndexError: + self._container.close() + raise ValueError(f"{self.path.name}: no video stream") from None + self._stream.thread_type = "AUTO" + self.rate = float(self._stream.average_rate or 30) + self.error: Exception | None = None + self._q = queue.Queue(maxsize=QUEUE_FRAMES) + self._stop = threading.Event() + self._eof = threading.Event() + self._pending = None # (t, rgb) decoded but not yet due + self._t0 = self._pts0 = None + self._end_at: float | None = None # clock time the last frame shown expires + self._thread = threading.Thread(target=self._decode, daemon=True, + name=f"video {self.path.name}") + self._thread.start() + + def _decode(self) -> None: + try: + for n, frame in enumerate(self._container.decode(self._stream)): + if self._stop.is_set(): + return + t = frame.time if frame.time is not None else n / self.rate + item = (t, frame.to_ndarray(format="rgb24")) + while not self._stop.is_set(): + try: + self._q.put(item, timeout=0.1) + break + except queue.Full: + continue + except Exception as exc: # a corrupt file ends the video, not the show + self.error = exc + finally: + self._eof.set() + + def frame(self) -> np.ndarray | None: + """The newest frame now due (row 0 = top, RGB8), or None if nothing new.""" + now, latest = self._clock(), None + while True: + if self._pending is None: + try: + self._pending = self._q.get_nowait() + except queue.Empty: + break + t, rgb = self._pending + if self._t0 is None: + self._t0, self._pts0 = now, t + if t - self._pts0 > now - self._t0: + break + latest, self._pending = rgb, None + self._end_at = max(now, self._t0 + t - self._pts0) + 1.0 / self.rate + return latest + + @property + def done(self) -> bool: + return (self._eof.is_set() and self._pending is None and self._q.empty() + and (self._end_at is None or self._clock() >= self._end_at)) + + def close(self) -> None: + self._stop.set() + self._thread.join(timeout=2.0) + if self._thread.is_alive(): + log.warning("%s: decoder didn't stop; leaving the file open", self.path.name) + return + self._container.close() + + +def fit_rect(src: tuple[int, int], dst: tuple[int, int]) -> tuple[int, int, int, int]: + """Largest src-aspect rect centred in dst → (x, y, w, h). Letterbox or pillarbox.""" + scale = min(dst[0] / src[0], dst[1] / src[1]) + w, h = max(1, round(src[0] * scale)), max(1, round(src[1] * scale)) + return (dst[0] - w) // 2, (dst[1] - h) // 2, w, h + + +_VIDEO_VERT = """ +#version 330 +in vec2 in_vert; +out vec2 uv; +void main() { + uv = vec2(in_vert.x * 0.5 + 0.5, 0.5 - in_vert.y * 0.5); // texture row 0 = top of picture + gl_Position = vec4(in_vert, 0.0, 1.0); +} +""" + +_VIDEO_FRAG = """ +#version 330 +uniform sampler2D src; +in vec2 uv; +out vec4 c; +void main() { c = vec4(texture(src, uv).rgb, 1.0); } +""" + + +class VideoScreen: + """The latest video frame, letterboxed on black.""" + + def __init__(self, ctx, win: tuple[int, int], vbo) -> None: + import moderngl + self._mgl = moderngl + self.ctx, self.win = ctx, win + self._prog = ctx.program(vertex_shader=_VIDEO_VERT, fragment_shader=_VIDEO_FRAG) + self._vao = ctx.vertex_array(self._prog, [(vbo, "2f", "in_vert")]) + self._tex = None + + def show(self, rgb: np.ndarray) -> None: + size = (rgb.shape[1], rgb.shape[0]) + if self._tex is None or self._tex.size != size: + self.clear() + self._tex = self.ctx.texture(size, 3, alignment=1) + self._tex.filter = (self._mgl.LINEAR, self._mgl.LINEAR) + self._tex.repeat_x = self._tex.repeat_y = False + self._tex.write(np.ascontiguousarray(rgb)) + + def draw(self) -> None: + self.ctx.screen.use() + self.ctx.viewport = (0, 0, *self.win) + self.ctx.clear(0.0, 0.0, 0.0, 1.0) + if self._tex is None: + return + self.ctx.viewport = fit_rect(self._tex.size, self.win) + self._tex.use(location=0) + self._prog["src"].value = 0 + self._vao.render(self._mgl.TRIANGLE_STRIP) + + def clear(self) -> None: + """Forget the last frame, so the next video doesn't open on it.""" + if self._tex is not None: + self._tex.release() + self._tex = None + + def release(self) -> None: + self.clear() + self._vao.release() + self._prog.release() + + +# ── Rotation ────────────────────────────────────────────────────────────────── + +class Playlist: + """Which item plays: one shuffle bag of Setups and videos. + + An item that won't load is skipped for this draw. A Setup that fails at + render time is dropped for the rest of the run (as shader_app.Rotation). + """ + + def __init__(self, renderer: Renderer, items: list[Item], setup_duration_s: float, + shader_dir: Path = SHADER_DIR, video_dir: Path = VIDEO_DIR, + open_video: Callable[[Path], VideoPlayer] = VideoPlayer, + clock: Callable[[], float] = time.monotonic, + on_load: Callable[[], None] | None = None) -> None: + self.renderer = renderer + self.items = list(items) + self.setup_duration_s = setup_duration_s + self._bag = ShuffleBag(self.items) + self._shaders = shader_dir + self._videos = video_dir + self._open_video = open_video + self._clock = clock + self._on_load = on_load # after every Setup load + self.current: Item | None = None + self.video: VideoPlayer | None = None + self.started = clock() + + def next(self) -> None: + if (len(self.items) == 1 and self.current is not None + and self.current.kind == "setup" and self.renderer.setup is not None): + self.started = self._clock() # nothing else to play; keep its state + return + for _ in range(max(1, len(self.items))): + item = self._bag.next() + if item is None: + break + if self._start(item): + return + log.error("Nothing in the playlist would start — retrying in %.0fs", RETRY_S) + self._stop_video() + if self.current is not None and self.current.kind == "video": + self.current = None + self.started = self._clock() + + def tick(self) -> None: + elapsed = self._clock() - self.started + if self.current is None: + if elapsed >= RETRY_S: + self.next() + elif self.current.kind == "setup": + if elapsed >= self.setup_duration_s: + self.next() + elif self.video is not None and self.video.done: + if self.video.error is not None: + log.error("Video %s stopped early: %s", self.current.name, self.video.error) + self.next() + + def reload(self) -> None: + """Re-read the current Setup from disk; a broken edit keeps the old one.""" + if self.current is not None and self.current.kind == "setup": + self._load_setup(self.current.name) + + def failed(self, exc: Exception) -> None: + item = self.current + log.error("%s failed while rendering, dropped: %s", item, exc) + self.renderer.unload() + self.current = None + if item in self.items: + self.items.remove(item) + self._bag = ShuffleBag(self.items) + self.next() + + def close(self) -> None: + self._stop_video() + + @property + def playing_video(self) -> bool: + return self.video is not None + + def _start(self, item: Item) -> bool: + if item.kind == "setup": + if not self._load_setup(item.name): + return False + self._stop_video() + else: + try: + video = self._open_video(self._videos / item.name) + except Exception as exc: # missing / unreadable file: never fatal + log.error("Video %s skipped: %s", item.name, exc) + return False + self._stop_video() + self.renderer.unload() # free the Setup's buffers while the video plays + self.video = video + self.current = item + self.started = self._clock() + log.info("Playing %s", item) + return True + + def _load_setup(self, name: str) -> bool: + try: + self.renderer.load(load_setup(self._shaders / name)) + except Exception as exc: # team-authored content: never fatal + log.error("Setup %s skipped: %s", name, exc) + return False + if self._on_load is not None: + self._on_load() + return True + + def _stop_video(self) -> None: + if self.video is not None: + self.video.close() + self.video = None + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main() -> None: + ap = argparse.ArgumentParser(description="Shader Setups and full-screen videos, shuffled") + add_mode_args(ap) + ap.add_argument("--playlist", type=Path, default=PLAYLIST_PATH, + help="Playlist file (default: playlist.json)") + args = ap.parse_args() + + logging.basicConfig(level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s") + + settings = load_settings() + fps = int(settings.get("shader_app", {}).get("fps", 30)) + playlist_cfg = load_playlist(args.playlist) + for item in playlist_cfg.items: + if item.kind == "video" and not (VIDEO_DIR / item.name).is_file(): + log.warning("Video %s not found in %s — it will be skipped", item.name, VIDEO_DIR) + + ctx, win = open_display(args.windowed, "Playlist player", args.test_input) + stage = ShaderStage(ctx, win, settings, args.test_input) + screen = VideoScreen(ctx, win, stage.renderer.vbo) + playlist = Playlist(stage.renderer, playlist_cfg.items, playlist_cfg.setup_duration_s, + on_load=stage.blobs.reset) + playlist.next() + cam_q, stop = start_camera(args, settings) + + import pygame + clock = pygame.time.Clock() + fps_t, fps_n = time.monotonic(), 0 + running = True + showing = None + while running: + # A video gets a loop at least as fast as its own frame rate. + rate = max(fps, math.ceil(playlist.video.rate)) if playlist.playing_video else fps + dt = min(clock.tick(rate) / 1000.0, 0.25) + + for ev in pygame.event.get(): + if ev.type == pygame.QUIT: + running = False + elif ev.type == pygame.KEYDOWN: + if ev.key == pygame.K_ESCAPE: + running = False + elif ev.key in (pygame.K_n, pygame.K_RIGHT): + playlist.next() + elif ev.key == pygame.K_r: + playlist.reload() + stage.handle_key(ev) + elif ev.type == pygame.KEYUP: + stage.handle_key(ev) + + playlist.tick() + fg = drain_camera(cam_q) # always drain: the camera thread blocks when full + + if playlist.video is not showing: + screen.clear() + showing = playlist.video + if playlist.playing_video: + rgb = playlist.video.frame() + if rgb is not None: + screen.show(rgb) + screen.draw() + elif stage.renderer.setup is not None: + failure = stage.frame(dt, fg) + if failure is not None: + playlist.failed(failure) + else: + screen.draw() # nothing playable: black + pygame.display.flip() + + fps_n += 1 + if time.monotonic() - fps_t >= 10.0: + log.info("%.1f fps · %s", fps_n / (time.monotonic() - fps_t), + playlist.current or "-") + fps_t, fps_n = time.monotonic(), 0 + + stop.set() + playlist.close() + screen.release() + stage.release() + pygame.quit() + + +if __name__ == "__main__": + main() diff --git a/ShowControl/FundingCAPTCHA/requirements.txt b/ShowControl/FundingCAPTCHA/requirements.txt index cd26ae7..30927b0 100644 --- a/ShowControl/FundingCAPTCHA/requirements.txt +++ b/ShowControl/FundingCAPTCHA/requirements.txt @@ -11,6 +11,7 @@ numpy>=1.24 python-osc>=1.8 Pillow>=9.0 # JPEG fallback for SDL_image builds without JPEG (Pi) moderngl>=5.13 # shader_app.py (ADR-0021) +av>=12 # playlist_app.py video decoding (FFmpeg) scipy>=1.10 # shader_app.py blob records (ADR-0022), and the CV pipeline # ── CV pipeline (required only for --camera or --mock-camera) ───────────────── diff --git a/ShowControl/FundingCAPTCHA/shader_app.py b/ShowControl/FundingCAPTCHA/shader_app.py index 6b620e2..71f405d 100644 --- a/ShowControl/FundingCAPTCHA/shader_app.py +++ b/ShowControl/FundingCAPTCHA/shader_app.py @@ -152,90 +152,179 @@ def _setup_names() -> list[str]: return [] -def main() -> None: - ap = argparse.ArgumentParser(description="Shadertoy Setups driven by the depth camera") +# ── Pieces shared with playlist_app.py ──────────────────────────────────────── + +def add_mode_args(ap: argparse.ArgumentParser) -> None: mode = ap.add_mutually_exclusive_group() mode.add_argument("--camera", action="store_true") mode.add_argument("--mock-camera", action="store_true") mode.add_argument("--test-input", action="store_true", help="Mouse paints the Body with shaders/body_painter.glsl, as in Shadertoy") - ap.add_argument("--setup", metavar="NAME", help="Pin one Setup (no cycling)") ap.add_argument("--windowed", action="store_true", help="1280×720 window instead of fullscreen") - args = ap.parse_args() - logging.basicConfig(level=logging.INFO, - format="%(asctime)s %(levelname)s %(name)s: %(message)s") - settings = load_settings() - cfg = settings.get("shader_app", {}) - cam_cfg = settings.get("camera", {}) - slabs = settings.get("depth_slabs", [{"near_mm": 800, "far_mm": 2500}]) - roi = settings.get("camera_roi", - {"x": 0, "y": 0, "w": cam_cfg.get("width", 640), - "h": cam_cfg.get("height", 400)}) - fps = int(cfg.get("fps", 30)) - scale = float(cfg.get("render_scale", 0.5)) - - if (args.camera or args.mock_camera) and not _CV_AVAILABLE: - sys.exit("ERROR: CV libraries not available — pip install -r requirements.txt") - - # ── Display ─────────────────────────────────────────────────────────────── +def open_display(windowed: bool, caption: str, mouse_visible: bool): + """pygame window with a GL 3.3 core context → (moderngl ctx, window size).""" pygame.init() pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MAJOR_VERSION, 3) pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MINOR_VERSION, 3) pygame.display.gl_set_attribute(pygame.GL_CONTEXT_PROFILE_MASK, pygame.GL_CONTEXT_PROFILE_CORE) - if args.windowed: + if windowed: win = (1280, 720) pygame.display.set_mode(win, pygame.OPENGL | pygame.DOUBLEBUF) else: info = pygame.display.Info() win = (info.current_w, info.current_h) pygame.display.set_mode(win, pygame.OPENGL | pygame.DOUBLEBUF | pygame.FULLSCREEN) - pygame.display.set_caption("Shader player") - pygame.mouse.set_visible(args.test_input) - - ctx = moderngl.create_context() # also attaches to SDL's EGL context (kmsdrm) - res = (max(1, int(win[0] * scale)), max(1, int(win[1] * scale))) - log.info("OpenGL %s · window %dx%d · render %dx%d", ctx.version_code, *win, *res) - - renderer = Renderer(ctx, res, body_mode="painter" if args.test_input else "camera") - blit = ctx.program(vertex_shader=VERT, fragment_shader=_BLIT_FRAG) - blit_vao = ctx.vertex_array(blit, [(renderer.vbo, "2f", "in_vert")]) - blit["size"].value = (float(win[0]), float(win[1])) - renderer.output_tex.filter = (moderngl.LINEAR, moderngl.LINEAR) - - # ── Setups ──────────────────────────────────────────────────────────────── - # A load (or R reload) restarts the Setup's buffers, so the tracker restarts - # with them: Setups that remember people by id must not meet ids from before. - blobs = BodyBlobs(BlobConfig.from_dict(cfg.get("blobs", {}))) - rotation = Rotation(renderer, [args.setup] if args.setup else _setup_names(), - on_load=blobs.reset) - rotation.next() + pygame.display.set_caption(caption) + pygame.mouse.set_visible(mouse_visible) + ctx = moderngl.create_context() # also attaches to SDL's EGL context (kmsdrm) + return ctx, win + - # ── Camera ──────────────────────────────────────────────────────────────── - cam_q = queue.Queue(maxsize=32) - stop = threading.Event() - collect = threading.Event() - if args.camera or args.mock_camera: - cam_args = dict(width=cam_cfg.get("width", 640), height=cam_cfg.get("height", 400), - fps=cam_cfg.get("fps", 10)) - camera = (MockCamera(**cam_args) if args.mock_camera - else OrbbecCamera(serial=cam_cfg.get("serial") or None, **cam_args)) - threading.Thread(target=_camera_thread, - args=(camera, settings, cam_q, stop, collect), daemon=True).start() - # Hold off background collection so people can clear the ROI (ADR-0013). - standback = float(settings.get("calibration_standback_s", 5.0)) - timer = threading.Timer(standback, collect.set) - timer.daemon = True - timer.start() - log.info("Camera started (%s) — calibrating in %.0fs, keep the floor clear", - "mock" if args.mock_camera else "Orbbec", standback) - - body_mouse = BodyMouse(int(settings.get("min_foreground_pixels", 1))) - painter_read_t = 0.0 - test_mouse = _TestMouse() - mouse = (0.0, 0.0, 0.0, 0.0) +def start_camera(args, settings: dict) -> tuple[queue.Queue, threading.Event]: + """Start the camera thread for --camera / --mock-camera (else an idle queue). + + The thread blocks once the queue is full: drain it every frame. + """ + cam_q = queue.Queue(maxsize=32) + stop = threading.Event() + if not (args.camera or args.mock_camera): + return cam_q, stop + if not _CV_AVAILABLE: + sys.exit("ERROR: CV libraries not available — pip install -r requirements.txt") + cam_cfg = settings.get("camera", {}) + collect = threading.Event() + cam_args = dict(width=cam_cfg.get("width", 640), height=cam_cfg.get("height", 400), + fps=cam_cfg.get("fps", 10)) + camera = (MockCamera(**cam_args) if args.mock_camera + else OrbbecCamera(serial=cam_cfg.get("serial") or None, **cam_args)) + threading.Thread(target=_camera_thread, + args=(camera, settings, cam_q, stop, collect), daemon=True).start() + # Hold off background collection so people can clear the ROI (ADR-0013). + standback = float(settings.get("calibration_standback_s", 5.0)) + timer = threading.Timer(standback, collect.set) + timer.daemon = True + timer.start() + log.info("Camera started (%s) — calibrating in %.0fs, keep the floor clear", + "mock" if args.mock_camera else "Orbbec", standback) + return cam_q, stop + + +def drain_camera(cam_q: queue.Queue): + """Empty the camera queue; return the newest foreground frame, or None.""" + fg = None + while True: + try: + msg = cam_q.get_nowait() + except queue.Empty: + return fg + if msg["type"] == "foreground": + fg = msg["frame"] + elif msg["type"] == "cal_done": + log.info("Calibration done — Body live") + elif msg["type"] == "error": + log.error("Camera: %s", msg["msg"]) + + +class ShaderStage: + """Body in, current Setup rendered, upscaled to the screen.""" + + def __init__(self, ctx, win: tuple[int, int], settings: dict, test_input: bool) -> None: + cfg = settings.get("shader_app", {}) + cam_cfg = settings.get("camera", {}) + scale = float(cfg.get("render_scale", 0.5)) + self.ctx, self.win = ctx, win + self.res = (max(1, int(win[0] * scale)), max(1, int(win[1] * scale))) + self.slabs = settings.get("depth_slabs", [{"near_mm": 800, "far_mm": 2500}]) + self.roi = settings.get("camera_roi", + {"x": 0, "y": 0, "w": cam_cfg.get("width", 640), + "h": cam_cfg.get("height", 400)}) + self.test_input = test_input + log.info("OpenGL %s · window %dx%d · render %dx%d", ctx.version_code, *win, *self.res) + + self.renderer = Renderer(ctx, self.res, body_mode="painter" if test_input else "camera") + self._blit = ctx.program(vertex_shader=VERT, fragment_shader=_BLIT_FRAG) + self._blit_vao = ctx.vertex_array(self._blit, [(self.renderer.vbo, "2f", "in_vert")]) + self._blit["size"].value = (float(win[0]), float(win[1])) + self.renderer.output_tex.filter = (moderngl.LINEAR, moderngl.LINEAR) + + # A load (or R reload) restarts the Setup's buffers, so the tracker restarts + # with them: Setups that remember people by id must not meet ids from before. + self.blobs = BodyBlobs(BlobConfig.from_dict(cfg.get("blobs", {}))) + self._body_mouse = BodyMouse(int(settings.get("min_foreground_pixels", 1))) + self._test_mouse = _TestMouse() + self._painter_read_t = 0.0 + self._mouse = (0.0, 0.0, 0.0, 0.0) + + def handle_key(self, ev) -> None: + """Forward a KEYDOWN / KEYUP to the Setup's keyboard texture.""" + code = js_keycode(ev.key) + if code is None: + return + if ev.type == pygame.KEYDOWN: + self.renderer.keyboard.key_down(code) + else: + self.renderer.keyboard.key_up(code) + + def frame(self, dt: float, fg) -> Exception | None: + """Feed the newest foreground (or None), render, blit. Returns a render failure.""" + uses_blobs = self.renderer.setup is not None and self.renderer.setup.uses_blobs + if fg is not None: + body = pack_body(fg, self.roi, self.slabs) + self.renderer.upload_body(body) + self._mouse = self._body_mouse.update(body, *self.res) + if uses_blobs: + self.blobs.update(body[..., 0] > 0, body[..., 1] / 255.0) + if self.test_input: + self._mouse = self._test_mouse.update(self.win, self.res) + if uses_blobs and time.monotonic() - self._painter_read_t >= PAINTER_TRACK_S: + self._painter_read_t = time.monotonic() + painted = self.renderer.read_body() + self.blobs.update(painted[..., 0] > 0.5, painted[..., 1]) + if uses_blobs: + self.renderer.upload_blobs(self.blobs.records()) + + failure = None + try: + self.renderer.render(dt, self._mouse) + except Exception as exc: + failure = exc + self._mouse = (*self._mouse[:3], -abs(self._mouse[3])) # click flag lasts one frame + self.ctx.screen.use() + self.ctx.viewport = (0, 0, *self.win) + self.renderer.output_tex.use(location=0) + self._blit["src"].value = 0 + self._blit_vao.render(moderngl.TRIANGLE_STRIP) + return failure + + def release(self) -> None: + self._blit_vao.release() + self._blit.release() + self.renderer.release() + + +def main() -> None: + ap = argparse.ArgumentParser(description="Shadertoy Setups driven by the depth camera") + add_mode_args(ap) + ap.add_argument("--setup", metavar="NAME", help="Pin one Setup (no cycling)") + args = ap.parse_args() + + logging.basicConfig(level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s") + + settings = load_settings() + fps = int(settings.get("shader_app", {}).get("fps", 30)) + if (args.camera or args.mock_camera) and not _CV_AVAILABLE: + sys.exit("ERROR: CV libraries not available — pip install -r requirements.txt") + + ctx, win = open_display(args.windowed, "Shader player", args.test_input) + stage = ShaderStage(ctx, win, settings, args.test_input) + rotation = Rotation(stage.renderer, [args.setup] if args.setup else _setup_names(), + on_load=stage.blobs.reset) + rotation.next() + cam_q, stop = start_camera(args, settings) # ── Loop ────────────────────────────────────────────────────────────────── clock = pygame.time.Clock() @@ -256,67 +345,24 @@ def main() -> None: rotation.next() elif ev.key == pygame.K_r: rotation.reload() - code = js_keycode(ev.key) - if code is not None: - renderer.keyboard.key_down(code) + stage.handle_key(ev) elif ev.type == pygame.KEYUP: - code = js_keycode(ev.key) - if code is not None: - renderer.keyboard.key_up(code) + stage.handle_key(ev) rotation.tick() - - # Drain to the newest foreground frame. - fg = None - while True: - try: - msg = cam_q.get_nowait() - except queue.Empty: - break - if msg["type"] == "foreground": - fg = msg["frame"] - elif msg["type"] == "cal_done": - log.info("Calibration done — Body live") - elif msg["type"] == "error": - log.error("Camera: %s", msg["msg"]) - uses_blobs = renderer.setup is not None and renderer.setup.uses_blobs - if fg is not None: - body = pack_body(fg, roi, slabs) - renderer.upload_body(body) - mouse = body_mouse.update(body, *res) - if uses_blobs: - blobs.update(body[..., 0] > 0, body[..., 1] / 255.0) - if args.test_input: - mouse = test_mouse.update(win, res) - if uses_blobs and time.monotonic() - painter_read_t >= PAINTER_TRACK_S: - painter_read_t = time.monotonic() - painted = renderer.read_body() - blobs.update(painted[..., 0] > 0.5, painted[..., 1]) - if uses_blobs: - renderer.upload_blobs(blobs.records()) - - try: - renderer.render(dt, mouse) - except Exception as exc: - rotation.failed(exc) - mouse = (*mouse[:3], -abs(mouse[3])) # click flag lasts one frame - ctx.screen.use() - ctx.viewport = (0, 0, *win) - renderer.output_tex.use(location=0) - blit["src"].value = 0 - blit_vao.render(moderngl.TRIANGLE_STRIP) + failure = stage.frame(dt, drain_camera(cam_q)) + if failure is not None: + rotation.failed(failure) pygame.display.flip() fps_n += 1 if time.monotonic() - fps_t >= 10.0: log.info("%.1f fps · %s", fps_n / (time.monotonic() - fps_t), - renderer.setup.name if renderer.setup else "-") + stage.renderer.setup.name if stage.renderer.setup else "-") fps_t, fps_n = time.monotonic(), 0 stop.set() - blit_vao.release() - blit.release() - renderer.release() + stage.release() pygame.quit() diff --git a/ShowControl/FundingCAPTCHA/test_playlist_app.py b/ShowControl/FundingCAPTCHA/test_playlist_app.py new file mode 100644 index 0000000..94f7a25 --- /dev/null +++ b/ShowControl/FundingCAPTCHA/test_playlist_app.py @@ -0,0 +1,203 @@ +"""playlist_app.py: playlist file, video decoding and pacing, letterbox, rotation.""" +import json +import time +from pathlib import Path + +import numpy as np +import pytest + +from playlist_app import Item, Playlist, VideoPlayer, fit_rect, load_playlist +from shadertoy import Renderer +from test_shadertoy import SOLID, ctx # noqa: F401 (GL context fixture) + + +# ── Playlist file ───────────────────────────────────────────────────────────── + +def test_load_playlist(tmp_path): + p = tmp_path / "playlist.json" + p.write_text(json.dumps({"setup_duration_s": 300, "setups": ["a"], "videos": ["v.mp4"]})) + cfg = load_playlist(p) + assert cfg.items == [Item("setup", "a"), Item("video", "v.mp4")] + assert cfg.setup_duration_s == 300 + + +def test_shipped_playlist_names_real_setups(): + cfg = load_playlist(Path(__file__).parent / "playlist.json") + shaders = Path(__file__).parent / "shaders" + for item in cfg.items: + if item.kind == "setup": + assert (shaders / item.name / "image.glsl").exists(), item.name + + +# ── Letterbox ───────────────────────────────────────────────────────────────── + +def test_fit_rect_letterboxes_wide_video(): + assert fit_rect((1920, 800), (1920, 1080)) == (0, 140, 1920, 800) + + +def test_fit_rect_pillarboxes_tall_video(): + assert fit_rect((1080, 1080), (1920, 1080)) == (420, 0, 1080, 1080) + + +def test_fit_rect_scales_up(): + assert fit_rect((640, 360), (1920, 1080)) == (0, 0, 1920, 1080) + + +# ── Video ───────────────────────────────────────────────────────────────────── + +def _write_video(path: Path, n: int = 6, rate: int = 10) -> Path: + av = pytest.importorskip("av") + with av.open(str(path), "w") as out: + s = out.add_stream("mpeg4", rate=rate) + s.width, s.height, s.pix_fmt = 32, 16, "yuv420p" + for i in range(n): + img = np.zeros((16, 32, 3), dtype=np.uint8) + img[..., 0] = i * 40 # red encodes the frame number + for pkt in s.encode(av.VideoFrame.from_ndarray(img, format="rgb24")): + out.mux(pkt) + for pkt in s.encode(): + out.mux(pkt) + return path + + +def _wait(cond, timeout=5.0): + end = time.monotonic() + timeout + while not cond(): + assert time.monotonic() < end, "timed out" + time.sleep(0.01) + + +def test_video_paces_frames_and_ends(tmp_path): + now = [100.0] + v = VideoPlayer(_write_video(tmp_path / "v.mp4"), clock=lambda: now[0]) + try: + assert v.rate == 10 + _wait(lambda: not v._q.empty()) + first = v.frame() + assert first is not None and first.shape == (16, 32, 3) + assert v.frame() is None # next frame not due yet + now[0] += 0.25 + _wait(lambda: v._eof.is_set()) + mid = v.frame() + assert mid is not None and mid[..., 0].mean() == pytest.approx(80, abs=12) # frame 2 + assert not v.done + now[0] += 0.3 # last frame (t=0.5) due + assert v.frame() is not None + assert not v.done # it still gets its 1/rate on screen + now[0] += 0.1 + assert v.done and v.error is None + finally: + v.close() + + +def test_missing_video_raises(tmp_path): + pytest.importorskip("av") + with pytest.raises(Exception): + VideoPlayer(tmp_path / "nope.mp4") + + +# ── Playlist rotation ───────────────────────────────────────────────────────── + +class _FakeVideo: + rate = 30.0 + + def __init__(self, path): + if not Path(path).name.startswith("ok"): + raise FileNotFoundError(path) + self.path, self.done, self.error, self.closed = Path(path), False, None, False + + def close(self): + self.closed = True + + +def _setups(root: Path, *names: str) -> Path: + for name in names: + (root / name).mkdir() + (root / name / "image.glsl").write_text(SOLID) + (root / name / "setup.json").write_text(json.dumps({"duration_s": 10})) + return root + + +def _playlist(ctx, tmp_path, items, now): + r = Renderer(ctx, (4, 4)) + pl = Playlist(r, items, 300, shader_dir=_setups(tmp_path, "a", "b"), + video_dir=tmp_path, open_video=_FakeVideo, clock=lambda: now[0]) + return r, pl + + +def test_setup_plays_playlist_duration_not_its_own(ctx, tmp_path): + now = [0.0] + r, pl = _playlist(ctx, tmp_path, [Item("setup", "a"), Item("setup", "b")], now) + pl.next() + first = pl.current + now[0] = 11.0 # past the Setup's own duration_s + pl.tick() + assert pl.current == first + now[0] = 300.0 + pl.tick() + assert pl.current != first + r.release() + + +def test_video_plays_until_done_then_closes(ctx, tmp_path): + now = [0.0] + r, pl = _playlist(ctx, tmp_path, [Item("video", "ok.mp4"), Item("setup", "a")], now) + pl._bag.next = iter([Item("video", "ok.mp4"), Item("setup", "a")]).__next__ + pl.next() + video = pl.video + assert pl.playing_video and r.setup is None + now[0] = 1000.0 + pl.tick() + assert pl.video is video # no time limit on videos + video.done = True + pl.tick() + assert video.closed and pl.video is None and pl.current == Item("setup", "a") + assert r.setup is not None and r.setup.name == "a" + r.release() + + +def test_missing_video_is_skipped(ctx, tmp_path): + now = [0.0] + r, pl = _playlist(ctx, tmp_path, [Item("video", "gone.mp4"), Item("setup", "a")], now) + pl._bag.next = iter([Item("video", "gone.mp4"), Item("setup", "a")]).__next__ + pl.next() + assert pl.current == Item("setup", "a") and not pl.playing_video + r.release() + + +def test_nothing_playable_goes_black_then_retries(ctx, tmp_path): + now = [0.0] + r, pl = _playlist(ctx, tmp_path, [Item("video", "gone.mp4")], now) + pl.next() + assert pl.current is None + calls = [] + pl.next = lambda: calls.append(now[0]) + now[0] = 1.0 + pl.tick() + assert calls == [] + now[0] = 5.0 + pl.tick() + assert calls == [5.0] + r.release() + + +def test_render_failure_drops_setup(ctx, tmp_path): + now = [0.0] + r, pl = _playlist(ctx, tmp_path, [Item("setup", "a"), Item("video", "ok.mp4")], now) + pl._bag.next = iter([Item("setup", "a")]).__next__ + pl.next() + pl.failed(RuntimeError("boom")) + assert pl.items == [Item("video", "ok.mp4")] + assert pl.playing_video + r.release() + + +def test_single_setup_is_not_reloaded_on_expiry(ctx, tmp_path): + now = [0.0] + r, pl = _playlist(ctx, tmp_path, [Item("setup", "a")], now) + pl.next() + first = r.setup + now[0] = 300.0 + pl.tick() + assert r.setup is first and pl.started == 300.0 + r.release() diff --git a/ShowControl/FundingCAPTCHA/videos/README.md b/ShowControl/FundingCAPTCHA/videos/README.md new file mode 100644 index 0000000..87e99a1 --- /dev/null +++ b/ShowControl/FundingCAPTCHA/videos/README.md @@ -0,0 +1,8 @@ +# videos/ + +Video files for `playlist_app.py`. Everything here except this README is +gitignored: copy the files onto the show PC by hand, then list their file +names under `"videos"` in `../playlist.json`. + +Any format FFmpeg (PyAV) decodes works. Audio is ignored; the picture is +letterboxed to the screen. diff --git a/docs/FundingCAPTCHA.md b/docs/FundingCAPTCHA.md index 6aca9e9..dd0322e 100644 --- a/docs/FundingCAPTCHA.md +++ b/docs/FundingCAPTCHA.md @@ -75,6 +75,19 @@ To run it as the show, point `deploy/captcha.service`'s `ExecStart` at `shader_a --- +## Playlist player — `playlist_app.py` + +Shader Setups and full-screen videos in one shuffle bag: every item plays once before any repeats. `playlist.json` defines the list: + +- `setups`: Setup folder names from `shaders/`. Each plays for `setup_duration_s` (300 s), which overrides the Setup's own `duration_s`. +- `videos`: file names in `ShowControl/FundingCAPTCHA/videos/`. Each plays to the end of its file, silent and letterboxed. The folder is gitignored, so copy the files onto the show PC. Decoding uses PyAV (`av` in `requirements.txt`). + +A missing or unreadable item is logged and skipped. The camera runs throughout, so calibration happens once at startup. + +Flags: `--camera` / `--mock-camera` / `--test-input` / `--windowed` as `shader_app.py`, and `--playlist PATH` to use another playlist file. Keys: `N` / `→` next item · `R` reload the current Setup from disk · Escape quit. + +--- + ## Settings — `captcha-settings.json` Per-machine overrides go in `captcha-settings.local.json` (loaded second, shallow-merged on top — not gitignored, but per-host).