streamo 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
streamo/composition.py ADDED
@@ -0,0 +1,156 @@
1
+ from .config import Streamo
2
+ from .providers import FfmpegDestination, FfmpegOutput, ingest_output
3
+
4
+ LOCAL_DISPLAY_URL = 'udp://127.0.0.1:23000?pkt_size=1316'
5
+
6
+
7
+ def ffmpeg_command(
8
+ config: Streamo,
9
+ *,
10
+ output: FfmpegOutput | None = None,
11
+ image_pipe: int | None = None,
12
+ preview: bool = False,
13
+ ) -> list[str]:
14
+ encoding = config.streaming_service.encoding
15
+ command = [
16
+ 'ffmpeg',
17
+ '-hide_banner',
18
+ '-loglevel',
19
+ 'warning',
20
+ '-nostats',
21
+ '-progress',
22
+ 'pipe:2',
23
+ '-f',
24
+ 'f32le',
25
+ '-ar',
26
+ str(config.sample_rate),
27
+ '-ac',
28
+ '2',
29
+ '-i',
30
+ 'pipe:0',
31
+ ]
32
+ if encoding.video is not None:
33
+ assert config.video is not None
34
+ command.extend(
35
+ [
36
+ '-re',
37
+ '-stream_loop',
38
+ '-1',
39
+ '-i',
40
+ config.video.as_posix(),
41
+ ]
42
+ )
43
+ if encoding.video is not None and config.live_overlays:
44
+ if image_pipe is None:
45
+ raise ValueError('overlay pipe is required when live_overlays is enabled')
46
+ command.extend(
47
+ [
48
+ '-f',
49
+ 'rawvideo',
50
+ '-pixel_format',
51
+ 'rgba',
52
+ '-video_size',
53
+ encoding.video.resolution,
54
+ '-framerate',
55
+ str(config.overlay_frame_rate),
56
+ '-i',
57
+ f'pipe:{image_pipe}',
58
+ '-filter_complex',
59
+ overlay_filter(config),
60
+ '-map',
61
+ '[video]',
62
+ ]
63
+ )
64
+ elif encoding.video is not None:
65
+ command.extend(['-map', '1:v:0'])
66
+ command.extend(['-map', '0:a:0'])
67
+ if encoding.video is not None:
68
+ command.extend(
69
+ [
70
+ '-c:v',
71
+ VIDEO_CODECS[encoding.video.codec],
72
+ '-b:v',
73
+ str(encoding.video.bitrate),
74
+ '-pix_fmt',
75
+ encoding.video.pixel_format,
76
+ '-r',
77
+ str(encoding.video.frame_rate),
78
+ '-s',
79
+ encoding.video.resolution,
80
+ '-g',
81
+ str(
82
+ round(encoding.video.frame_rate * encoding.video.keyframe_interval)
83
+ ),
84
+ ]
85
+ )
86
+ if encoding.video.codec in {'h264', 'hevc'}:
87
+ command.extend(['-preset', 'veryfast'])
88
+ if encoding.video.codec == 'h264':
89
+ command.extend(['-tune', 'animation'])
90
+ command.extend(
91
+ [
92
+ '-c:a',
93
+ AUDIO_CODECS[encoding.audio.codec],
94
+ '-b:a',
95
+ str(encoding.audio.bitrate),
96
+ '-ar',
97
+ str(encoding.audio.sample_rate),
98
+ '-ac',
99
+ str(encoding.audio.channels),
100
+ ]
101
+ )
102
+ if preview:
103
+ command.extend(['-f', 'nut', 'pipe:1'])
104
+ else:
105
+ command.extend(
106
+ (
107
+ output
108
+ if output is not None
109
+ else local_display_output(
110
+ config, ingest_output(config.streaming_service)
111
+ )
112
+ ).arguments
113
+ )
114
+ return command
115
+
116
+
117
+ def local_display_output(config: Streamo, output: FfmpegOutput | None) -> FfmpegOutput:
118
+ output = output or ingest_output(config.streaming_service)
119
+ if not config.local_display or config.streaming_service.encoding.video is None:
120
+ return output
121
+ return output.model_copy(
122
+ update={
123
+ 'destinations': [
124
+ *output.destinations,
125
+ FfmpegDestination(
126
+ muxer='mpegts', url=LOCAL_DISPLAY_URL, secret_url=False
127
+ ),
128
+ ]
129
+ }
130
+ )
131
+
132
+
133
+ def overlay_filter(config: Streamo) -> str:
134
+ encoding = config.streaming_service.encoding.video
135
+ assert encoding is not None
136
+ width, height = encoding.resolution.lower().split('x')
137
+ return (
138
+ f'[1:v]scale={width}:{height},fps={encoding.frame_rate},format=yuv420p[base];'
139
+ '[2:v]setpts=PTS-STARTPTS[live];'
140
+ '[base][live]overlay=0:0:eof_action=pass[video]'
141
+ )
142
+
143
+
144
+ AUDIO_CODECS = {
145
+ 'aac': 'aac',
146
+ 'mp3': 'libmp3lame',
147
+ 'opus': 'libopus',
148
+ 'vorbis': 'libvorbis',
149
+ }
150
+
151
+
152
+ VIDEO_CODECS = {
153
+ 'h264': 'libx264',
154
+ 'hevc': 'libx265',
155
+ 'av1': 'libaom-av1',
156
+ }
streamo/config.py ADDED
@@ -0,0 +1,308 @@
1
+ import threading
2
+ import time
3
+ from math import isfinite
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING, Self
6
+
7
+ from PIL import Image
8
+ from pydantic import ConfigDict, Field, PrivateAttr, field_validator, model_validator
9
+ from reccy.configuration import units
10
+ from reccy.protocol import ipc, rpc
11
+ from reccy.reccy import Reccy, ReccyStatus
12
+ from reccy.services.spec import load
13
+
14
+ from .closing import DEFAULT_CREDITS, ClosingCredits, ClosingSequence
15
+ from .image_feed import ImageFeed, ImageFeedPoller
16
+ from .images import image_paths
17
+ from .provider_config import StreamingServiceConfiguration, pixel_resolution
18
+ from .providers import GenericServiceAdapter, adapter_for
19
+ from .runtime import HealthWarnings, RuntimeState
20
+
21
+ STREAMO_SERVICE = load(Path(__file__).with_name('service.toml'))
22
+
23
+ if TYPE_CHECKING:
24
+ from .control import ControlController
25
+
26
+
27
+ class Streamo(Reccy, frozen=True):
28
+ name = 'streamo'
29
+ service_spec = STREAMO_SERVICE
30
+ status_model = ReccyStatus
31
+ rpc_enabled = True
32
+
33
+ device_name: str
34
+ channel: int = Field(
35
+ description='First channel of the stereo input pair, numbered from 1.'
36
+ )
37
+ streaming_service: StreamingServiceConfiguration
38
+ video: Path | None = None
39
+ title_card: Path | None = None
40
+ closing_credits: ClosingCredits | None = None
41
+ closing_state_path: Path = Field(
42
+ default_factory=lambda: Path.home() / '.local/state/streamo/closing.json'
43
+ )
44
+
45
+ sample_rate: units.WholeHertz = 48_000
46
+ overlay_resolution: str = '640x360'
47
+ overlay_frame_rate: units.WholeFramesPerSecond = 10
48
+ title_interval: units.Seconds = 180.0
49
+ title_duration: units.Seconds = 8.0
50
+ title_fade: units.Seconds = 2.0
51
+ local_display: bool = True
52
+ live_overlays: bool = Field(
53
+ default=True,
54
+ description='Enable titles, photos and slate. Requires a restart to change.',
55
+ )
56
+ recover_publish: bool = False
57
+ health_warnings: HealthWarnings = Field(default_factory=HealthWarnings)
58
+ image_dir: list[Path] = Field(default_factory=lambda: [Path('images')])
59
+ image_dir_weights: str | list[int] | None = None
60
+ image_approval_required: bool = False
61
+ image_feed: ImageFeed | None = None
62
+ current_session_image_weight: int = 3
63
+ image_interval: units.Seconds = 0.0
64
+ image_duration: units.Seconds = 8.0
65
+ image_fade: units.Seconds = 2.0
66
+ _controller: 'ControlController | None' = PrivateAttr(default=None)
67
+
68
+ def run(self, *, preview: bool = False) -> int:
69
+ from . import control, streamer
70
+
71
+ self.validate_media()
72
+ service_adapter = (
73
+ GenericServiceAdapter(self.streaming_service)
74
+ if preview
75
+ else adapter_for(self.streaming_service)
76
+ )
77
+ controller = control.ControlController(
78
+ state=RuntimeState(self.health_warnings),
79
+ image_dir=self.incoming_image_dir,
80
+ service=None if preview else service_adapter,
81
+ closing=ClosingSequence(
82
+ self.resolved_closing_credits, self.closing_state_path
83
+ ),
84
+ )
85
+ controller.state.configure_service(service_adapter)
86
+ if preview:
87
+ controller.state.capabilities = []
88
+ object.__setattr__(self, '_controller', controller)
89
+ image_feed_poller = (
90
+ None
91
+ if self.image_feed is None
92
+ or not self.live_overlays
93
+ or self.streaming_service.encoding.video is None
94
+ else ImageFeedPoller(self.image_feed, self.incoming_image_dir)
95
+ )
96
+ initial_image_paths = {
97
+ p
98
+ for d in [*self.image_dir, self.incoming_image_dir]
99
+ for p in image_paths(d)
100
+ }
101
+ self.start()
102
+ try:
103
+ if image_feed_poller is not None:
104
+ image_feed_poller.start()
105
+ returncode = streamer.stream(
106
+ self,
107
+ controller,
108
+ service_adapter,
109
+ initial_image_paths=initial_image_paths,
110
+ preview=preview,
111
+ )
112
+ if returncode:
113
+ self.publish_error(f'ffmpeg exited with {returncode}')
114
+ if controller.closing is not None and (
115
+ controller.closing.snapshot()['state'] == 'completed'
116
+ ):
117
+ while not streamer.should_stop(controller):
118
+ time.sleep(0.1)
119
+ return returncode
120
+ finally:
121
+ if image_feed_poller is not None:
122
+ image_feed_poller.stop()
123
+ self.close()
124
+
125
+ def validate_media(self) -> None:
126
+ if self.streaming_service.encoding.video is not None:
127
+ if self.video is None or not self.video.is_file():
128
+ raise ValueError(f'Video file is unavailable: {self.video}')
129
+ with self.video.open('rb'):
130
+ pass
131
+ if self.live_overlays and self.title_card is not None:
132
+ with Image.open(self.title_card) as image:
133
+ image.load()
134
+ if self.resolved_closing_credits is not None:
135
+ from .overlays import render_text
136
+
137
+ width, height = (int(v) for v in self.overlay_resolution.split('x'))
138
+ for page in self.resolved_closing_credits.pages:
139
+ if page.image is not None:
140
+ with Image.open(page.image) as image:
141
+ image.load()
142
+ else:
143
+ assert page.text is not None
144
+ render_text(page.text, (width, height))
145
+
146
+ @field_validator('overlay_resolution')
147
+ @classmethod
148
+ def validate_resolution(cls, value: str) -> str:
149
+ width, height = pixel_resolution(value)
150
+ if width > 1920 or height > 1080:
151
+ raise ValueError('overlay_resolution must not exceed 1920x1080')
152
+ return f'{width}x{height}'
153
+
154
+ def rpc_response(
155
+ self, request: rpc.Request, cancelled: threading.Event
156
+ ) -> rpc.Result:
157
+ if cancelled.is_set():
158
+ return ipc.Error(type='error', message='request was cancelled')
159
+ if request.command == 'preflight':
160
+ from .preflight import check
161
+
162
+ if request.params:
163
+ return ipc.Error(
164
+ type='error',
165
+ message=(
166
+ 'RPC preflight accepts no parameters; '
167
+ 'use the CLI for explicit probes'
168
+ ),
169
+ )
170
+ return check(self).model_dump(mode='json')
171
+ if self._controller is None:
172
+ return ipc.Error(type='error', message='streamO is not running')
173
+ return self._controller.handle_request(request)
174
+
175
+ @field_validator('channel', 'sample_rate', 'overlay_frame_rate')
176
+ @classmethod
177
+ def validate_positive(cls, value: int) -> int:
178
+ if value <= 0:
179
+ raise ValueError('must be positive')
180
+ return value
181
+
182
+ @model_validator(mode='after')
183
+ def validate_resource_limits(self) -> Self:
184
+ if self.sample_rate > 192_000:
185
+ raise ValueError('sample_rate must not exceed 192000')
186
+ if self.overlay_frame_rate > 60:
187
+ raise ValueError('overlay_frame_rate must not exceed 60')
188
+ return self
189
+
190
+ @field_validator('current_session_image_weight')
191
+ @classmethod
192
+ def validate_nonnegative_weight(cls, value: int) -> int:
193
+ if not isfinite(value) or value < 0:
194
+ raise ValueError('must not be negative')
195
+ return value
196
+
197
+ @model_validator(mode='after')
198
+ def validate_image_dirs(self) -> Self:
199
+ if not self.image_dir:
200
+ raise ValueError('image_dir must contain at least one directory')
201
+ if len(set(self.image_dir)) != len(self.image_dir):
202
+ raise ValueError('image_dir entries must be distinct')
203
+ if self.incoming_image_dir.resolve() in {p.resolve() for p in self.image_dir}:
204
+ raise ValueError('image_dir must not include the incoming image inbox')
205
+ if self.image_dir_weights is not None:
206
+ weights = parse_image_dir_weights(self.image_dir_weights)
207
+ if not weights:
208
+ raise ValueError('image_dir_weights must contain at least one weight')
209
+ if len(weights) > len(self.image_dir):
210
+ raise ValueError('image_dir_weights must not exceed image_dir')
211
+ if any(w <= 0 for w in weights):
212
+ raise ValueError('image_dir_weights must be positive')
213
+ return self
214
+
215
+ @property
216
+ def primary_image_dir(self) -> Path:
217
+ return self.image_dir[0]
218
+
219
+ @property
220
+ def incoming_image_dir(self) -> Path:
221
+ return self.primary_image_dir / 'incoming'
222
+
223
+ @property
224
+ def resolved_image_dir_weights(self) -> list[int]:
225
+ if self.image_dir_weights is None:
226
+ return list(range(len(self.image_dir), 0, -1))
227
+ weights = parse_image_dir_weights(self.image_dir_weights)
228
+ return weights + [weights[-1]] * (len(self.image_dir) - len(weights))
229
+
230
+ @field_validator(
231
+ 'title_interval',
232
+ 'title_duration',
233
+ 'title_fade',
234
+ 'image_interval',
235
+ 'image_duration',
236
+ 'image_fade',
237
+ )
238
+ @classmethod
239
+ def validate_nonnegative_time(cls, value: float) -> float:
240
+ if not isfinite(value) or value < 0:
241
+ raise ValueError('must be finite and nonnegative')
242
+ return value
243
+
244
+ @model_validator(mode='after')
245
+ def validate_title_card(self) -> Self:
246
+ if not self.live_overlays:
247
+ return self
248
+ if self.title_card is not None and not self.title_card.is_file():
249
+ raise ValueError(f'{self.title_card} does not exist')
250
+ if self.title_interval <= 0:
251
+ raise ValueError('title_interval must be positive')
252
+ if self.title_duration <= 0:
253
+ raise ValueError('title_duration must be positive')
254
+ if self.title_duration >= self.title_interval:
255
+ raise ValueError('title_duration must be shorter than title_interval')
256
+ if self.title_fade * 2 > self.title_duration:
257
+ raise ValueError('title_fade must fit within title_duration')
258
+ return self
259
+
260
+ @model_validator(mode='after')
261
+ def validate_image_overlay(self) -> Self:
262
+ if self.image_interval == 0:
263
+ if self.image_feed is not None:
264
+ raise ValueError('image_interval must be positive with an image feed')
265
+ return self
266
+ if self.image_duration <= 0:
267
+ raise ValueError('image_duration must be positive')
268
+ if self.image_duration >= self.image_interval:
269
+ raise ValueError('image_duration must be shorter than image_interval')
270
+ if self.image_fade * 2 > self.image_duration:
271
+ raise ValueError('image_fade must fit within image_duration')
272
+ return self
273
+
274
+ @model_validator(mode='after')
275
+ def validate_video(self) -> Self:
276
+ if self.streaming_service.encoding.video is not None and self.video is None:
277
+ raise ValueError('video is required for video streaming')
278
+ if self.closing_credits is not None and (
279
+ self.streaming_service.encoding.video is None or not self.live_overlays
280
+ ):
281
+ raise ValueError('closing_credits requires video and live_overlays')
282
+ return self
283
+
284
+ @property
285
+ def required_channels(self) -> int:
286
+ return self.channel + 1
287
+
288
+ @property
289
+ def resolved_closing_credits(self) -> ClosingCredits | None:
290
+ if self.streaming_service.encoding.video is None or not self.live_overlays:
291
+ return None
292
+ return self.closing_credits or DEFAULT_CREDITS
293
+
294
+ model_config = ConfigDict(hide_input_in_errors=True, extra='forbid')
295
+
296
+
297
+ def parse_image_dir_weights(value: str | list[int]) -> list[int]:
298
+ if isinstance(value, list):
299
+ return value
300
+ try:
301
+ weights = [int(part.strip()) for part in value.split(',')]
302
+ except ValueError as error:
303
+ raise ValueError(
304
+ 'image_dir_weights must be comma-separated integers'
305
+ ) from error
306
+ if not weights or any(not part.strip() for part in value.split(',')):
307
+ raise ValueError('image_dir_weights must be comma-separated integers')
308
+ return weights