diff --git a/c64cast.schema.json b/c64cast.schema.json index d095306..bea3032 100644 --- a/c64cast.schema.json +++ b/c64cast.schema.json @@ -785,7 +785,7 @@ "number", "null" ], - "description": "Seconds before auto-advance. Unset = scene-type default. Video scenes reject this (they run until the file ends). For launcher this is the idle timeout (reset by player input)." + "description": "Seconds before auto-advance; 0 = run forever. Unset = scene-type default (webcam/blank run forever when they're the only scene, else 30s; waveform = song length or 30s; slideshow/generative = 30s). Video scenes reject this (they run until the file ends). For launcher this is the idle timeout (reset by player input)." }, "target_fps": { "type": [ diff --git a/c64cast/config.py b/c64cast/config.py index 55320cb..e85cf37 100644 --- a/c64cast/config.py +++ b/c64cast/config.py @@ -795,12 +795,17 @@ class SceneCfg: default=None, metadata={"help": "Display name (shown in interstitials/logs; ensemble match key)."}, ) - # None = scene-type default (30s for webcam/blank, songlengths-or-30s for - # waveform/midi). Video scenes reject any value (video-driven). + # None = scene-type default: webcam/blank run forever in a single-scene + # playlist (else 30s so a rotation still advances), songlengths-or-30s for + # waveform/midi, 30s for slideshow/generative. 0 = run forever (any type). + # Video scenes reject any value (video-driven). duration_s: float | None = field( default=None, metadata={ - "help": "Seconds before auto-advance. Unset = scene-type default. " + "help": "Seconds before auto-advance; 0 = run forever. Unset = " + "scene-type default (webcam/blank run forever when they're the " + "only scene, else 30s; waveform = song length or 30s; " + "slideshow/generative = 30s). " "Video scenes reject this (they run until the file ends). " "For launcher this is the idle timeout (reset by player input).", "applies_to": ( @@ -2982,6 +2987,11 @@ def validate_scene_cfg(s: SceneCfg, cfg: Config, *, audio_enabled: bool) -> None "Remove the field (it would be a silent no-op here)." ) + # duration_s = 0 is the "run forever" sentinel; negatives are a typo. + # (Video rejects any duration_s below in _validate_video.) + if s.duration_s is not None and s.duration_s < 0: + raise ValueError(f"duration_s must be >= 0 (0 = run forever), got {s.duration_s!r}") + if s.type == "webcam": mode = _display_mode_for_scene(s.display, s, cfg) elif s.type == "blank": @@ -3468,11 +3478,27 @@ def build_scene( buffered_player=s.asid_buffered_player, name=s.name or "ASID", ) - # Video scenes set their own video-driven duration in __init__ - # (math.inf) — `validate_scene_cfg` above already rejected any explicit - # duration_s on them, so honor that by not overwriting it here. - if s.duration_s is not None and s.type != "video": - scene.duration_s = s.duration_s + # Duration resolution. `scene.duration_s = math.inf` means "run until + # stopped" (the scene never auto-advances). + # * explicit duration_s == 0 → the "run forever" sentinel (any type); + # * explicit duration_s > 0 → honored verbatim; + # * unset (None): webcam/blank default to infinite in a SINGLE-scene + # playlist ("leave the camera running"), but keep the base 30 s in a + # multi-scene playlist so the rotation still advances — an infinite + # live scene never becomes is_done and would wedge the playlist. Every + # other type keeps the default already set above (video's video-driven + # math.inf, waveform's song-length, etc.). + # Video scenes set their own math.inf in __init__ and reject explicit + # duration_s in _validate_video, so leave them untouched here. + if s.type != "video": + # A single configured scene stays single-scene: interleave_videos is + # skipped for a 1-scene playlist (see scenes_from_config), so the + # scene count is the whole story here. + single_scene_playlist = len(cfg.scenes) <= 1 + if s.duration_s is not None: + scene.duration_s = math.inf if s.duration_s == 0 else s.duration_s + elif s.type in ("webcam", "blank") and single_scene_playlist: + scene.duration_s = math.inf if s.target_fps is not None: scene.target_fps = float(s.target_fps) # Per-scene pixel effect (validated frame-bearing in validate_scene_cfg). @@ -3568,7 +3594,8 @@ def scenes_from_config( api, None, HiresDisplayMode(style="edges"), source, cfg.audio, "Live Hi-Res Edges" ) ) - base[-1].duration_s = 30.0 + # The sole scene when nothing is configured — leave it running. + base[-1].duration_s = math.inf if not cfg.playlist.interleave_videos: return base diff --git a/c64cast/scenes.py b/c64cast/scenes.py index 3c6cbd7..37a0c28 100644 --- a/c64cast/scenes.py +++ b/c64cast/scenes.py @@ -283,9 +283,11 @@ def setup(self) -> None: fps_str = f"{self.target_fps:.0f}fps" if self.target_fps else "auto-fps" overlay_names = [getattr(ov, "name", type(ov).__name__) for ov in self.overlays] ov_str = ", ".join(overlay_names) if overlay_names else "no overlays" - # VideoScene sets duration_s = math.inf because its lifetime - # is video-driven; format that distinctly instead of "inf.0s". - dur_str = "video-driven" if math.isinf(self.duration_s) else f"{self.duration_s:.1f}s" + # duration_s = math.inf means the scene never auto-advances: a video + # (lifetime is video-driven, ends on EOF) or a single-scene + # webcam/blank (runs until stopped). Format that distinctly instead + # of "inf.0s". + dur_str = "unbounded" if math.isinf(self.duration_s) else f"{self.duration_s:.1f}s" log.info( "scene %r: mode=%s duration=%s %s [%s]", self.name, mode_name, dur_str, fps_str, ov_str ) diff --git a/config/c64cast.example.toml b/config/c64cast.example.toml index d63bc46..21d75a5 100644 --- a/config/c64cast.example.toml +++ b/config/c64cast.example.toml @@ -607,8 +607,10 @@ profile_interval = 10.0 # seconds between profiler summary lines # Scenes run in declaration order; each plays for `duration_s` seconds, # then advances. Common fields: # type — "webcam" | "video" | "waveform" | "midi" -# duration_s — seconds before advancing. Rejected on `video` -# scenes (they run until the video file ends). +# duration_s — seconds before advancing; 0 = run forever. Unset = +# scene-type default (webcam/blank run forever when they're +# the only scene, else 30s). Rejected on `video` scenes +# (they run until the video file ends). # name — display name (used by the interstitial). Optional. # target_fps — per-scene FPS cap. Default: system rate # (60 NTSC / 50 PAL). Bitmap (hires/mhires) video, webcam, diff --git a/docs/architecture.md b/docs/architecture.md index 8e8ffa8..3eb7250 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -125,7 +125,7 @@ Mechanism (`modes.py`): `setup()` zeroes both VIC banks' bitmap+screen, pins ban ### `scenes.py` — Scene state machine -Playlist calls `setup()` → `process_frame()` * → `teardown()` for each scene, with an interstitial scene (built by an injected `interstitial_factory`) between them. `WebcamScene` is tuned for low latency: each camera frame is pushed straight through, no delay buffer. Audio follows the global `[audio].enabled` flag — when on, the webcam/blank scene picks up the AudioStreamer automatically; a per-scene `audio = false` opts back out (useful for muting one segment in an otherwise audible playlist). When attached it runs uncorrelated to the video — sync between mic and display isn't preserved. Backpressure is handled at the Playlist layer via deadline-based frame-dropping rather than a per-scene queue check — the DMA socket's TCP send buffer absorbs short bursts, and missed frames get dropped at the deadline instead of bursting to catch up. `VideoScene` is the opposite: it uses the audio playback position as the master clock and picks the closest video frame against it, so A/V never drift. Its lifetime is video-driven — `process_frame` returns False once the source reports `finished`, and `__init__` pins `self.duration_s = math.inf` so the base-class duration timer can't truncate playback. `config.validate_scene_cfg` rejects any user-supplied `duration_s` on a video cfg (the field would either be a silent no-op or a truncation footgun); `Scene.setup` formats the inf as `duration=video-driven` in the startup log. +Playlist calls `setup()` → `process_frame()` * → `teardown()` for each scene, with an interstitial scene (built by an injected `interstitial_factory`) between them. `WebcamScene` is tuned for low latency: each camera frame is pushed straight through, no delay buffer. Its duration default is context-dependent (resolved in `config.build_scene`): webcam/blank with an unset `duration_s` default to `math.inf` ("leave the camera running") **only when they're the sole scene** in a single-scene playlist — in a multi-scene playlist they keep the base 30 s, because an infinite live scene never becomes `is_done` and would wedge the rotation. An explicit `duration_s = 0` is the universal "run forever" sentinel (mapped to `math.inf` for any non-video type); `< 0` is rejected at load. Audio follows the global `[audio].enabled` flag — when on, the webcam/blank scene picks up the AudioStreamer automatically; a per-scene `audio = false` opts back out (useful for muting one segment in an otherwise audible playlist). When attached it runs uncorrelated to the video — sync between mic and display isn't preserved. Backpressure is handled at the Playlist layer via deadline-based frame-dropping rather than a per-scene queue check — the DMA socket's TCP send buffer absorbs short bursts, and missed frames get dropped at the deadline instead of bursting to catch up. `VideoScene` is the opposite: it uses the audio playback position as the master clock and picks the closest video frame against it, so A/V never drift. Its lifetime is video-driven — `process_frame` returns False once the source reports `finished`, and `__init__` pins `self.duration_s = math.inf` so the base-class duration timer can't truncate playback. `config.validate_scene_cfg` rejects any user-supplied `duration_s` on a video cfg (the field would either be a silent no-op or a truncation footgun); `Scene.setup` formats the inf as `duration=video-driven` in the startup log. Each Scene also carries an `overlays: list[Overlay]`. The Playlist runs every overlay's `setup`/`process_frame`/`teardown` around the scene's lifecycle — scene paints first, overlays paint on top (in declaration order). diff --git a/tests/test_config.py b/tests/test_config.py index f83ae4f..a96561b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import math import os import tempfile import unittest @@ -464,6 +465,16 @@ def test_valid_blank_scene_passes(self): s = cfgmod.SceneCfg(type="blank") cfgmod.validate_scene_cfg(s, self._cfg(), audio_enabled=False) + def test_negative_duration_rejected(self): + s = cfgmod.SceneCfg(type="blank", duration_s=-1.0) + with self.assertRaisesRegex(ValueError, "duration_s must be >= 0"): + cfgmod.validate_scene_cfg(s, self._cfg(), audio_enabled=False) + + def test_zero_duration_allowed(self): + # 0 is the "run forever" sentinel, not an error. + s = cfgmod.SceneCfg(type="blank", duration_s=0) + cfgmod.validate_scene_cfg(s, self._cfg(), audio_enabled=False) + def test_blank_scene_rejects_wrong_display(self): s = cfgmod.SceneCfg(type="blank", display="mhires") with self.assertRaisesRegex(ValueError, "blank scene must use"): @@ -939,6 +950,65 @@ def test_pre_emphasis_defaults_to_none_auto(self): self.assertIsNone(scene.pre_emphasis) +class SceneDurationDefaultTest(unittest.TestCase): + """build_scene's duration resolution: webcam/blank default to infinite + in a single-scene playlist ("leave the camera running"), keep 30 s in a + multi-scene playlist (so the rotation still advances), and treat + duration_s = 0 as a universal "run forever" sentinel.""" + + def setUp(self): + import os + import sys + from typing import cast + + from c64cast.api import Ultimate64API + from c64cast.video import WebcamSource + + sys.path.insert(0, os.path.join(os.path.dirname(__file__))) + from _fakes import FakeAPI + + self.api = cast(Ultimate64API, FakeAPI()) + self.source = cast(WebcamSource, object()) + + def _cfg(self, *scenes: cfgmod.SceneCfg) -> cfgmod.Config: + cfg = cfgmod.Config() + cfg.scenes = list(scenes) + return cfg + + def _build(self, cfg: cfgmod.Config, s: cfgmod.SceneCfg): + return cfgmod.build_scene(s, cfg, self.api, None, self.source) + + def test_single_scene_webcam_unset_is_infinite(self): + s = cfgmod.SceneCfg(type="webcam", display="petscii") + scene = self._build(self._cfg(s), s) + self.assertTrue(math.isinf(scene.duration_s)) + + def test_single_scene_blank_unset_is_infinite(self): + s = cfgmod.SceneCfg(type="blank") + scene = self._build(self._cfg(s), s) + self.assertTrue(math.isinf(scene.duration_s)) + + def test_multi_scene_webcam_unset_stays_30s(self): + # Two scenes → rotation; an infinite live scene would wedge it, so + # the webcam keeps the finite base default and advances. + s = cfgmod.SceneCfg(type="webcam", display="petscii") + other = cfgmod.SceneCfg(type="blank") + scene = self._build(self._cfg(s, other), s) + self.assertEqual(scene.duration_s, 30.0) + + def test_zero_is_run_forever_sentinel_even_in_multi_scene(self): + # Explicit 0 overrides the finite multi-scene default. + s = cfgmod.SceneCfg(type="webcam", display="petscii", duration_s=0) + other = cfgmod.SceneCfg(type="blank") + scene = self._build(self._cfg(s, other), s) + self.assertTrue(math.isinf(scene.duration_s)) + + def test_positive_duration_honored(self): + s = cfgmod.SceneCfg(type="webcam", display="petscii", duration_s=45.0) + scene = self._build(self._cfg(s), s) + self.assertEqual(scene.duration_s, 45.0) + + class FollowerOnlyRotationFilterTest(unittest.TestCase): """scenes_from_config skips follower_only scenes — they're available for follower-override lookup via cfg.scenes but must never reach the