Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion c64cast.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
45 changes: 36 additions & 9 deletions c64cast/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": (
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions c64cast/scenes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
6 changes: 4 additions & 2 deletions config/c64cast.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
70 changes: 70 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations

import argparse
import math
import os
import tempfile
import unittest
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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
Expand Down