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/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, *, temp_path: Path | None = 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
- _delete_path(temp_path)
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, reporter: ProgressReporter | None = None
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
- _delete_path(temp_path)
84
- _create_path(temp_path)
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 = build_video_commands(
209
- os.fspath(input_path),
210
- os.fspath(audio_new_path),
211
- os.fspath(filter_graph_path),
212
- os.fspath(output_path),
213
- ffmpeg_path=ffmpeg_path,
214
- cuda_available=cuda_available,
215
- small=options.small,
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
- _delete_path(temp_path)
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
- _delete_path(temp_path)
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 as exc:
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
- _delete_path(temp_path)
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
- _FAVICON_CANDIDATES = (
148
- Path(__file__).resolve().parent / "resources" / "icons" / "icon.ico",
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
- progress_callback: Optional[Callable[[int, int, str], None]] = None
270
- if progress is not None:
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
- def _worker() -> None:
304
- try:
305
- result = speed_up_video(options, reporter=reporter)
306
- except FFmpegNotFoundError as exc: # pragma: no cover - depends on runtime env
307
- events.put(("error", gr.Error(str(exc))))
308
- except FileNotFoundError as exc:
309
- events.put(("error", gr.Error(str(exc))))
310
- except Exception as exc: # pragma: no cover - defensive fallback
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
- while True:
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