talks-reducer 0.7.2__py3-none-any.whl → 0.8.1__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.
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, List
8
8
  from ..discovery import discover_servers as core_discover_servers
9
9
 
10
10
  if TYPE_CHECKING: # pragma: no cover - imported for type checking only
11
- from . import TalksReducerGUI
11
+ from .app import TalksReducerGUI
12
12
 
13
13
 
14
14
  def start_discovery(gui: "TalksReducerGUI") -> None:
@@ -11,7 +11,7 @@ from ..models import default_temp_folder
11
11
  if TYPE_CHECKING: # pragma: no cover - imported for type checking only
12
12
  import tkinter as tk
13
13
 
14
- from . import TalksReducerGUI
14
+ from .app import TalksReducerGUI
15
15
 
16
16
 
17
17
  def build_layout(gui: "TalksReducerGUI") -> None:
@@ -0,0 +1,80 @@
1
+ """Progress helpers that bridge the pipeline with the Tkinter GUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Callable, Optional
6
+
7
+ from ..progress import ProgressHandle, SignalProgressReporter
8
+
9
+
10
+ class _GuiProgressHandle(ProgressHandle):
11
+ """Simple progress handle that records totals but only logs milestones."""
12
+
13
+ def __init__(self, log_callback: Callable[[str], None], desc: str) -> None:
14
+ self._log_callback = log_callback
15
+ self._desc = desc
16
+ self._current = 0
17
+ self._total: Optional[int] = None
18
+ if desc:
19
+ self._log_callback(f"{desc} started")
20
+
21
+ @property
22
+ def current(self) -> int:
23
+ return self._current
24
+
25
+ def ensure_total(self, total: int) -> None:
26
+ if self._total is None or total > self._total:
27
+ self._total = total
28
+
29
+ def advance(self, amount: int) -> None:
30
+ if amount > 0:
31
+ self._current += amount
32
+
33
+ def finish(self) -> None:
34
+ if self._total is not None:
35
+ self._current = self._total
36
+ if self._desc:
37
+ self._log_callback(f"{self._desc} completed")
38
+
39
+ def __enter__(self) -> "_GuiProgressHandle":
40
+ return self
41
+
42
+ def __exit__(self, exc_type, exc, tb) -> bool:
43
+ if exc_type is None:
44
+ self.finish()
45
+ return False
46
+
47
+
48
+ class _TkProgressReporter(SignalProgressReporter):
49
+ """Progress reporter that forwards updates to the GUI thread."""
50
+
51
+ def __init__(
52
+ self,
53
+ log_callback: Callable[[str], None],
54
+ process_callback: Optional[Callable] = None,
55
+ *,
56
+ stop_callback: Optional[Callable[[], bool]] = None,
57
+ ) -> None:
58
+ self._log_callback = log_callback
59
+ self.process_callback = process_callback
60
+ self._stop_callback = stop_callback
61
+
62
+ def log(self, message: str) -> None:
63
+ self._log_callback(message)
64
+ print(message, flush=True)
65
+
66
+ def task(
67
+ self, *, desc: str = "", total: Optional[int] = None, unit: str = ""
68
+ ) -> _GuiProgressHandle:
69
+ del total, unit
70
+ return _GuiProgressHandle(self._log_callback, desc)
71
+
72
+ def stop_requested(self) -> bool:
73
+ """Return ``True`` when the GUI has asked to cancel processing."""
74
+
75
+ if self._stop_callback is None:
76
+ return False
77
+ return bool(self._stop_callback())
78
+
79
+
80
+ __all__ = ["_GuiProgressHandle", "_TkProgressReporter"]
@@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, Callable, Dict, List, Optional
14
14
  from ..pipeline import ProcessingAborted
15
15
 
16
16
  if TYPE_CHECKING: # pragma: no cover - imported for type checking only
17
- from . import TalksReducerGUI
17
+ from .app import TalksReducerGUI
18
18
 
19
19
 
20
20
  def normalize_server_url(server_url: str) -> str:
@@ -219,6 +219,12 @@ def check_remote_server_for_gui(
219
219
  )
220
220
 
221
221
 
222
+ def _load_service_client() -> object:
223
+ """Return the Talks Reducer service client module."""
224
+
225
+ return importlib.import_module("talks_reducer.service_client")
226
+
227
+
222
228
  def process_files_via_server(
223
229
  gui: "TalksReducerGUI",
224
230
  files: List[str],
@@ -228,6 +234,8 @@ def process_files_via_server(
228
234
  open_after_convert: bool,
229
235
  default_remote_destination: Callable[[Path, bool], Path],
230
236
  parse_summary: Callable[[str], tuple[Optional[float], Optional[float]]],
237
+ load_service_client: Callable[[], object] = _load_service_client,
238
+ check_server: Callable[..., bool] = check_remote_server_for_gui,
231
239
  ) -> bool:
232
240
  """Send *files* to the configured server for processing."""
233
241
 
@@ -236,7 +244,7 @@ def process_files_via_server(
236
244
  raise ProcessingAborted("Remote processing cancelled by user.")
237
245
 
238
246
  try:
239
- service_module = importlib.import_module("talks_reducer.service_client")
247
+ service_module = load_service_client()
240
248
  except ModuleNotFoundError as exc:
241
249
  gui._append_log(f"Server client unavailable: {exc}")
242
250
  gui._schedule_on_ui_thread(
@@ -253,7 +261,7 @@ def process_files_via_server(
253
261
  lambda: gui._set_status("waiting", f"Waiting server {host_label}...")
254
262
  )
255
263
 
256
- available = check_remote_server_for_gui(
264
+ available = check_server(
257
265
  gui,
258
266
  server_url,
259
267
  success_status="waiting",
@@ -0,0 +1,202 @@
1
+ """Startup utilities for launching the Talks Reducer GUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib
7
+ import json
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Optional, Sequence, Tuple
12
+
13
+ from ..cli import main as cli_main
14
+ from .app import TalksReducerGUI
15
+
16
+
17
+ def _check_tkinter_available() -> Tuple[bool, str]:
18
+ """Check if tkinter can create windows without importing it globally."""
19
+
20
+ # Test in a subprocess to avoid crashing the main process
21
+ test_code = """
22
+ import json
23
+
24
+ def run_check():
25
+ try:
26
+ import tkinter as tk # noqa: F401 - imported for side effect
27
+ except Exception as exc: # pragma: no cover - runs in subprocess
28
+ return {
29
+ "status": "import_error",
30
+ "error": f"{exc.__class__.__name__}: {exc}",
31
+ }
32
+
33
+ try:
34
+ import tkinter as tk
35
+
36
+ root = tk.Tk()
37
+ root.destroy()
38
+ except Exception as exc: # pragma: no cover - runs in subprocess
39
+ return {
40
+ "status": "init_error",
41
+ "error": f"{exc.__class__.__name__}: {exc}",
42
+ }
43
+
44
+ return {"status": "ok"}
45
+
46
+
47
+ if __name__ == "__main__":
48
+ print(json.dumps(run_check()))
49
+ """
50
+
51
+ try:
52
+ result = subprocess.run(
53
+ [sys.executable, "-c", test_code], capture_output=True, text=True, timeout=5
54
+ )
55
+
56
+ output = result.stdout.strip() or result.stderr.strip()
57
+
58
+ if not output:
59
+ return False, "Window creation failed"
60
+
61
+ try:
62
+ payload = json.loads(output)
63
+ except json.JSONDecodeError:
64
+ return False, output
65
+
66
+ status = payload.get("status")
67
+
68
+ if status == "ok":
69
+ return True, ""
70
+
71
+ if status == "import_error":
72
+ return (
73
+ False,
74
+ f"tkinter is not installed ({payload.get('error', 'unknown error')})",
75
+ )
76
+
77
+ if status == "init_error":
78
+ return (
79
+ False,
80
+ f"tkinter could not open a window ({payload.get('error', 'unknown error')})",
81
+ )
82
+
83
+ return False, output
84
+ except Exception as e: # pragma: no cover - defensive fallback
85
+ return False, f"Error testing tkinter: {e}"
86
+
87
+
88
+ def main(argv: Optional[Sequence[str]] = None) -> bool:
89
+ """Launch the GUI when run without arguments, otherwise defer to the CLI."""
90
+
91
+ if argv is None:
92
+ argv = sys.argv[1:]
93
+
94
+ parser = argparse.ArgumentParser(add_help=False)
95
+ parser.add_argument(
96
+ "--server",
97
+ action="store_true",
98
+ help="Launch the Talks Reducer server tray instead of the desktop GUI.",
99
+ )
100
+ parser.add_argument(
101
+ "--no-tray",
102
+ action="store_true",
103
+ help="Deprecated: the GUI no longer starts the server tray automatically.",
104
+ )
105
+
106
+ parsed_args, remaining = parser.parse_known_args(argv)
107
+ if parsed_args.server:
108
+ package_name = __package__ or "talks_reducer"
109
+ module_name = f"{package_name}.server_tray"
110
+ try:
111
+ tray_module = importlib.import_module(module_name)
112
+ except ModuleNotFoundError as exc:
113
+ if exc.name != module_name:
114
+ raise
115
+ root_package = package_name.split(".")[0] or "talks_reducer"
116
+ tray_module = importlib.import_module(f"{root_package}.server_tray")
117
+ tray_main = getattr(tray_module, "main")
118
+ tray_main(remaining)
119
+ return False
120
+ if parsed_args.no_tray:
121
+ sys.stderr.write(
122
+ "Warning: --no-tray is deprecated; the GUI no longer starts the server tray automatically.\n"
123
+ )
124
+ argv = remaining
125
+
126
+ if argv:
127
+ launch_gui = False
128
+ if sys.platform == "win32" and not any(arg.startswith("-") for arg in argv):
129
+ if any(Path(arg).exists() for arg in argv if arg):
130
+ launch_gui = True
131
+
132
+ if launch_gui:
133
+ try:
134
+ app = TalksReducerGUI(argv, auto_run=True)
135
+ app.run()
136
+ return True
137
+ except Exception:
138
+ # Fall back to the CLI if the GUI cannot be started.
139
+ pass
140
+
141
+ cli_main(argv)
142
+ return False
143
+
144
+ is_frozen = getattr(sys, "frozen", False)
145
+
146
+ if not is_frozen:
147
+ tkinter_available, error_msg = _check_tkinter_available()
148
+
149
+ if not tkinter_available:
150
+ try:
151
+ print("Talks Reducer GUI")
152
+ print("=" * 50)
153
+ print("X GUI not available on this system")
154
+ print(f"Error: {error_msg}")
155
+ print()
156
+ print("! Alternative: Use the command-line interface")
157
+ print()
158
+ print("The CLI provides all the same functionality:")
159
+ print(" python3 -m talks_reducer <input_file> [options]")
160
+ print()
161
+ print("Examples:")
162
+ print(" python3 -m talks_reducer video.mp4")
163
+ print(" python3 -m talks_reducer video.mp4 --small")
164
+ print(" python3 -m talks_reducer video.mp4 -o output.mp4")
165
+ print()
166
+ print("Run 'python3 -m talks_reducer --help' for all options.")
167
+ print()
168
+ print("Troubleshooting tips:")
169
+ if sys.platform == "darwin":
170
+ print(
171
+ " - On macOS, install Python from python.org or ensure "
172
+ "Homebrew's python-tk package is present."
173
+ )
174
+ elif sys.platform.startswith("linux"):
175
+ print(
176
+ " - On Linux, install the Tk bindings for Python (for example, "
177
+ "python3-tk)."
178
+ )
179
+ else:
180
+ print(" - Ensure your Python installation includes Tk support.")
181
+ print(" - You can always fall back to the CLI workflow below.")
182
+ print()
183
+ print("The CLI interface works perfectly and is recommended.")
184
+ except UnicodeEncodeError:
185
+ sys.stderr.write("GUI not available. Use CLI mode instead.\n")
186
+ return False
187
+
188
+ try:
189
+ app = TalksReducerGUI()
190
+ app.run()
191
+ return True
192
+ except Exception as e:
193
+ import traceback
194
+
195
+ sys.stderr.write(f"Error starting GUI: {e}\n")
196
+ sys.stderr.write(traceback.format_exc())
197
+ sys.stderr.write("\nPlease use the CLI mode instead:\n")
198
+ sys.stderr.write(" python3 -m talks_reducer <input_file> [options]\n")
199
+ sys.exit(1)
200
+
201
+
202
+ __all__ = ["_check_tkinter_available", "main"]
talks_reducer/icons.py CHANGED
@@ -11,6 +11,7 @@ from typing import Iterator, Optional, Sequence
11
11
  LOGGER = logging.getLogger(__name__)
12
12
 
13
13
  _ICON_RELATIVE_PATHS: Sequence[Path] = (
14
+ Path("resources") / "icons",
14
15
  Path("talks_reducer") / "resources" / "icons",
15
16
  Path("docs") / "assets",
16
17
  )
@@ -43,6 +44,7 @@ def _iter_base_roots(module_file: Optional[Path | str] = None) -> Iterator[Path]
43
44
  for root in (
44
45
  package_root,
45
46
  project_root,
47
+ Path.cwd(),
46
48
  ):
47
49
  yield from _yield(root)
48
50
 
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)
Binary file
Binary file