talks-reducer 0.7.2__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.
@@ -1,1550 +1,21 @@
1
- """Minimal Tkinter-based GUI for the talks reducer pipeline."""
1
+ """Compatibility layer for the Talks Reducer GUI package."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import argparse
6
- import importlib
7
- import json
8
- import os
9
- import re
10
- import subprocess
11
- import sys
12
- import threading
13
- import time
14
- from pathlib import Path
15
- from typing import (
16
- TYPE_CHECKING,
17
- Any,
18
- Callable,
19
- Iterable,
20
- List,
21
- Optional,
22
- Sequence,
23
- Tuple,
5
+ from .app import (
6
+ TalksReducerGUI,
7
+ _default_remote_destination,
8
+ _parse_ratios_from_summary,
24
9
  )
25
-
26
- if TYPE_CHECKING:
27
- import tkinter as tk
28
- from tkinter import filedialog, messagebox, ttk
29
-
30
- try:
31
- from ..cli import gather_input_files
32
- from ..cli import main as cli_main
33
- from ..ffmpeg import FFmpegNotFoundError
34
- from ..models import ProcessingOptions
35
- from ..pipeline import ProcessingAborted, speed_up_video
36
- from ..progress import ProgressHandle, SignalProgressReporter
37
- from ..version_utils import resolve_version
38
- from .preferences import GUIPreferences, determine_config_path
39
- from .remote import (
40
- check_remote_server_for_gui,
41
- format_server_host,
42
- normalize_server_url,
43
- ping_server,
44
- process_files_via_server,
45
- )
46
- from .theme import (
47
- DARK_THEME,
48
- LIGHT_THEME,
49
- STATUS_COLORS,
50
- apply_theme,
51
- detect_system_theme,
52
- read_windows_theme_registry,
53
- run_defaults_command,
54
- )
55
- except ImportError: # pragma: no cover - handled at runtime
56
- if __package__ not in (None, ""):
57
- raise
58
-
59
- PACKAGE_ROOT = Path(__file__).resolve().parent.parent
60
- if str(PACKAGE_ROOT) not in sys.path:
61
- sys.path.insert(0, str(PACKAGE_ROOT))
62
-
63
- from talks_reducer.cli import gather_input_files
64
- from talks_reducer.cli import main as cli_main
65
- from talks_reducer.ffmpeg import FFmpegNotFoundError
66
- from talks_reducer.gui.preferences import GUIPreferences, determine_config_path
67
- from talks_reducer.gui.remote import (
68
- check_remote_server_for_gui,
69
- format_server_host,
70
- normalize_server_url,
71
- ping_server,
72
- process_files_via_server,
73
- )
74
- from talks_reducer.gui.theme import (
75
- DARK_THEME,
76
- LIGHT_THEME,
77
- STATUS_COLORS,
78
- apply_theme,
79
- detect_system_theme,
80
- read_windows_theme_registry,
81
- run_defaults_command,
82
- )
83
- from talks_reducer.models import ProcessingOptions
84
- from talks_reducer.pipeline import ProcessingAborted, speed_up_video
85
- from talks_reducer.progress import ProgressHandle, SignalProgressReporter
86
- from talks_reducer.version_utils import resolve_version
87
-
88
-
89
- def _check_tkinter_available() -> tuple[bool, str]:
90
- """Check if tkinter can create windows without importing it globally."""
91
- # Test in a subprocess to avoid crashing the main process
92
- test_code = """
93
- import json
94
-
95
- def run_check():
96
- try:
97
- import tkinter as tk # noqa: F401 - imported for side effect
98
- except Exception as exc: # pragma: no cover - runs in subprocess
99
- return {
100
- "status": "import_error",
101
- "error": f"{exc.__class__.__name__}: {exc}",
102
- }
103
-
104
- try:
105
- import tkinter as tk
106
-
107
- root = tk.Tk()
108
- root.destroy()
109
- except Exception as exc: # pragma: no cover - runs in subprocess
110
- return {
111
- "status": "init_error",
112
- "error": f"{exc.__class__.__name__}: {exc}",
113
- }
114
-
115
- return {"status": "ok"}
116
-
117
-
118
- if __name__ == "__main__":
119
- print(json.dumps(run_check()))
120
- """
121
-
122
- try:
123
- result = subprocess.run(
124
- [sys.executable, "-c", test_code], capture_output=True, text=True, timeout=5
125
- )
126
-
127
- output = result.stdout.strip() or result.stderr.strip()
128
-
129
- if not output:
130
- return False, "Window creation failed"
131
-
132
- try:
133
- payload = json.loads(output)
134
- except json.JSONDecodeError:
135
- return False, output
136
-
137
- status = payload.get("status")
138
-
139
- if status == "ok":
140
- return True, ""
141
-
142
- if status == "import_error":
143
- return (
144
- False,
145
- f"tkinter is not installed ({payload.get('error', 'unknown error')})",
146
- )
147
-
148
- if status == "init_error":
149
- return (
150
- False,
151
- f"tkinter could not open a window ({payload.get('error', 'unknown error')})",
152
- )
153
-
154
- return False, output
155
- except Exception as e: # pragma: no cover - defensive fallback
156
- return False, f"Error testing tkinter: {e}"
157
-
158
-
159
- try:
160
- from tkinterdnd2 import DND_FILES, TkinterDnD
161
- except ModuleNotFoundError: # pragma: no cover - runtime dependency
162
- DND_FILES = None # type: ignore[assignment]
163
- TkinterDnD = None # type: ignore[assignment]
164
-
165
-
166
- from . import discovery as discovery_helpers
167
- from . import layout as layout_helpers
168
-
169
-
170
- def _default_remote_destination(input_file: Path, *, small: bool) -> Path:
171
- """Return the default remote output path for *input_file*.
172
-
173
- Mirrors the naming scheme from the local pipeline so that remote jobs save
174
- next to the source media with the expected suffix.
175
- """
176
-
177
- name = input_file.name
178
- dot_index = name.rfind(".")
179
- suffix = "_speedup_small" if small else "_speedup"
180
-
181
- if dot_index != -1:
182
- new_name = name[:dot_index] + suffix + name[dot_index:]
183
- else:
184
- new_name = name + suffix
185
-
186
- return input_file.with_name(new_name)
187
-
188
-
189
- def _parse_ratios_from_summary(summary: str) -> Tuple[Optional[float], Optional[float]]:
190
- """Extract time and size ratios from a Markdown *summary* string."""
191
-
192
- time_ratio: Optional[float] = None
193
- size_ratio: Optional[float] = None
194
-
195
- for line in summary.splitlines():
196
- if "**Duration:**" in line:
197
- match = re.search(r"—\s*([0-9]+(?:\.[0-9]+)?)% of the original", line)
198
- if match:
199
- try:
200
- time_ratio = float(match.group(1)) / 100
201
- except ValueError:
202
- time_ratio = None
203
- elif "**Size:**" in line:
204
- match = re.search(r"\*\*Size:\*\*\s*([0-9]+(?:\.[0-9]+)?)%", line)
205
- if match:
206
- try:
207
- size_ratio = float(match.group(1)) / 100
208
- except ValueError:
209
- size_ratio = None
210
-
211
- return time_ratio, size_ratio
212
-
213
-
214
- class _GuiProgressHandle(ProgressHandle):
215
- """Simple progress handle that records totals but only logs milestones."""
216
-
217
- def __init__(self, log_callback: Callable[[str], None], desc: str) -> None:
218
- self._log_callback = log_callback
219
- self._desc = desc
220
- self._current = 0
221
- self._total: Optional[int] = None
222
- if desc:
223
- self._log_callback(f"{desc} started")
224
-
225
- @property
226
- def current(self) -> int:
227
- return self._current
228
-
229
- def ensure_total(self, total: int) -> None:
230
- if self._total is None or total > self._total:
231
- self._total = total
232
-
233
- def advance(self, amount: int) -> None:
234
- if amount > 0:
235
- self._current += amount
236
-
237
- def finish(self) -> None:
238
- if self._total is not None:
239
- self._current = self._total
240
- if self._desc:
241
- self._log_callback(f"{self._desc} completed")
242
-
243
- def __enter__(self) -> "_GuiProgressHandle":
244
- return self
245
-
246
- def __exit__(self, exc_type, exc, tb) -> bool:
247
- if exc_type is None:
248
- self.finish()
249
- return False
250
-
251
-
252
- class _TkProgressReporter(SignalProgressReporter):
253
- """Progress reporter that forwards updates to the GUI thread."""
254
-
255
- def __init__(
256
- self,
257
- log_callback: Callable[[str], None],
258
- process_callback: Optional[Callable] = None,
259
- *,
260
- stop_callback: Optional[Callable[[], bool]] = None,
261
- ) -> None:
262
- self._log_callback = log_callback
263
- self.process_callback = process_callback
264
- self._stop_callback = stop_callback
265
-
266
- def log(self, message: str) -> None:
267
- self._log_callback(message)
268
- print(message, flush=True)
269
-
270
- def task(
271
- self, *, desc: str = "", total: Optional[int] = None, unit: str = ""
272
- ) -> _GuiProgressHandle:
273
- del total, unit
274
- return _GuiProgressHandle(self._log_callback, desc)
275
-
276
- def stop_requested(self) -> bool:
277
- """Return ``True`` when the GUI has asked to cancel processing."""
278
-
279
- if self._stop_callback is None:
280
- return False
281
- return bool(self._stop_callback())
282
-
283
-
284
- class TalksReducerGUI:
285
- """Tkinter application mirroring the CLI options with form controls."""
286
-
287
- PADDING = 10
288
- AUDIO_PROCESSING_RATIO = 0.02
289
- AUDIO_PROGRESS_STEPS = 20
290
- MIN_AUDIO_INTERVAL_MS = 10
291
- DEFAULT_AUDIO_INTERVAL_MS = 200
292
-
293
- def __init__(
294
- self,
295
- initial_inputs: Optional[Sequence[str]] = None,
296
- *,
297
- auto_run: bool = False,
298
- ) -> None:
299
- self._config_path = determine_config_path()
300
- self.preferences = GUIPreferences(self._config_path)
301
-
302
- # Import tkinter here to avoid loading it at module import time
303
- import tkinter as tk
304
- from tkinter import filedialog, messagebox, ttk
305
-
306
- # Store references for use in methods
307
- self.tk = tk
308
- self.filedialog = filedialog
309
- self.messagebox = messagebox
310
- self.ttk = ttk
311
-
312
- if TkinterDnD is not None:
313
- self.root = TkinterDnD.Tk() # type: ignore[call-arg]
314
- else:
315
- self.root = tk.Tk()
316
-
317
- # Set window title with version information
318
- app_version = resolve_version()
319
- if app_version and app_version != "unknown":
320
- self.root.title(f"Talks Reducer v{app_version}")
321
- else:
322
- self.root.title("Talks Reducer")
323
-
324
- self._apply_window_icon()
325
-
326
- self._full_size = (1000, 800)
327
- self._simple_size = (300, 270)
328
- # self.root.geometry(f"{self._full_size[0]}x{self._full_size[1]}")
329
- self.style = self.ttk.Style(self.root)
330
-
331
- self._processing_thread: Optional[threading.Thread] = None
332
- self._last_output: Optional[Path] = None
333
- self._last_time_ratio: Optional[float] = None
334
- self._last_size_ratio: Optional[float] = None
335
- self._last_progress_seconds: Optional[int] = None
336
- self._run_start_time: Optional[float] = None
337
- self._status_state = "Idle"
338
- self.status_var = tk.StringVar(value=self._status_state)
339
- self._status_animation_job: Optional[str] = None
340
- self._status_animation_phase = 0
341
- self._video_duration_seconds: Optional[float] = None
342
- self._encode_target_duration_seconds: Optional[float] = None
343
- self._encode_total_frames: Optional[int] = None
344
- self._encode_current_frame: Optional[int] = None
345
- self._source_duration_seconds: Optional[float] = None
346
- self._audio_progress_job: Optional[str] = None
347
- self._audio_progress_interval_ms: Optional[int] = None
348
- self._audio_progress_steps_completed = 0
349
- self.progress_var = tk.IntVar(value=0)
350
- self._ffmpeg_process: Optional[subprocess.Popen] = None
351
- self._stop_requested = False
352
- self._ping_worker_stop_requested = False
353
- self._current_remote_mode = False
354
-
355
- self.input_files: List[str] = []
356
-
357
- self._dnd_available = TkinterDnD is not None and DND_FILES is not None
358
-
359
- self.simple_mode_var = tk.BooleanVar(
360
- value=self.preferences.get("simple_mode", True)
361
- )
362
- self.run_after_drop_var = tk.BooleanVar(value=True)
363
- self.small_var = tk.BooleanVar(value=self.preferences.get("small_video", True))
364
- self.open_after_convert_var = tk.BooleanVar(
365
- value=self.preferences.get("open_after_convert", True)
366
- )
367
- stored_mode = str(self.preferences.get("processing_mode", "local"))
368
- if stored_mode not in {"local", "remote"}:
369
- stored_mode = "local"
370
- self.processing_mode_var = tk.StringVar(value=stored_mode)
371
- self.processing_mode_var.trace_add("write", self._on_processing_mode_change)
372
- self.theme_var = tk.StringVar(value=self.preferences.get("theme", "os"))
373
- self.theme_var.trace_add("write", self._on_theme_change)
374
- self.small_var.trace_add("write", self._on_small_video_change)
375
- self.open_after_convert_var.trace_add(
376
- "write", self._on_open_after_convert_change
377
- )
378
- self.server_url_var = tk.StringVar(
379
- value=str(self.preferences.get("server_url", ""))
380
- )
381
- self.server_url_var.trace_add("write", self._on_server_url_change)
382
- self._discovery_thread: Optional[threading.Thread] = None
383
-
384
- self._basic_defaults: dict[str, float] = {}
385
- self._basic_variables: dict[str, tk.DoubleVar] = {}
386
- self._slider_updaters: dict[str, Callable[[str], None]] = {}
387
- self._sliders: list[tk.Scale] = []
388
-
389
- self._build_layout()
390
- self._apply_simple_mode(initial=True)
391
- self._apply_status_style(self._status_state)
392
- self._refresh_theme()
393
- self.preferences.save()
394
- self._hide_stop_button()
395
-
396
- # Ping server on startup if in remote mode
397
- if (
398
- self.processing_mode_var.get() == "remote"
399
- and self.server_url_var.get().strip()
400
- ):
401
- server_url = self.server_url_var.get().strip()
402
-
403
- def ping_worker() -> None:
404
- try:
405
- self._check_remote_server(
406
- server_url,
407
- success_status="Idle",
408
- waiting_status="Error",
409
- failure_status="Error",
410
- stop_check=lambda: self._ping_worker_stop_requested,
411
- switch_to_local_on_failure=True,
412
- )
413
- except Exception as exc: # pragma: no cover - defensive safeguard
414
- host_label = self._format_server_host(server_url)
415
- message = f"Error pinging server {host_label}: {exc}"
416
- self._schedule_on_ui_thread(
417
- lambda msg=message: self._append_log(msg)
418
- )
419
- self._schedule_on_ui_thread(
420
- lambda msg=message: self._set_status("Idle", msg)
421
- )
422
-
423
- threading.Thread(target=ping_worker, daemon=True).start()
424
-
425
- if not self._dnd_available:
426
- self._append_log(
427
- "Drag and drop requires the tkinterdnd2 package. Install it to enable the drop zone."
428
- )
429
-
430
- if initial_inputs:
431
- self._populate_initial_inputs(initial_inputs, auto_run=auto_run)
432
-
433
- def _start_run(self) -> None:
434
- if self._processing_thread and self._processing_thread.is_alive():
435
- self.messagebox.showinfo("Processing", "A job is already running.")
436
- return
437
-
438
- if not self.input_files:
439
- self.messagebox.showwarning(
440
- "Missing input", "Please add at least one file or folder."
441
- )
442
- return
443
-
444
- try:
445
- args = self._collect_arguments()
446
- except ValueError as exc:
447
- self.messagebox.showerror("Invalid value", str(exc))
448
- return
449
-
450
- self._append_log("Starting processing…")
451
- self._stop_requested = False
452
- self.stop_button.configure(text="Stop")
453
- self._run_start_time = time.monotonic()
454
- self._ping_worker_stop_requested = True
455
- open_after_convert = bool(self.open_after_convert_var.get())
456
- server_url = self.server_url_var.get().strip()
457
- remote_mode = self.processing_mode_var.get() == "remote"
458
- if remote_mode and not server_url:
459
- self.messagebox.showerror(
460
- "Missing server URL", "Remote mode requires a server URL."
461
- )
462
- remote_mode = remote_mode and bool(server_url)
463
-
464
- # Store remote_mode for use after thread starts
465
- self._current_remote_mode = remote_mode
466
-
467
- def worker() -> None:
468
- def set_process(proc: subprocess.Popen) -> None:
469
- self._ffmpeg_process = proc
470
-
471
- try:
472
- files = gather_input_files(self.input_files)
473
- if not files:
474
- self._schedule_on_ui_thread(
475
- lambda: self.messagebox.showwarning(
476
- "No files", "No supported media files were found."
477
- )
478
- )
479
- self._set_status("Idle")
480
- return
481
-
482
- if self._current_remote_mode:
483
- success = self._process_files_via_server(
484
- files,
485
- args,
486
- server_url,
487
- open_after_convert=open_after_convert,
488
- )
489
- if success:
490
- self._schedule_on_ui_thread(self._hide_stop_button)
491
- return
492
- # If server processing failed, fall back to local processing
493
- # The _process_files_via_server function already switched to local mode
494
- # Update remote_mode variable to reflect the change
495
- self._current_remote_mode = False
496
-
497
- reporter = _TkProgressReporter(
498
- self._append_log,
499
- process_callback=set_process,
500
- stop_callback=lambda: self._stop_requested,
501
- )
502
- for index, file in enumerate(files, start=1):
503
- self._append_log(f"Processing: {os.path.basename(file)}")
504
- options = self._create_processing_options(Path(file), args)
505
- result = speed_up_video(options, reporter=reporter)
506
- self._last_output = result.output_file
507
- self._last_time_ratio = result.time_ratio
508
- self._last_size_ratio = result.size_ratio
509
-
510
- # Create completion message with ratios if available
511
- completion_msg = f"Completed: {result.output_file}"
512
- if result.time_ratio is not None and result.size_ratio is not None:
513
- completion_msg += f" (Time: {result.time_ratio:.2%}, Size: {result.size_ratio:.2%})"
514
-
515
- self._append_log(completion_msg)
516
- if open_after_convert:
517
- self._schedule_on_ui_thread(
518
- lambda path=result.output_file: self._open_in_file_manager(
519
- path
520
- )
521
- )
522
-
523
- self._append_log("All jobs finished successfully.")
524
- self._schedule_on_ui_thread(
525
- lambda: self.open_button.configure(state=self.tk.NORMAL)
526
- )
527
- self._schedule_on_ui_thread(self._clear_input_files)
528
- except FFmpegNotFoundError as exc:
529
- self._schedule_on_ui_thread(
530
- lambda: self.messagebox.showerror("FFmpeg not found", str(exc))
531
- )
532
- self._set_status("Error")
533
- except ProcessingAborted:
534
- self._append_log("Processing aborted by user.")
535
- self._set_status("Aborted")
536
- except Exception as exc: # pragma: no cover - GUI level safeguard
537
- # If stop was requested, don't show error (FFmpeg termination is expected)
538
- if self._stop_requested:
539
- self._append_log("Processing aborted by user.")
540
- self._set_status("Aborted")
541
- else:
542
- error_msg = f"Processing failed: {exc}"
543
- self._append_log(error_msg)
544
- print(error_msg, file=sys.stderr) # Also output to console
545
- self._schedule_on_ui_thread(
546
- lambda: self.messagebox.showerror("Error", error_msg)
547
- )
548
- self._set_status("Error")
549
- finally:
550
- self._run_start_time = None
551
- self._schedule_on_ui_thread(self._hide_stop_button)
552
-
553
- self._processing_thread = threading.Thread(target=worker, daemon=True)
554
- self._processing_thread.start()
555
-
556
- # Show Stop button when processing starts regardless of mode
557
- self.stop_button.grid()
558
-
559
- # ------------------------------------------------------------------ UI --
560
- def _apply_window_icon(self) -> None:
561
- layout_helpers.apply_window_icon(self)
562
-
563
- def _build_layout(self) -> None:
564
- layout_helpers.build_layout(self)
565
-
566
- def _update_basic_reset_state(self) -> None:
567
- layout_helpers.update_basic_reset_state(self)
568
-
569
- def _reset_basic_defaults(self) -> None:
570
- layout_helpers.reset_basic_defaults(self)
571
-
572
- def _update_processing_mode_state(self) -> None:
573
- has_url = bool(self.server_url_var.get().strip())
574
- if not has_url and self.processing_mode_var.get() == "remote":
575
- self.processing_mode_var.set("local")
576
- return
577
-
578
- if hasattr(self, "remote_mode_button"):
579
- state = self.tk.NORMAL if has_url else self.tk.DISABLED
580
- self.remote_mode_button.configure(state=state)
581
-
582
- def _normalize_server_url(self, server_url: str) -> str:
583
- return normalize_server_url(server_url)
584
-
585
- def _format_server_host(self, server_url: str) -> str:
586
- return format_server_host(server_url)
587
-
588
- def _check_remote_server(
589
- self,
590
- server_url: str,
591
- *,
592
- success_status: str,
593
- waiting_status: str,
594
- failure_status: str,
595
- success_message: Optional[str] = None,
596
- waiting_message_template: str = "Waiting server {host} (attempt {attempt}/{max_attempts})",
597
- failure_message: Optional[str] = None,
598
- stop_check: Optional[Callable[[], bool]] = None,
599
- on_stop: Optional[Callable[[], None]] = None,
600
- switch_to_local_on_failure: bool = False,
601
- alert_on_failure: bool = False,
602
- warning_title: str = "Server unavailable",
603
- warning_message: Optional[str] = None,
604
- max_attempts: int = 5,
605
- delay: float = 1.0,
606
- ) -> bool:
607
- return check_remote_server_for_gui(
608
- self,
609
- server_url,
610
- success_status=success_status,
611
- waiting_status=waiting_status,
612
- failure_status=failure_status,
613
- success_message=success_message,
614
- waiting_message_template=waiting_message_template,
615
- failure_message=failure_message,
616
- stop_check=stop_check,
617
- on_stop=on_stop,
618
- switch_to_local_on_failure=switch_to_local_on_failure,
619
- alert_on_failure=alert_on_failure,
620
- warning_title=warning_title,
621
- warning_message=warning_message,
622
- max_attempts=max_attempts,
623
- delay=delay,
624
- )
625
-
626
- def _ping_server(self, server_url: str, *, timeout: float = 5.0) -> bool:
627
- return ping_server(server_url, timeout=timeout)
628
-
629
- def _start_discovery(self) -> None:
630
- discovery_helpers.start_discovery(self)
631
-
632
- def _on_discovery_failed(self, exc: Exception) -> None:
633
- discovery_helpers.on_discovery_failed(self, exc)
634
-
635
- def _on_discovery_progress(self, current: int, total: int) -> None:
636
- discovery_helpers.on_discovery_progress(self, current, total)
637
-
638
- def _on_discovery_complete(self, urls: List[str]) -> None:
639
- discovery_helpers.on_discovery_complete(self, urls)
640
-
641
- def _show_discovery_results(self, urls: List[str]) -> None:
642
- discovery_helpers.show_discovery_results(self, urls)
643
-
644
- def _toggle_simple_mode(self) -> None:
645
- self.preferences.update("simple_mode", self.simple_mode_var.get())
646
- self._apply_simple_mode()
647
-
648
- def _apply_simple_mode(self, *, initial: bool = False) -> None:
649
- layout_helpers.apply_simple_mode(self, initial=initial)
650
-
651
- def _apply_window_size(self, *, simple: bool) -> None:
652
- layout_helpers.apply_window_size(self, simple=simple)
653
-
654
- def _toggle_advanced(self, *, initial: bool = False) -> None:
655
- if not initial:
656
- self.advanced_visible.set(not self.advanced_visible.get())
657
- visible = self.advanced_visible.get()
658
- if visible:
659
- self.advanced_frame.grid()
660
- self.advanced_button.configure(text="Hide advanced")
661
- else:
662
- self.advanced_frame.grid_remove()
663
- self.advanced_button.configure(text="Advanced")
664
-
665
- def _on_theme_change(self, *_: object) -> None:
666
- self.preferences.update("theme", self.theme_var.get())
667
- self._refresh_theme()
668
-
669
- def _on_small_video_change(self, *_: object) -> None:
670
- self.preferences.update("small_video", bool(self.small_var.get()))
671
-
672
- def _on_open_after_convert_change(self, *_: object) -> None:
673
- self.preferences.update(
674
- "open_after_convert", bool(self.open_after_convert_var.get())
675
- )
676
-
677
- def _on_processing_mode_change(self, *_: object) -> None:
678
- value = self.processing_mode_var.get()
679
- if value not in {"local", "remote"}:
680
- self.processing_mode_var.set("local")
681
- return
682
- self.preferences.update("processing_mode", value)
683
- self._update_processing_mode_state()
684
-
685
- if self.processing_mode_var.get() == "remote":
686
- server_url = self.server_url_var.get().strip()
687
- if not server_url:
688
- return
689
-
690
- def ping_remote_mode() -> None:
691
- self._check_remote_server(
692
- server_url,
693
- success_status="Idle",
694
- waiting_status="Error",
695
- failure_status="Error",
696
- failure_message="Server {host} is unreachable. Switching to local mode.",
697
- switch_to_local_on_failure=True,
698
- alert_on_failure=True,
699
- warning_message="Server {host} is unreachable. Switching to local mode.",
700
- )
701
-
702
- threading.Thread(target=ping_remote_mode, daemon=True).start()
703
-
704
- def _on_server_url_change(self, *_: object) -> None:
705
- value = self.server_url_var.get().strip()
706
- self.preferences.update("server_url", value)
707
- self._update_processing_mode_state()
708
-
709
- def _resolve_theme_mode(self) -> str:
710
- preference = self.theme_var.get().lower()
711
- if preference not in {"light", "dark"}:
712
- return detect_system_theme(
713
- os.environ,
714
- sys.platform,
715
- read_windows_theme_registry,
716
- run_defaults_command,
717
- )
718
- return preference
719
-
720
- def _refresh_theme(self) -> None:
721
- mode = self._resolve_theme_mode()
722
- palette = LIGHT_THEME if mode == "light" else DARK_THEME
723
- apply_theme(
724
- self.style,
725
- palette,
726
- {
727
- "root": self.root,
728
- "drop_zone": getattr(self, "drop_zone", None),
729
- "log_text": getattr(self, "log_text", None),
730
- "status_label": getattr(self, "status_label", None),
731
- "sliders": getattr(self, "_sliders", []),
732
- "tk": self.tk,
733
- "apply_status_style": self._apply_status_style,
734
- "status_state": self._status_state,
735
- },
736
- )
737
-
738
- def _configure_drop_targets(self, widget) -> None:
739
- if not self._dnd_available:
740
- return
741
- widget.drop_target_register(DND_FILES) # type: ignore[arg-type]
742
- widget.dnd_bind("<<Drop>>", self._on_drop) # type: ignore[attr-defined]
743
-
744
- def _populate_initial_inputs(
745
- self, inputs: Sequence[str], *, auto_run: bool = False
746
- ) -> None:
747
- """Seed the GUI with preselected inputs and optionally start processing."""
748
-
749
- normalized: list[str] = []
750
- for path in inputs:
751
- if not path:
752
- continue
753
- resolved = os.fspath(Path(path))
754
- if resolved not in self.input_files:
755
- self.input_files.append(resolved)
756
- normalized.append(resolved)
757
-
758
- if auto_run and normalized:
759
- # Kick off processing once the event loop becomes idle so the
760
- # interface has a chance to render before the work starts.
761
- self.root.after_idle(self._start_run)
762
-
763
- # -------------------------------------------------------------- actions --
764
- def _ask_for_input_files(self) -> tuple[str, ...]:
765
- """Prompt the user to select input files for processing."""
766
-
767
- return self.filedialog.askopenfilenames(
768
- title="Select input files",
769
- filetypes=[
770
- ("Video files", "*.mp4 *.mkv *.mov *.avi *.m4v"),
771
- ("All", "*.*"),
772
- ],
773
- )
774
-
775
- def _add_files(self) -> None:
776
- files = self._ask_for_input_files()
777
- self._extend_inputs(files)
778
-
779
- def _add_directory(self) -> None:
780
- directory = self.filedialog.askdirectory(title="Select input folder")
781
- if directory:
782
- self._extend_inputs([directory])
783
-
784
- def _extend_inputs(self, paths: Iterable[str], *, auto_run: bool = False) -> None:
785
- added = False
786
- for path in paths:
787
- if path and path not in self.input_files:
788
- self.input_files.append(path)
789
- added = True
790
- if auto_run and added and self.run_after_drop_var.get():
791
- self._start_run()
792
-
793
- def _clear_input_files(self) -> None:
794
- """Clear all queued input files."""
795
- self.input_files.clear()
796
-
797
- def _on_drop(self, event: object) -> None:
798
- data = getattr(event, "data", "")
799
- if not data:
800
- return
801
- paths = self.root.tk.splitlist(data)
802
- cleaned = [path.strip("{}") for path in paths]
803
- # Clear existing files before adding dropped files
804
- self.input_files.clear()
805
- self._extend_inputs(cleaned, auto_run=True)
806
-
807
- def _on_drop_zone_click(self, event: object) -> str | None:
808
- """Open a file selection dialog when the drop zone is activated."""
809
-
810
- files = self._ask_for_input_files()
811
- if not files:
812
- return "break"
813
- self._clear_input_files()
814
- self._extend_inputs(files, auto_run=True)
815
- return "break"
816
-
817
- def _browse_path(
818
- self, variable, label: str
819
- ) -> None: # type: (tk.StringVar, str) -> None
820
- if "folder" in label.lower():
821
- result = self.filedialog.askdirectory()
822
- else:
823
- initial = variable.get() or os.getcwd()
824
- result = self.filedialog.asksaveasfilename(
825
- initialfile=os.path.basename(initial)
826
- )
827
- if result:
828
- variable.set(result)
829
-
830
- def _stop_processing(self) -> None:
831
- """Stop the currently running processing by terminating FFmpeg."""
832
- import signal
833
-
834
- self._stop_requested = True
835
- # Update button text to indicate stopping state
836
- self.stop_button.configure(text="Stopping...")
837
- if self._current_remote_mode:
838
- self._append_log("Cancelling remote job...")
839
- elif self._ffmpeg_process and self._ffmpeg_process.poll() is None:
840
- self._append_log("Stopping FFmpeg process...")
841
- try:
842
- # Send SIGTERM to FFmpeg process
843
- if sys.platform == "win32":
844
- # Windows doesn't have SIGTERM, use terminate()
845
- self._ffmpeg_process.terminate()
846
- else:
847
- # Unix-like systems can use SIGTERM
848
- self._ffmpeg_process.send_signal(signal.SIGTERM)
849
-
850
- self._append_log("FFmpeg process stopped.")
851
- except Exception as e:
852
- self._append_log(f"Error stopping process: {e}")
853
- else:
854
- self._append_log("No active FFmpeg process to stop.")
855
-
856
- self._hide_stop_button()
857
-
858
- def _hide_stop_button(self) -> None:
859
- """Hide Stop button."""
860
- self.stop_button.grid_remove()
861
- # Show drop hint when stop button is hidden and no other buttons are visible
862
- if (
863
- not self.open_button.winfo_viewable()
864
- and hasattr(self, "drop_hint_button")
865
- and not self.drop_hint_button.winfo_viewable()
866
- ):
867
- self.drop_hint_button.grid()
868
-
869
- def _collect_arguments(self) -> dict[str, object]:
870
- args: dict[str, object] = {}
871
-
872
- if self.output_var.get():
873
- args["output_file"] = Path(self.output_var.get())
874
- if self.temp_var.get():
875
- args["temp_folder"] = Path(self.temp_var.get())
876
- silent_threshold = float(self.silent_threshold_var.get())
877
- args["silent_threshold"] = round(silent_threshold, 2)
878
-
879
- sounded_speed = float(self.sounded_speed_var.get())
880
- args["sounded_speed"] = round(sounded_speed, 2)
881
-
882
- silent_speed = float(self.silent_speed_var.get())
883
- args["silent_speed"] = round(silent_speed, 2)
884
- if self.frame_margin_var.get():
885
- args["frame_spreadage"] = int(
886
- round(self._parse_float(self.frame_margin_var.get(), "Frame margin"))
887
- )
888
- if self.sample_rate_var.get():
889
- args["sample_rate"] = int(
890
- round(self._parse_float(self.sample_rate_var.get(), "Sample rate"))
891
- )
892
- if self.small_var.get():
893
- args["small"] = True
894
- return args
895
-
896
- def _process_files_via_server(
897
- self,
898
- files: List[str],
899
- args: dict[str, object],
900
- server_url: str,
901
- *,
902
- open_after_convert: bool,
903
- ) -> bool:
904
- """Send *files* to the configured server for processing."""
905
-
906
- return process_files_via_server(
907
- self,
908
- files,
909
- args,
910
- server_url,
911
- open_after_convert=open_after_convert,
912
- default_remote_destination=_default_remote_destination,
913
- parse_summary=_parse_ratios_from_summary,
914
- )
915
-
916
- def _parse_float(self, value: str, label: str) -> float:
917
- try:
918
- return float(value)
919
- except ValueError as exc: # pragma: no cover - input validation
920
- raise ValueError(f"{label} must be a number.") from exc
921
-
922
- def _create_processing_options(
923
- self, input_file: Path, args: dict[str, object]
924
- ) -> ProcessingOptions:
925
- options = dict(args)
926
- options["input_file"] = input_file
927
-
928
- if "temp_folder" in options:
929
- options["temp_folder"] = Path(options["temp_folder"])
930
-
931
- return ProcessingOptions(**options)
932
-
933
- def _open_last_output(self) -> None:
934
- if self._last_output is not None:
935
- self._open_in_file_manager(self._last_output)
936
-
937
- def _open_in_file_manager(self, path: Path) -> None:
938
- target = Path(path)
939
- if sys.platform.startswith("win"):
940
- command = ["explorer", f"/select,{target}"]
941
- elif sys.platform == "darwin":
942
- command = ["open", "-R", os.fspath(target)]
943
- else:
944
- command = [
945
- "xdg-open",
946
- os.fspath(target.parent if target.exists() else target),
947
- ]
948
- try:
949
- subprocess.Popen(command)
950
- except OSError:
951
- self._append_log(f"Could not open file manager for {target}")
952
-
953
- def _append_log(self, message: str) -> None:
954
- self._update_status_from_message(message)
955
-
956
- def updater() -> None:
957
- self.log_text.configure(state=self.tk.NORMAL)
958
- self.log_text.insert(self.tk.END, message + "\n")
959
- self.log_text.see(self.tk.END)
960
- self.log_text.configure(state=self.tk.DISABLED)
961
-
962
- self.log_text.after(0, updater)
963
-
964
- def _update_status_from_message(self, message: str) -> None:
965
- normalized = message.strip().lower()
966
- metadata_match = re.search(
967
- r"source metadata: duration:\s*([\d.]+)s",
968
- message,
969
- re.IGNORECASE,
970
- )
971
- if metadata_match:
972
- try:
973
- self._source_duration_seconds = float(metadata_match.group(1))
974
- except ValueError:
975
- self._source_duration_seconds = None
976
- if "all jobs finished successfully" in normalized:
977
- status_components: List[str] = []
978
- if self._run_start_time is not None:
979
- finish_time = time.monotonic()
980
- runtime_seconds = max(0.0, finish_time - self._run_start_time)
981
- duration_str = self._format_progress_time(runtime_seconds)
982
- status_components.append(f"{duration_str}")
983
- else:
984
- finished_seconds = next(
985
- (
986
- value
987
- for value in (
988
- self._last_progress_seconds,
989
- self._encode_target_duration_seconds,
990
- self._video_duration_seconds,
991
- )
992
- if value is not None
993
- ),
994
- None,
995
- )
996
-
997
- if finished_seconds is not None:
998
- duration_str = self._format_progress_time(finished_seconds)
999
- status_components.append(f"{duration_str}")
1000
- else:
1001
- status_components.append("Finished")
1002
-
1003
- if self._last_time_ratio is not None and self._last_size_ratio is not None:
1004
- status_components.append(
1005
- f"time: {self._last_time_ratio:.0%}, size: {self._last_size_ratio:.0%}"
1006
- )
1007
-
1008
- status_msg = ", ".join(status_components)
1009
-
1010
- self._reset_audio_progress_state(clear_source=True)
1011
- self._set_status("success", status_msg)
1012
- self._set_progress(100) # 100% on success
1013
- self._run_start_time = None
1014
- self._video_duration_seconds = None # Reset for next video
1015
- self._encode_target_duration_seconds = None
1016
- self._encode_total_frames = None
1017
- self._encode_current_frame = None
1018
- self._last_progress_seconds = None
1019
- elif normalized.startswith("extracting audio"):
1020
- self._reset_audio_progress_state(clear_source=False)
1021
- self._set_status("processing", "Extracting audio...")
1022
- self._set_progress(0) # 0% on start
1023
- self._video_duration_seconds = None # Reset for new processing
1024
- self._encode_target_duration_seconds = None
1025
- self._encode_total_frames = None
1026
- self._encode_current_frame = None
1027
- self._last_progress_seconds = None
1028
- self._start_audio_progress()
1029
- elif normalized.startswith("uploading"):
1030
- self._set_status("processing", "Uploading...")
1031
- elif normalized.startswith("starting processing"):
1032
- self._reset_audio_progress_state(clear_source=True)
1033
- self._set_status("processing", "Processing")
1034
- self._set_progress(0) # 0% on start
1035
- self._video_duration_seconds = None # Reset for new processing
1036
- self._encode_target_duration_seconds = None
1037
- self._encode_total_frames = None
1038
- self._encode_current_frame = None
1039
- self._last_progress_seconds = None
1040
- elif normalized.startswith("processing"):
1041
- is_new_job = bool(re.match(r"processing \d+/\d+:", normalized))
1042
- should_reset = self._status_state.lower() != "processing" or is_new_job
1043
- if should_reset:
1044
- self._set_progress(0) # 0% on start
1045
- self._video_duration_seconds = None # Reset for new processing
1046
- self._encode_target_duration_seconds = None
1047
- self._encode_total_frames = None
1048
- self._encode_current_frame = None
1049
- self._last_progress_seconds = None
1050
- if is_new_job:
1051
- self._reset_audio_progress_state(clear_source=True)
1052
- self._set_status("processing", "Processing")
1053
-
1054
- frame_total_match = re.search(
1055
- r"Final encode target frames(?: \(fallback\))?:\s*(\d+)", message
1056
- )
1057
- if frame_total_match:
1058
- self._encode_total_frames = int(frame_total_match.group(1))
1059
- return
1060
-
1061
- if "final encode target frames" in normalized and "unknown" in normalized:
1062
- self._encode_total_frames = None
1063
- return
1064
-
1065
- frame_match = re.search(r"frame=\s*(\d+)", message)
1066
- if frame_match:
1067
- try:
1068
- current_frame = int(frame_match.group(1))
1069
- except ValueError:
1070
- current_frame = None
1071
-
1072
- if current_frame is not None:
1073
- if self._encode_current_frame == current_frame:
1074
- return
1075
-
1076
- self._encode_current_frame = current_frame
1077
- if self._encode_total_frames and self._encode_total_frames > 0:
1078
- self._complete_audio_phase()
1079
- frame_ratio = min(current_frame / self._encode_total_frames, 1.0)
1080
- percentage = min(100, 5 + int(frame_ratio * 95))
1081
- self._set_progress(percentage)
1082
- else:
1083
- self._complete_audio_phase()
1084
- self._set_status("processing", f"{current_frame} frames encoded")
1085
-
1086
- # Parse encode target duration reported by the pipeline
1087
- encode_duration_match = re.search(
1088
- r"Final encode target duration(?: \(fallback\))?:\s*([\d.]+)s",
1089
- message,
1090
- )
1091
- if encode_duration_match:
1092
- try:
1093
- self._encode_target_duration_seconds = float(
1094
- encode_duration_match.group(1)
1095
- )
1096
- except ValueError:
1097
- self._encode_target_duration_seconds = None
1098
-
1099
- if "final encode target duration" in normalized and "unknown" in normalized:
1100
- self._encode_target_duration_seconds = None
1101
-
1102
- # Parse video duration from FFmpeg output
1103
- duration_match = re.search(r"Duration:\s*(\d{2}):(\d{2}):(\d{2}\.\d+)", message)
1104
- if duration_match:
1105
- hours = int(duration_match.group(1))
1106
- minutes = int(duration_match.group(2))
1107
- seconds = float(duration_match.group(3))
1108
- self._video_duration_seconds = hours * 3600 + minutes * 60 + seconds
1109
-
1110
- # Parse FFmpeg progress information (time and speed)
1111
- time_match = re.search(r"time=(\d{2}):(\d{2}):(\d{2})\.\d+", message)
1112
- speed_match = re.search(r"speed=\s*([\d.]+)x", message)
1113
-
1114
- if time_match and speed_match:
1115
- hours = int(time_match.group(1))
1116
- minutes = int(time_match.group(2))
1117
- seconds = int(time_match.group(3))
1118
- current_seconds = hours * 3600 + minutes * 60 + seconds
1119
- time_str = self._format_progress_time(current_seconds)
1120
- speed_str = speed_match.group(1)
1121
-
1122
- self._last_progress_seconds = current_seconds
1123
-
1124
- total_seconds = (
1125
- self._encode_target_duration_seconds or self._video_duration_seconds
1126
- )
1127
- if total_seconds:
1128
- total_str = self._format_progress_time(total_seconds)
1129
- time_display = f"{time_str} / {total_str}"
1130
- else:
1131
- time_display = time_str
1132
-
1133
- status_msg = f"{time_display}, {speed_str}x"
1134
-
1135
- if (
1136
- (
1137
- not self._encode_total_frames
1138
- or self._encode_total_frames <= 0
1139
- or self._encode_current_frame is None
1140
- )
1141
- and total_seconds
1142
- and total_seconds > 0
1143
- ):
1144
- self._complete_audio_phase()
1145
- time_ratio = min(current_seconds / total_seconds, 1.0)
1146
- percentage = min(100, 5 + int(time_ratio * 95))
1147
- self._set_progress(percentage)
1148
-
1149
- self._set_status("processing", status_msg)
1150
-
1151
- def _compute_audio_progress_interval(self) -> int:
1152
- duration = self._source_duration_seconds or self._video_duration_seconds
1153
- if duration and duration > 0:
1154
- audio_seconds = max(duration * self.AUDIO_PROCESSING_RATIO, 0.0)
1155
- interval_seconds = audio_seconds / self.AUDIO_PROGRESS_STEPS
1156
- interval_ms = int(round(interval_seconds * 1000))
1157
- return max(self.MIN_AUDIO_INTERVAL_MS, interval_ms)
1158
- return self.DEFAULT_AUDIO_INTERVAL_MS
1159
-
1160
- def _start_audio_progress(self) -> None:
1161
- interval_ms = self._compute_audio_progress_interval()
1162
-
1163
- def _start() -> None:
1164
- if self._audio_progress_job is not None:
1165
- self.root.after_cancel(self._audio_progress_job)
1166
- self._audio_progress_steps_completed = 0
1167
- self._audio_progress_interval_ms = interval_ms
1168
- self._audio_progress_job = self.root.after(
1169
- interval_ms, self._advance_audio_progress
1170
- )
1171
-
1172
- self._schedule_on_ui_thread(_start)
1173
-
1174
- def _advance_audio_progress(self) -> None:
1175
- self._audio_progress_job = None
1176
- if self._audio_progress_steps_completed >= self.AUDIO_PROGRESS_STEPS:
1177
- self._audio_progress_interval_ms = None
1178
- return
1179
-
1180
- self._audio_progress_steps_completed += 1
1181
- audio_percentage = (
1182
- self._audio_progress_steps_completed / self.AUDIO_PROGRESS_STEPS * 100
1183
- )
1184
- percentage = (audio_percentage / 100) * 5
1185
- self._set_progress(percentage)
1186
- self._set_status("processing", "Audio processing: %d%%" % (audio_percentage))
1187
-
1188
- if self._audio_progress_steps_completed < self.AUDIO_PROGRESS_STEPS:
1189
- interval_ms = (
1190
- self._audio_progress_interval_ms or self.DEFAULT_AUDIO_INTERVAL_MS
1191
- )
1192
- self._audio_progress_job = self.root.after(
1193
- interval_ms, self._advance_audio_progress
1194
- )
1195
- else:
1196
- self._audio_progress_interval_ms = None
1197
-
1198
- def _cancel_audio_progress(self) -> None:
1199
- if self._audio_progress_job is None:
1200
- self._audio_progress_interval_ms = None
1201
- return
1202
-
1203
- def _cancel() -> None:
1204
- if self._audio_progress_job is not None:
1205
- self.root.after_cancel(self._audio_progress_job)
1206
- self._audio_progress_job = None
1207
- self._audio_progress_interval_ms = None
1208
-
1209
- self._schedule_on_ui_thread(_cancel)
1210
-
1211
- def _reset_audio_progress_state(self, *, clear_source: bool) -> None:
1212
- if clear_source:
1213
- self._source_duration_seconds = None
1214
- self._audio_progress_steps_completed = 0
1215
- self._audio_progress_interval_ms = None
1216
- if self._audio_progress_job is not None:
1217
- self._cancel_audio_progress()
1218
-
1219
- def _complete_audio_phase(self) -> None:
1220
- def _complete() -> None:
1221
- if self._audio_progress_job is not None:
1222
- self.root.after_cancel(self._audio_progress_job)
1223
- self._audio_progress_job = None
1224
- self._audio_progress_interval_ms = None
1225
- if self._audio_progress_steps_completed < self.AUDIO_PROGRESS_STEPS:
1226
- self._audio_progress_steps_completed = self.AUDIO_PROGRESS_STEPS
1227
- current_value = self.progress_var.get()
1228
- if current_value < self.AUDIO_PROGRESS_STEPS:
1229
- self._set_progress(self.AUDIO_PROGRESS_STEPS)
1230
-
1231
- self._schedule_on_ui_thread(_complete)
1232
-
1233
- def _apply_status_style(self, status: str) -> None:
1234
- color = STATUS_COLORS.get(status.lower())
1235
- if color:
1236
- self.status_label.configure(fg=color)
1237
- else:
1238
- # For extracting audio or FFmpeg progress messages, use processing color
1239
- # Also handle the new "Time: X%, Size: Y%" format as success
1240
- status_lower = status.lower()
1241
- if (
1242
- "extracting audio" in status_lower
1243
- or re.search(
1244
- r"\d+:\d{2}(?::\d{2})?(?: / \d+:\d{2}(?::\d{2})?)?.*\d+\.?\d*x",
1245
- status,
1246
- )
1247
- or ("time:" in status_lower and "size:" in status_lower)
1248
- ):
1249
- if "time:" in status_lower and "size:" in status_lower:
1250
- # This is our new success format with ratios
1251
- self.status_label.configure(fg=STATUS_COLORS["success"])
1252
- else:
1253
- self.status_label.configure(fg=STATUS_COLORS["processing"])
1254
-
1255
- def _set_status(self, status: str, status_msg: str = "") -> None:
1256
- def apply() -> None:
1257
- self._status_state = status
1258
- # Use status_msg if provided, otherwise use status
1259
- display_text = status_msg if status_msg else status
1260
- self.status_var.set(display_text)
1261
- self._apply_status_style(
1262
- status
1263
- ) # Colors depend on status, not display text
1264
- self._set_progress_bar_style(status)
1265
- lowered = status.lower()
1266
- is_processing = lowered == "processing" or "extracting audio" in lowered
1267
-
1268
- if is_processing:
1269
- # Show stop button during processing
1270
- if hasattr(self, "status_frame"):
1271
- self.status_frame.grid()
1272
- self.stop_button.grid()
1273
- self.drop_hint_button.grid_remove()
1274
- else:
1275
- self._reset_audio_progress_state(clear_source=True)
1276
-
1277
- if lowered == "success" or "time:" in lowered and "size:" in lowered:
1278
- if self.simple_mode_var.get() and hasattr(self, "status_frame"):
1279
- self.status_frame.grid()
1280
- self.stop_button.grid_remove()
1281
- self.drop_hint_button.grid_remove()
1282
- self.open_button.grid()
1283
- self.open_button.lift() # Ensure open_button is above drop_hint_button
1284
- # print("success status")
1285
- else:
1286
- self.open_button.grid_remove()
1287
- # print("not success status")
1288
- if self.simple_mode_var.get() and not is_processing:
1289
- self.stop_button.grid_remove()
1290
- # Show drop hint when no other buttons are visible
1291
- if hasattr(self, "drop_hint_button"):
1292
- self.drop_hint_button.grid()
1293
-
1294
- self.root.after(0, apply)
1295
-
1296
- def _format_progress_time(self, total_seconds: float) -> str:
1297
- """Format a duration in seconds as h:mm:ss or m:ss for status display."""
1298
-
1299
- try:
1300
- rounded_seconds = max(0, int(round(total_seconds)))
1301
- except (TypeError, ValueError):
1302
- return "0:00"
1303
-
1304
- hours, remainder = divmod(rounded_seconds, 3600)
1305
- minutes, seconds = divmod(remainder, 60)
1306
-
1307
- if hours > 0:
1308
- return f"{hours}:{minutes:02d}:{seconds:02d}"
1309
-
1310
- total_minutes = rounded_seconds // 60
1311
- return f"{total_minutes}:{seconds:02d}"
1312
-
1313
- def _calculate_gradient_color(self, percentage: int, darken: float = 1.0) -> str:
1314
- """Calculate color gradient from red (0%) to green (100%).
1315
-
1316
- Args:
1317
- percentage: The position in the gradient (0-100)
1318
- darken: Value between 0.0 (black) and 1.0 (original brightness)
1319
-
1320
- Returns:
1321
- Hex color code string
1322
- """
1323
- # Clamp percentage between 0 and 100
1324
- percentage = max(0, min(100, percentage))
1325
- # Clamp darken between 0.0 and 1.0
1326
- darken = max(0.0, min(1.0, darken))
1327
-
1328
- if percentage <= 50:
1329
- # Red to Yellow (0% to 50%)
1330
- # Red: (248, 113, 113) -> Yellow: (250, 204, 21)
1331
- ratio = percentage / 50.0
1332
- r = int((248 + (250 - 248) * ratio) * darken)
1333
- g = int((113 + (204 - 113) * ratio) * darken)
1334
- b = int((113 + (21 - 113) * ratio) * darken)
1335
- else:
1336
- # Yellow to Green (50% to 100%)
1337
- # Yellow: (250, 204, 21) -> Green: (34, 197, 94)
1338
- ratio = (percentage - 50) / 50.0
1339
- r = int((250 + (34 - 250) * ratio) * darken)
1340
- g = int((204 + (197 - 204) * ratio) * darken)
1341
- b = int((21 + (94 - 21) * ratio) * darken)
1342
-
1343
- # Ensure values are within 0-255 range after darkening
1344
- r = max(0, min(255, r))
1345
- g = max(0, min(255, g))
1346
- b = max(0, min(255, b))
1347
-
1348
- return f"#{r:02x}{g:02x}{b:02x}"
1349
-
1350
- def _set_progress(self, percentage: int) -> None:
1351
- """Update the progress bar value and color (thread-safe)."""
1352
-
1353
- def updater() -> None:
1354
- self.progress_var.set(percentage)
1355
- # Update color based on percentage gradient
1356
- color = self._calculate_gradient_color(percentage, 0.5)
1357
- palette = (
1358
- LIGHT_THEME if self._resolve_theme_mode() == "light" else DARK_THEME
1359
- )
1360
- if self.theme_var.get().lower() in {"light", "dark"}:
1361
- palette = (
1362
- LIGHT_THEME
1363
- if self.theme_var.get().lower() == "light"
1364
- else DARK_THEME
1365
- )
1366
-
1367
- self.style.configure(
1368
- "Dynamic.Horizontal.TProgressbar",
1369
- background=color,
1370
- troughcolor=palette["surface"],
1371
- borderwidth=0,
1372
- thickness=20,
1373
- )
1374
- self.progress_bar.configure(style="Dynamic.Horizontal.TProgressbar")
1375
-
1376
- # Show stop button when progress < 100
1377
- if percentage < 100:
1378
- if hasattr(self, "status_frame"):
1379
- self.status_frame.grid()
1380
- self.stop_button.grid()
1381
- self.drop_hint_button.grid_remove()
1382
-
1383
- self.root.after(0, updater)
1384
-
1385
- def _set_progress_bar_style(self, status: str) -> None:
1386
- """Update the progress bar color based on status."""
1387
-
1388
- def updater() -> None:
1389
- # Map status to progress bar style
1390
- status_lower = status.lower()
1391
- if status_lower == "success" or (
1392
- "time:" in status_lower and "size:" in status_lower
1393
- ):
1394
- style = "Success.Horizontal.TProgressbar"
1395
- elif status_lower == "error":
1396
- style = "Error.Horizontal.TProgressbar"
1397
- elif status_lower == "aborted":
1398
- style = "Aborted.Horizontal.TProgressbar"
1399
- elif status_lower == "idle":
1400
- style = "Idle.Horizontal.TProgressbar"
1401
- else:
1402
- # For processing states, use dynamic gradient (will be set by _set_progress)
1403
- return
1404
-
1405
- self.progress_bar.configure(style=style)
1406
-
1407
- self.root.after(0, updater)
1408
-
1409
- def _schedule_on_ui_thread(self, callback: Callable[[], None]) -> None:
1410
- self.root.after(0, callback)
1411
-
1412
- def run(self) -> None:
1413
- """Start the Tkinter event loop."""
1414
-
1415
- self.root.mainloop()
1416
-
1417
-
1418
- def main(argv: Optional[Sequence[str]] = None) -> bool:
1419
- """Launch the GUI when run without arguments, otherwise defer to the CLI.
1420
-
1421
- Returns ``True`` if the GUI event loop started successfully. ``False``
1422
- indicates that execution was delegated to the CLI or aborted early.
1423
- """
1424
-
1425
- if argv is None:
1426
- argv = sys.argv[1:]
1427
-
1428
- parser = argparse.ArgumentParser(add_help=False)
1429
- parser.add_argument(
1430
- "--server",
1431
- action="store_true",
1432
- help="Launch the Talks Reducer server tray instead of the desktop GUI.",
1433
- )
1434
- parser.add_argument(
1435
- "--no-tray",
1436
- action="store_true",
1437
- help="Deprecated: the GUI no longer starts the server tray automatically.",
1438
- )
1439
-
1440
- parsed_args, remaining = parser.parse_known_args(argv)
1441
- if parsed_args.server:
1442
- package_name = __package__ or "talks_reducer"
1443
- module_name = f"{package_name}.server_tray"
1444
- try:
1445
- tray_module = importlib.import_module(module_name)
1446
- except ModuleNotFoundError as exc:
1447
- if exc.name != module_name:
1448
- raise
1449
- root_package = package_name.split(".")[0] or "talks_reducer"
1450
- tray_module = importlib.import_module(f"{root_package}.server_tray")
1451
- tray_main = getattr(tray_module, "main")
1452
- tray_main(remaining)
1453
- return False
1454
- if parsed_args.no_tray:
1455
- sys.stderr.write(
1456
- "Warning: --no-tray is deprecated; the GUI no longer starts the server tray automatically.\n"
1457
- )
1458
- argv = remaining
1459
-
1460
- if argv:
1461
- launch_gui = False
1462
- if sys.platform == "win32" and not any(arg.startswith("-") for arg in argv):
1463
- # Only attempt to launch the GUI automatically when the arguments
1464
- # look like file or directory paths. This matches the behaviour of
1465
- # file association launches on Windows while still allowing the CLI
1466
- # to be used explicitly with option flags.
1467
- if any(Path(arg).exists() for arg in argv if arg):
1468
- launch_gui = True
1469
-
1470
- if launch_gui:
1471
- try:
1472
- app = TalksReducerGUI(argv, auto_run=True)
1473
- app.run()
1474
- return True
1475
- except Exception:
1476
- # Fall back to the CLI if the GUI cannot be started.
1477
- pass
1478
-
1479
- cli_main(argv)
1480
- return False
1481
-
1482
- # Skip tkinter check if running as a PyInstaller frozen app
1483
- # In that case, tkinter is bundled and the subprocess check would fail
1484
- is_frozen = getattr(sys, "frozen", False)
1485
-
1486
- if not is_frozen:
1487
- # Check if tkinter is available before creating GUI (only when not frozen)
1488
- tkinter_available, error_msg = _check_tkinter_available()
1489
-
1490
- if not tkinter_available:
1491
- # Use ASCII-safe output for Windows console compatibility
1492
- try:
1493
- print("Talks Reducer GUI")
1494
- print("=" * 50)
1495
- print("X GUI not available on this system")
1496
- print(f"Error: {error_msg}")
1497
- print()
1498
- print("! Alternative: Use the command-line interface")
1499
- print()
1500
- print("The CLI provides all the same functionality:")
1501
- print(" python3 -m talks_reducer <input_file> [options]")
1502
- print()
1503
- print("Examples:")
1504
- print(" python3 -m talks_reducer video.mp4")
1505
- print(" python3 -m talks_reducer video.mp4 --small")
1506
- print(" python3 -m talks_reducer video.mp4 -o output.mp4")
1507
- print()
1508
- print("Run 'python3 -m talks_reducer --help' for all options.")
1509
- print()
1510
- print("Troubleshooting tips:")
1511
- if sys.platform == "darwin":
1512
- print(
1513
- " - On macOS, install Python from python.org or ensure "
1514
- "Homebrew's python-tk package is present."
1515
- )
1516
- elif sys.platform.startswith("linux"):
1517
- print(
1518
- " - On Linux, install the Tk bindings for Python (for example, "
1519
- "python3-tk)."
1520
- )
1521
- else:
1522
- print(" - Ensure your Python installation includes Tk support.")
1523
- print(" - You can always fall back to the CLI workflow below.")
1524
- print()
1525
- print("The CLI interface works perfectly and is recommended.")
1526
- except UnicodeEncodeError:
1527
- # Fallback for extreme encoding issues
1528
- sys.stderr.write("GUI not available. Use CLI mode instead.\n")
1529
- return False
1530
-
1531
- # Catch and report any errors during GUI initialization
1532
- try:
1533
- app = TalksReducerGUI()
1534
- app.run()
1535
- return True
1536
- except Exception as e:
1537
- import traceback
1538
-
1539
- sys.stderr.write(f"Error starting GUI: {e}\n")
1540
- sys.stderr.write(traceback.format_exc())
1541
- sys.stderr.write("\nPlease use the CLI mode instead:\n")
1542
- sys.stderr.write(" python3 -m talks_reducer <input_file> [options]\n")
1543
- sys.exit(1)
1544
-
1545
-
1546
- if __name__ == "__main__":
1547
- main()
1548
-
1549
-
1550
- __all__ = ["TalksReducerGUI", "main"]
10
+ from .progress import _GuiProgressHandle, _TkProgressReporter
11
+ from .startup import _check_tkinter_available, main
12
+
13
+ __all__ = [
14
+ "TalksReducerGUI",
15
+ "_GuiProgressHandle",
16
+ "_TkProgressReporter",
17
+ "_check_tkinter_available",
18
+ "_default_remote_destination",
19
+ "_parse_ratios_from_summary",
20
+ "main",
21
+ ]