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/__init__.py +1 -0
- streamo/__main__.py +20 -0
- streamo/audio.py +160 -0
- streamo/auth.py +204 -0
- streamo/closing.py +229 -0
- streamo/composition.py +156 -0
- streamo/config.py +308 -0
- streamo/control.py +319 -0
- streamo/credentials.py +23 -0
- streamo/daemon.py +90 -0
- streamo/ffmpeg_progress.py +24 -0
- streamo/image_feed.py +200 -0
- streamo/images.py +276 -0
- streamo/kick_api.py +392 -0
- streamo/moderation.py +122 -0
- streamo/overlays.py +379 -0
- streamo/preflight.py +443 -0
- streamo/provider_config.py +390 -0
- streamo/providers.py +461 -0
- streamo/runtime.py +396 -0
- streamo/service.toml +6 -0
- streamo/streamer.py +512 -0
- streamo/twitch_api.py +227 -0
- streamo/youtube_api.py +380 -0
- streamo-0.2.0.dist-info/METADATA +46 -0
- streamo-0.2.0.dist-info/RECORD +29 -0
- streamo-0.2.0.dist-info/WHEEL +4 -0
- streamo-0.2.0.dist-info/entry_points.txt +2 -0
- streamo-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|