talks-reducer 0.7.1__py3-none-any.whl → 0.8.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.
- talks_reducer/__about__.py +1 -1
- talks_reducer/cli.py +225 -181
- talks_reducer/discovery.py +78 -22
- talks_reducer/gui/__init__.py +17 -1546
- talks_reducer/gui/__main__.py +1 -1
- talks_reducer/gui/app.py +1385 -0
- talks_reducer/gui/discovery.py +1 -1
- talks_reducer/gui/layout.py +18 -31
- talks_reducer/gui/progress.py +80 -0
- talks_reducer/gui/remote.py +11 -3
- talks_reducer/gui/startup.py +202 -0
- talks_reducer/icons.py +123 -0
- talks_reducer/pipeline.py +65 -31
- talks_reducer/server.py +111 -47
- talks_reducer/server_tray.py +192 -236
- talks_reducer/service_client.py +77 -14
- {talks_reducer-0.7.1.dist-info → talks_reducer-0.8.0.dist-info}/METADATA +24 -2
- talks_reducer-0.8.0.dist-info/RECORD +33 -0
- talks_reducer-0.7.1.dist-info/RECORD +0 -29
- {talks_reducer-0.7.1.dist-info → talks_reducer-0.8.0.dist-info}/WHEEL +0 -0
- {talks_reducer-0.7.1.dist-info → talks_reducer-0.8.0.dist-info}/entry_points.txt +0 -0
- {talks_reducer-0.7.1.dist-info → talks_reducer-0.8.0.dist-info}/licenses/LICENSE +0 -0
- {talks_reducer-0.7.1.dist-info → talks_reducer-0.8.0.dist-info}/top_level.txt +0 -0
talks_reducer/pipeline.py
CHANGED
@@ -6,12 +6,15 @@ import math
|
|
6
6
|
import os
|
7
7
|
import re
|
8
8
|
import subprocess
|
9
|
+
from dataclasses import dataclass
|
9
10
|
from pathlib import Path
|
10
|
-
from typing import Dict
|
11
|
+
from typing import Callable, Dict
|
11
12
|
|
12
13
|
import numpy as np
|
13
14
|
from scipy.io import wavfile
|
14
15
|
|
16
|
+
from talks_reducer.version_utils import resolve_version
|
17
|
+
|
15
18
|
from . import audio as audio_utils
|
16
19
|
from . import chunks as chunk_utils
|
17
20
|
from .ffmpeg import (
|
@@ -23,13 +26,33 @@ from .ffmpeg import (
|
|
23
26
|
)
|
24
27
|
from .models import ProcessingOptions, ProcessingResult
|
25
28
|
from .progress import NullProgressReporter, ProgressReporter
|
26
|
-
from talks_reducer.version_utils import resolve_version
|
27
29
|
|
28
30
|
|
29
31
|
class ProcessingAborted(RuntimeError):
|
30
32
|
"""Raised when processing is cancelled by the caller."""
|
31
33
|
|
32
34
|
|
35
|
+
@dataclass
|
36
|
+
class PipelineDependencies:
|
37
|
+
"""Bundle of external dependencies used by :func:`speed_up_video`."""
|
38
|
+
|
39
|
+
get_ffmpeg_path: Callable[[], str] = get_ffmpeg_path
|
40
|
+
check_cuda_available: Callable[[str], bool] = check_cuda_available
|
41
|
+
build_extract_audio_command: Callable[..., str] = build_extract_audio_command
|
42
|
+
build_video_commands: Callable[..., tuple[str, str | None, bool]] = (
|
43
|
+
build_video_commands
|
44
|
+
)
|
45
|
+
run_timed_ffmpeg_command: Callable[..., None] = run_timed_ffmpeg_command
|
46
|
+
create_path: Callable[[Path], None] | None = None
|
47
|
+
delete_path: Callable[[Path], None] | None = None
|
48
|
+
|
49
|
+
def __post_init__(self) -> None:
|
50
|
+
if self.create_path is None:
|
51
|
+
self.create_path = _create_path
|
52
|
+
if self.delete_path is None:
|
53
|
+
self.delete_path = _delete_path
|
54
|
+
|
55
|
+
|
33
56
|
def _stop_requested(reporter: ProgressReporter | None) -> bool:
|
34
57
|
"""Return ``True`` when *reporter* indicates that processing should stop."""
|
35
58
|
|
@@ -46,7 +69,10 @@ def _stop_requested(reporter: ProgressReporter | None) -> bool:
|
|
46
69
|
|
47
70
|
|
48
71
|
def _raise_if_stopped(
|
49
|
-
reporter: ProgressReporter | None,
|
72
|
+
reporter: ProgressReporter | None,
|
73
|
+
*,
|
74
|
+
temp_path: Path | None = None,
|
75
|
+
dependencies: PipelineDependencies | None = None,
|
50
76
|
) -> None:
|
51
77
|
"""Abort processing when the user has requested a stop."""
|
52
78
|
|
@@ -54,34 +80,40 @@ def _raise_if_stopped(
|
|
54
80
|
return
|
55
81
|
|
56
82
|
if temp_path is not None and temp_path.exists():
|
57
|
-
|
83
|
+
if dependencies is not None:
|
84
|
+
dependencies.delete_path(temp_path)
|
85
|
+
else:
|
86
|
+
_delete_path(temp_path)
|
58
87
|
raise ProcessingAborted("Processing aborted by user request.")
|
59
88
|
|
60
89
|
|
61
90
|
def speed_up_video(
|
62
|
-
options: ProcessingOptions,
|
91
|
+
options: ProcessingOptions,
|
92
|
+
reporter: ProgressReporter | None = None,
|
93
|
+
dependencies: PipelineDependencies | None = None,
|
63
94
|
) -> ProcessingResult:
|
64
95
|
"""Speed up a video by shortening silent sections while keeping sounded sections intact."""
|
65
96
|
|
66
97
|
reporter = reporter or NullProgressReporter()
|
98
|
+
dependencies = dependencies or PipelineDependencies()
|
67
99
|
|
68
100
|
input_path = Path(options.input_file)
|
69
101
|
if not input_path.exists():
|
70
102
|
raise FileNotFoundError(f"Input file not found: {input_path}")
|
71
103
|
|
72
|
-
ffmpeg_path = get_ffmpeg_path()
|
104
|
+
ffmpeg_path = dependencies.get_ffmpeg_path()
|
73
105
|
|
74
106
|
output_path = options.output_file or _input_to_output_filename(
|
75
107
|
input_path, options.small
|
76
108
|
)
|
77
109
|
output_path = Path(output_path)
|
78
110
|
|
79
|
-
cuda_available = check_cuda_available(ffmpeg_path)
|
111
|
+
cuda_available = dependencies.check_cuda_available(ffmpeg_path)
|
80
112
|
|
81
113
|
temp_path = Path(options.temp_folder)
|
82
114
|
if temp_path.exists():
|
83
|
-
|
84
|
-
|
115
|
+
dependencies.delete_path(temp_path)
|
116
|
+
dependencies.create_path(temp_path)
|
85
117
|
|
86
118
|
metadata = _extract_video_metadata(input_path, options.frame_rate)
|
87
119
|
frame_rate = metadata["frame_rate"]
|
@@ -117,7 +149,7 @@ def speed_up_video(
|
|
117
149
|
|
118
150
|
extraction_sample_rate = options.sample_rate
|
119
151
|
|
120
|
-
extract_command = build_extract_audio_command(
|
152
|
+
extract_command = dependencies.build_extract_audio_command(
|
121
153
|
os.fspath(input_path),
|
122
154
|
os.fspath(audio_wav),
|
123
155
|
extraction_sample_rate,
|
@@ -126,7 +158,7 @@ def speed_up_video(
|
|
126
158
|
ffmpeg_path=ffmpeg_path,
|
127
159
|
)
|
128
160
|
|
129
|
-
_raise_if_stopped(reporter, temp_path=temp_path)
|
161
|
+
_raise_if_stopped(reporter, temp_path=temp_path, dependencies=dependencies)
|
130
162
|
reporter.log("Extracting audio...")
|
131
163
|
process_callback = getattr(reporter, "process_callback", None)
|
132
164
|
estimated_total_frames = frame_count
|
@@ -138,7 +170,7 @@ def speed_up_video(
|
|
138
170
|
else:
|
139
171
|
reporter.log("Extract audio target frames: unknown")
|
140
172
|
|
141
|
-
run_timed_ffmpeg_command(
|
173
|
+
dependencies.run_timed_ffmpeg_command(
|
142
174
|
extract_command,
|
143
175
|
reporter=reporter,
|
144
176
|
total=estimated_total_frames if estimated_total_frames > 0 else None,
|
@@ -157,7 +189,7 @@ def speed_up_video(
|
|
157
189
|
samples_per_frame = wav_sample_rate / frame_rate
|
158
190
|
audio_frame_count = int(math.ceil(audio_sample_count / samples_per_frame))
|
159
191
|
|
160
|
-
_raise_if_stopped(reporter, temp_path=temp_path)
|
192
|
+
_raise_if_stopped(reporter, temp_path=temp_path, dependencies=dependencies)
|
161
193
|
|
162
194
|
has_loud_audio = chunk_utils.detect_loud_frames(
|
163
195
|
audio_data,
|
@@ -171,7 +203,7 @@ def speed_up_video(
|
|
171
203
|
|
172
204
|
reporter.log(f"Processing {len(chunks)} chunks...")
|
173
205
|
|
174
|
-
_raise_if_stopped(reporter, temp_path=temp_path)
|
206
|
+
_raise_if_stopped(reporter, temp_path=temp_path, dependencies=dependencies)
|
175
207
|
|
176
208
|
new_speeds = [options.silent_speed, options.sounded_speed]
|
177
209
|
output_audio_data, updated_chunks = audio_utils.process_audio_chunks(
|
@@ -192,7 +224,7 @@ def speed_up_video(
|
|
192
224
|
_prepare_output_audio(output_audio_data),
|
193
225
|
)
|
194
226
|
|
195
|
-
_raise_if_stopped(reporter, temp_path=temp_path)
|
227
|
+
_raise_if_stopped(reporter, temp_path=temp_path, dependencies=dependencies)
|
196
228
|
|
197
229
|
expression = chunk_utils.get_tree_expression(updated_chunks)
|
198
230
|
filter_graph_path = temp_path / "filterGraph.txt"
|
@@ -205,14 +237,16 @@ def speed_up_video(
|
|
205
237
|
filter_parts.append(f"setpts={escaped_expression}")
|
206
238
|
filter_graph_file.write(",".join(filter_parts))
|
207
239
|
|
208
|
-
command_str, fallback_command_str, use_cuda_encoder =
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
240
|
+
command_str, fallback_command_str, use_cuda_encoder = (
|
241
|
+
dependencies.build_video_commands(
|
242
|
+
os.fspath(input_path),
|
243
|
+
os.fspath(audio_new_path),
|
244
|
+
os.fspath(filter_graph_path),
|
245
|
+
os.fspath(output_path),
|
246
|
+
ffmpeg_path=ffmpeg_path,
|
247
|
+
cuda_available=cuda_available,
|
248
|
+
small=options.small,
|
249
|
+
)
|
216
250
|
)
|
217
251
|
|
218
252
|
output_dir = output_path.parent.resolve()
|
@@ -224,14 +258,14 @@ def speed_up_video(
|
|
224
258
|
reporter.log(command_str)
|
225
259
|
|
226
260
|
if not audio_new_path.exists():
|
227
|
-
|
261
|
+
dependencies.delete_path(temp_path)
|
228
262
|
raise FileNotFoundError("Audio intermediate file was not generated")
|
229
263
|
|
230
264
|
if not filter_graph_path.exists():
|
231
|
-
|
265
|
+
dependencies.delete_path(temp_path)
|
232
266
|
raise FileNotFoundError("Filter graph file was not generated")
|
233
267
|
|
234
|
-
_raise_if_stopped(reporter, temp_path=temp_path)
|
268
|
+
_raise_if_stopped(reporter, temp_path=temp_path, dependencies=dependencies)
|
235
269
|
|
236
270
|
try:
|
237
271
|
final_total_frames = updated_chunks[-1][3] if updated_chunks else 0
|
@@ -253,7 +287,7 @@ def speed_up_video(
|
|
253
287
|
|
254
288
|
total_frames_arg = final_total_frames if final_total_frames > 0 else None
|
255
289
|
|
256
|
-
run_timed_ffmpeg_command(
|
290
|
+
dependencies.run_timed_ffmpeg_command(
|
257
291
|
command_str,
|
258
292
|
reporter=reporter,
|
259
293
|
total=total_frames_arg,
|
@@ -261,9 +295,9 @@ def speed_up_video(
|
|
261
295
|
desc="Generating final:",
|
262
296
|
process_callback=process_callback,
|
263
297
|
)
|
264
|
-
except subprocess.CalledProcessError
|
298
|
+
except subprocess.CalledProcessError:
|
265
299
|
if fallback_command_str and use_cuda_encoder:
|
266
|
-
_raise_if_stopped(reporter, temp_path=temp_path)
|
300
|
+
_raise_if_stopped(reporter, temp_path=temp_path, dependencies=dependencies)
|
267
301
|
|
268
302
|
reporter.log("CUDA encoding failed, retrying with CPU encoder...")
|
269
303
|
if final_total_frames > 0:
|
@@ -281,7 +315,7 @@ def speed_up_video(
|
|
281
315
|
fps=frame_rate,
|
282
316
|
)
|
283
317
|
)
|
284
|
-
run_timed_ffmpeg_command(
|
318
|
+
dependencies.run_timed_ffmpeg_command(
|
285
319
|
fallback_command_str,
|
286
320
|
reporter=reporter,
|
287
321
|
total=total_frames_arg,
|
@@ -292,7 +326,7 @@ def speed_up_video(
|
|
292
326
|
else:
|
293
327
|
raise
|
294
328
|
finally:
|
295
|
-
|
329
|
+
dependencies.delete_path(temp_path)
|
296
330
|
|
297
331
|
output_metadata = _extract_video_metadata(output_path, frame_rate)
|
298
332
|
output_duration = output_metadata.get("duration", 0.0)
|
talks_reducer/server.py
CHANGED
@@ -6,8 +6,10 @@ import argparse
|
|
6
6
|
import atexit
|
7
7
|
import shutil
|
8
8
|
import socket
|
9
|
+
import sys
|
9
10
|
import tempfile
|
10
11
|
from contextlib import AbstractContextManager, suppress
|
12
|
+
from dataclasses import dataclass
|
11
13
|
from pathlib import Path
|
12
14
|
from queue import SimpleQueue
|
13
15
|
from threading import Thread
|
@@ -16,6 +18,7 @@ from typing import Callable, Iterator, Optional, Sequence, cast
|
|
16
18
|
import gradio as gr
|
17
19
|
|
18
20
|
from talks_reducer.ffmpeg import FFmpegNotFoundError
|
21
|
+
from talks_reducer.icons import find_icon_path
|
19
22
|
from talks_reducer.models import ProcessingOptions, ProcessingResult
|
20
23
|
from talks_reducer.pipeline import speed_up_video
|
21
24
|
from talks_reducer.progress import ProgressHandle, SignalProgressReporter
|
@@ -144,13 +147,10 @@ class GradioProgressReporter(SignalProgressReporter):
|
|
144
147
|
self._progress_callback(bounded_current, total_value, display_desc)
|
145
148
|
|
146
149
|
|
147
|
-
|
148
|
-
|
149
|
-
Path(__file__).resolve().parent.parent / "docs" / "assets" / "icon.ico",
|
150
|
-
)
|
151
|
-
_FAVICON_PATH: Optional[Path] = next(
|
152
|
-
(path for path in _FAVICON_CANDIDATES if path.exists()), None
|
150
|
+
_FAVICON_FILENAMES = (
|
151
|
+
("app.ico", "app.png") if sys.platform.startswith("win") else ("app.png", "app.ico")
|
153
152
|
)
|
153
|
+
_FAVICON_PATH = find_icon_path(filenames=_FAVICON_FILENAMES)
|
154
154
|
_FAVICON_PATH_STR = str(_FAVICON_PATH) if _FAVICON_PATH else None
|
155
155
|
_WORKSPACES: list[Path] = []
|
156
156
|
|
@@ -245,6 +245,98 @@ def _format_summary(result: ProcessingResult) -> str:
|
|
245
245
|
return "\n".join(lines)
|
246
246
|
|
247
247
|
|
248
|
+
PipelineEvent = tuple[str, object]
|
249
|
+
|
250
|
+
|
251
|
+
def _default_reporter_factory(
|
252
|
+
progress_callback: Optional[Callable[[int, int, str], None]],
|
253
|
+
log_callback: Callable[[str], None],
|
254
|
+
) -> SignalProgressReporter:
|
255
|
+
"""Construct a :class:`GradioProgressReporter` with the given callbacks."""
|
256
|
+
|
257
|
+
return GradioProgressReporter(
|
258
|
+
progress_callback=progress_callback,
|
259
|
+
log_callback=log_callback,
|
260
|
+
)
|
261
|
+
|
262
|
+
|
263
|
+
def run_pipeline_job(
|
264
|
+
options: ProcessingOptions,
|
265
|
+
*,
|
266
|
+
speed_up: Callable[[ProcessingOptions, SignalProgressReporter], ProcessingResult],
|
267
|
+
reporter_factory: Callable[
|
268
|
+
[Optional[Callable[[int, int, str], None]], Callable[[str], None]],
|
269
|
+
SignalProgressReporter,
|
270
|
+
],
|
271
|
+
events: SimpleQueue[PipelineEvent],
|
272
|
+
enable_progress: bool = True,
|
273
|
+
start_in_thread: bool = True,
|
274
|
+
) -> Iterator[PipelineEvent]:
|
275
|
+
"""Execute the processing pipeline and yield emitted events."""
|
276
|
+
|
277
|
+
def _emit(kind: str, payload: object) -> None:
|
278
|
+
events.put((kind, payload))
|
279
|
+
|
280
|
+
progress_callback: Optional[Callable[[int, int, str], None]] = None
|
281
|
+
if enable_progress:
|
282
|
+
progress_callback = lambda current, total, desc: _emit(
|
283
|
+
"progress", (current, total, desc)
|
284
|
+
)
|
285
|
+
|
286
|
+
reporter = reporter_factory(
|
287
|
+
progress_callback, lambda message: _emit("log", message)
|
288
|
+
)
|
289
|
+
|
290
|
+
def _worker() -> None:
|
291
|
+
try:
|
292
|
+
result = speed_up(options, reporter=reporter)
|
293
|
+
except FFmpegNotFoundError as exc: # pragma: no cover - depends on runtime env
|
294
|
+
_emit("error", gr.Error(str(exc)))
|
295
|
+
except FileNotFoundError as exc:
|
296
|
+
_emit("error", gr.Error(str(exc)))
|
297
|
+
except Exception as exc: # pragma: no cover - defensive fallback
|
298
|
+
reporter.log(f"Error: {exc}")
|
299
|
+
_emit("error", gr.Error(f"Failed to process the video: {exc}"))
|
300
|
+
else:
|
301
|
+
reporter.log("Processing complete.")
|
302
|
+
_emit("result", result)
|
303
|
+
finally:
|
304
|
+
_emit("done", None)
|
305
|
+
|
306
|
+
thread: Optional[Thread] = None
|
307
|
+
if start_in_thread:
|
308
|
+
thread = Thread(target=_worker, daemon=True)
|
309
|
+
thread.start()
|
310
|
+
else:
|
311
|
+
_worker()
|
312
|
+
|
313
|
+
try:
|
314
|
+
while True:
|
315
|
+
kind, payload = events.get()
|
316
|
+
if kind == "done":
|
317
|
+
break
|
318
|
+
yield (kind, payload)
|
319
|
+
finally:
|
320
|
+
if thread is not None:
|
321
|
+
thread.join()
|
322
|
+
|
323
|
+
|
324
|
+
@dataclass
|
325
|
+
class ProcessVideoDependencies:
|
326
|
+
"""Container for dependencies used by :func:`process_video`."""
|
327
|
+
|
328
|
+
speed_up: Callable[
|
329
|
+
[ProcessingOptions, SignalProgressReporter], ProcessingResult
|
330
|
+
] = speed_up_video
|
331
|
+
reporter_factory: Callable[
|
332
|
+
[Optional[Callable[[int, int, str], None]], Callable[[str], None]],
|
333
|
+
SignalProgressReporter,
|
334
|
+
] = _default_reporter_factory
|
335
|
+
queue_factory: Callable[[], SimpleQueue[PipelineEvent]] = SimpleQueue
|
336
|
+
run_pipeline_job_func: Callable[..., Iterator[PipelineEvent]] = run_pipeline_job
|
337
|
+
start_in_thread: bool = True
|
338
|
+
|
339
|
+
|
248
340
|
def process_video(
|
249
341
|
file_path: Optional[str],
|
250
342
|
small_video: bool,
|
@@ -252,6 +344,8 @@ def process_video(
|
|
252
344
|
sounded_speed: Optional[float] = None,
|
253
345
|
silent_speed: Optional[float] = None,
|
254
346
|
progress: Optional[gr.Progress] = gr.Progress(track_tqdm=False),
|
347
|
+
*,
|
348
|
+
dependencies: Optional[ProcessVideoDependencies] = None,
|
255
349
|
) -> Iterator[tuple[Optional[str], str, str, Optional[str]]]:
|
256
350
|
"""Run the Talks Reducer pipeline for a single uploaded file."""
|
257
351
|
|
@@ -266,23 +360,8 @@ def process_video(
|
|
266
360
|
temp_folder = workspace / "temp"
|
267
361
|
output_file = _build_output_path(input_path, workspace, small_video)
|
268
362
|
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
def _callback(current: int, total: int, desc: str) -> None:
|
273
|
-
events.put(("progress", (current, total, desc)))
|
274
|
-
|
275
|
-
progress_callback = _callback
|
276
|
-
|
277
|
-
events: "SimpleQueue[tuple[str, object]]" = SimpleQueue()
|
278
|
-
|
279
|
-
def _log_callback(message: str) -> None:
|
280
|
-
events.put(("log", message))
|
281
|
-
|
282
|
-
reporter = GradioProgressReporter(
|
283
|
-
progress_callback=progress_callback,
|
284
|
-
log_callback=_log_callback,
|
285
|
-
)
|
363
|
+
deps = dependencies or ProcessVideoDependencies()
|
364
|
+
events = deps.queue_factory()
|
286
365
|
|
287
366
|
option_kwargs: dict[str, float] = {}
|
288
367
|
if silent_threshold is not None:
|
@@ -300,31 +379,20 @@ def process_video(
|
|
300
379
|
**option_kwargs,
|
301
380
|
)
|
302
381
|
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
reporter.log(f"Error: {exc}")
|
312
|
-
events.put(("error", gr.Error(f"Failed to process the video: {exc}")))
|
313
|
-
else:
|
314
|
-
reporter.log("Processing complete.")
|
315
|
-
events.put(("result", result))
|
316
|
-
finally:
|
317
|
-
events.put(("done", None))
|
318
|
-
|
319
|
-
worker = Thread(target=_worker, daemon=True)
|
320
|
-
worker.start()
|
382
|
+
event_stream = deps.run_pipeline_job_func(
|
383
|
+
options,
|
384
|
+
speed_up=deps.speed_up,
|
385
|
+
reporter_factory=deps.reporter_factory,
|
386
|
+
events=events,
|
387
|
+
enable_progress=progress is not None,
|
388
|
+
start_in_thread=deps.start_in_thread,
|
389
|
+
)
|
321
390
|
|
322
391
|
collected_logs: list[str] = []
|
323
392
|
final_result: Optional[ProcessingResult] = None
|
324
393
|
error: Optional[gr.Error] = None
|
325
394
|
|
326
|
-
|
327
|
-
kind, payload = events.get()
|
395
|
+
for kind, payload in event_stream:
|
328
396
|
if kind == "log":
|
329
397
|
text = str(payload).strip()
|
330
398
|
if text:
|
@@ -344,10 +412,6 @@ def process_video(
|
|
344
412
|
final_result = payload # type: ignore[assignment]
|
345
413
|
elif kind == "error":
|
346
414
|
error = payload # type: ignore[assignment]
|
347
|
-
elif kind == "done":
|
348
|
-
break
|
349
|
-
|
350
|
-
worker.join()
|
351
415
|
|
352
416
|
if error is not None:
|
353
417
|
raise error
|