talks-reducer 0.6.1__py3-none-any.whl → 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- talks_reducer/__about__.py +1 -1
- talks_reducer/cli.py +9 -3
- talks_reducer/{gui.py → gui/__init__.py} +384 -1216
- talks_reducer/gui/__main__.py +8 -0
- talks_reducer/gui/discovery.py +126 -0
- talks_reducer/gui/layout.py +526 -0
- talks_reducer/gui/preferences.py +113 -0
- talks_reducer/gui/remote.py +356 -0
- talks_reducer/gui/theme.py +269 -0
- talks_reducer/models.py +1 -1
- talks_reducer/pipeline.py +142 -92
- talks_reducer/resources/__init__.py +0 -0
- talks_reducer/server.py +60 -6
- talks_reducer/server_tray.py +4 -6
- talks_reducer/service_client.py +56 -4
- {talks_reducer-0.6.1.dist-info → talks_reducer-0.7.0.dist-info}/METADATA +23 -13
- talks_reducer-0.7.0.dist-info/RECORD +29 -0
- talks_reducer-0.6.1.dist-info/RECORD +0 -22
- {talks_reducer-0.6.1.dist-info → talks_reducer-0.7.0.dist-info}/WHEEL +0 -0
- {talks_reducer-0.6.1.dist-info → talks_reducer-0.7.0.dist-info}/entry_points.txt +0 -0
- {talks_reducer-0.6.1.dist-info → talks_reducer-0.7.0.dist-info}/licenses/LICENSE +0 -0
- {talks_reducer-0.6.1.dist-info → talks_reducer-0.7.0.dist-info}/top_level.txt +0 -0
@@ -10,9 +10,7 @@ import re
|
|
10
10
|
import subprocess
|
11
11
|
import sys
|
12
12
|
import threading
|
13
|
-
import
|
14
|
-
import urllib.parse
|
15
|
-
import urllib.request
|
13
|
+
import time
|
16
14
|
from pathlib import Path
|
17
15
|
from typing import (
|
18
16
|
TYPE_CHECKING,
|
@@ -30,14 +28,30 @@ if TYPE_CHECKING:
|
|
30
28
|
from tkinter import filedialog, messagebox, ttk
|
31
29
|
|
32
30
|
try:
|
33
|
-
from
|
34
|
-
from
|
35
|
-
from
|
36
|
-
from
|
37
|
-
from
|
38
|
-
from
|
39
|
-
from
|
40
|
-
from .
|
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
|
+
)
|
41
55
|
except ImportError: # pragma: no cover - handled at runtime
|
42
56
|
if __package__ not in (None, ""):
|
43
57
|
raise
|
@@ -48,10 +62,26 @@ except ImportError: # pragma: no cover - handled at runtime
|
|
48
62
|
|
49
63
|
from talks_reducer.cli import gather_input_files
|
50
64
|
from talks_reducer.cli import main as cli_main
|
51
|
-
from talks_reducer.discovery import discover_servers
|
52
65
|
from talks_reducer.ffmpeg import FFmpegNotFoundError
|
53
|
-
from talks_reducer.
|
54
|
-
from talks_reducer.
|
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
|
55
85
|
from talks_reducer.progress import ProgressHandle, SignalProgressReporter
|
56
86
|
from talks_reducer.version_utils import resolve_version
|
57
87
|
|
@@ -133,42 +163,8 @@ except ModuleNotFoundError: # pragma: no cover - runtime dependency
|
|
133
163
|
TkinterDnD = None # type: ignore[assignment]
|
134
164
|
|
135
165
|
|
136
|
-
|
137
|
-
|
138
|
-
"waiting": "#9ca3af",
|
139
|
-
"processing": "#af8e0e",
|
140
|
-
"success": "#178941",
|
141
|
-
"error": "#ad4f4f",
|
142
|
-
"aborted": "#6d727a",
|
143
|
-
}
|
144
|
-
|
145
|
-
LIGHT_THEME = {
|
146
|
-
"background": "#f5f5f5",
|
147
|
-
"foreground": "#1f2933",
|
148
|
-
"accent": "#2563eb",
|
149
|
-
"surface": "#ffffff",
|
150
|
-
"border": "#cbd5e1",
|
151
|
-
"hover": "#efefef",
|
152
|
-
"hover_text": "#000000",
|
153
|
-
"selection_background": "#2563eb",
|
154
|
-
"selection_foreground": "#ffffff",
|
155
|
-
}
|
156
|
-
|
157
|
-
DARK_THEME = {
|
158
|
-
"background": "#1e1e28",
|
159
|
-
"foreground": "#f3f4f6",
|
160
|
-
"accent": "#60a5fa",
|
161
|
-
"surface": "#2b2b3c",
|
162
|
-
"border": "#4b5563",
|
163
|
-
"hover": "#333333",
|
164
|
-
"hover_text": "#ffffff",
|
165
|
-
"selection_background": "#333333",
|
166
|
-
"selection_foreground": "#f3f4f6",
|
167
|
-
}
|
168
|
-
|
169
|
-
|
170
|
-
_TRAY_LOCK = threading.Lock()
|
171
|
-
_TRAY_PROCESS: Optional[subprocess.Popen[Any]] = None
|
166
|
+
from . import discovery as discovery_helpers
|
167
|
+
from . import layout as layout_helpers
|
172
168
|
|
173
169
|
|
174
170
|
def _default_remote_destination(input_file: Path, *, small: bool) -> Path:
|
@@ -190,34 +186,6 @@ def _default_remote_destination(input_file: Path, *, small: bool) -> Path:
|
|
190
186
|
return input_file.with_name(new_name)
|
191
187
|
|
192
188
|
|
193
|
-
def _ensure_server_tray_running(extra_args: Optional[Sequence[str]] = None) -> None:
|
194
|
-
"""Start the server tray in a background process if one is not active."""
|
195
|
-
|
196
|
-
global _TRAY_PROCESS
|
197
|
-
|
198
|
-
with _TRAY_LOCK:
|
199
|
-
if _TRAY_PROCESS is not None and _TRAY_PROCESS.poll() is None:
|
200
|
-
return
|
201
|
-
|
202
|
-
package_name = __package__ or "talks_reducer"
|
203
|
-
|
204
|
-
if getattr(sys, "frozen", False):
|
205
|
-
command = [sys.executable, "--server"]
|
206
|
-
else:
|
207
|
-
command = [sys.executable, "-m", f"{package_name}.server_tray"]
|
208
|
-
|
209
|
-
if extra_args:
|
210
|
-
command.extend(extra_args)
|
211
|
-
|
212
|
-
try:
|
213
|
-
_TRAY_PROCESS = subprocess.Popen(command)
|
214
|
-
except Exception as exc: # pragma: no cover - best-effort fallback
|
215
|
-
_TRAY_PROCESS = None
|
216
|
-
sys.stderr.write(
|
217
|
-
f"Warning: failed to launch Talks Reducer server tray: {exc}\n"
|
218
|
-
)
|
219
|
-
|
220
|
-
|
221
189
|
def _parse_ratios_from_summary(summary: str) -> Tuple[Optional[float], Optional[float]]:
|
222
190
|
"""Extract time and size ratios from a Markdown *summary* string."""
|
223
191
|
|
@@ -288,9 +256,12 @@ class _TkProgressReporter(SignalProgressReporter):
|
|
288
256
|
self,
|
289
257
|
log_callback: Callable[[str], None],
|
290
258
|
process_callback: Optional[Callable] = None,
|
259
|
+
*,
|
260
|
+
stop_callback: Optional[Callable[[], bool]] = None,
|
291
261
|
) -> None:
|
292
262
|
self._log_callback = log_callback
|
293
263
|
self.process_callback = process_callback
|
264
|
+
self._stop_callback = stop_callback
|
294
265
|
|
295
266
|
def log(self, message: str) -> None:
|
296
267
|
self._log_callback(message)
|
@@ -302,6 +273,13 @@ class _TkProgressReporter(SignalProgressReporter):
|
|
302
273
|
del total, unit
|
303
274
|
return _GuiProgressHandle(self._log_callback, desc)
|
304
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
|
+
|
305
283
|
|
306
284
|
class TalksReducerGUI:
|
307
285
|
"""Tkinter application mirroring the CLI options with form controls."""
|
@@ -312,72 +290,14 @@ class TalksReducerGUI:
|
|
312
290
|
MIN_AUDIO_INTERVAL_MS = 10
|
313
291
|
DEFAULT_AUDIO_INTERVAL_MS = 200
|
314
292
|
|
315
|
-
def _determine_config_path(self) -> Path:
|
316
|
-
if sys.platform == "win32":
|
317
|
-
appdata = os.environ.get("APPDATA")
|
318
|
-
base = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming"
|
319
|
-
elif sys.platform == "darwin":
|
320
|
-
base = Path.home() / "Library" / "Application Support"
|
321
|
-
else:
|
322
|
-
xdg_config = os.environ.get("XDG_CONFIG_HOME")
|
323
|
-
base = Path(xdg_config) if xdg_config else Path.home() / ".config"
|
324
|
-
return base / "talks-reducer" / "settings.json"
|
325
|
-
|
326
|
-
def _load_settings(self) -> dict[str, object]:
|
327
|
-
try:
|
328
|
-
with self._config_path.open("r", encoding="utf-8") as handle:
|
329
|
-
data = json.load(handle)
|
330
|
-
if isinstance(data, dict):
|
331
|
-
return data
|
332
|
-
except FileNotFoundError:
|
333
|
-
return {}
|
334
|
-
except (OSError, json.JSONDecodeError):
|
335
|
-
return {}
|
336
|
-
return {}
|
337
|
-
|
338
|
-
def _save_settings(self) -> None:
|
339
|
-
try:
|
340
|
-
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
341
|
-
with self._config_path.open("w", encoding="utf-8") as handle:
|
342
|
-
json.dump(self._settings, handle, indent=2, sort_keys=True)
|
343
|
-
except OSError:
|
344
|
-
pass
|
345
|
-
|
346
|
-
def _get_setting(self, key: str, default: object) -> object:
|
347
|
-
value = self._settings.get(key, default)
|
348
|
-
if key not in self._settings:
|
349
|
-
self._settings[key] = value
|
350
|
-
return value
|
351
|
-
|
352
|
-
def _get_float_setting(self, key: str, default: float) -> float:
|
353
|
-
"""Return *key* as a float, coercing stored strings when necessary."""
|
354
|
-
|
355
|
-
raw_value = self._get_setting(key, default)
|
356
|
-
try:
|
357
|
-
number = float(raw_value)
|
358
|
-
except (TypeError, ValueError):
|
359
|
-
number = float(default)
|
360
|
-
|
361
|
-
if self._settings.get(key) != number:
|
362
|
-
self._settings[key] = number
|
363
|
-
self._save_settings()
|
364
|
-
|
365
|
-
return number
|
366
|
-
|
367
|
-
def _update_setting(self, key: str, value: object) -> None:
|
368
|
-
if self._settings.get(key) == value:
|
369
|
-
return
|
370
|
-
self._settings[key] = value
|
371
|
-
self._save_settings()
|
372
|
-
|
373
293
|
def __init__(
|
374
294
|
self,
|
375
295
|
initial_inputs: Optional[Sequence[str]] = None,
|
376
296
|
*,
|
377
297
|
auto_run: bool = False,
|
378
298
|
) -> None:
|
379
|
-
self._config_path =
|
380
|
-
self.
|
299
|
+
self._config_path = determine_config_path()
|
300
|
+
self.preferences = GUIPreferences(self._config_path)
|
381
301
|
|
382
302
|
# Import tkinter here to avoid loading it at module import time
|
383
303
|
import tkinter as tk
|
@@ -412,6 +332,8 @@ class TalksReducerGUI:
|
|
412
332
|
self._last_output: Optional[Path] = None
|
413
333
|
self._last_time_ratio: Optional[float] = None
|
414
334
|
self._last_size_ratio: Optional[float] = None
|
335
|
+
self._last_progress_seconds: Optional[int] = None
|
336
|
+
self._run_start_time: Optional[float] = None
|
415
337
|
self._status_state = "Idle"
|
416
338
|
self.status_var = tk.StringVar(value=self._status_state)
|
417
339
|
self._status_animation_job: Optional[str] = None
|
@@ -427,32 +349,34 @@ class TalksReducerGUI:
|
|
427
349
|
self.progress_var = tk.IntVar(value=0)
|
428
350
|
self._ffmpeg_process: Optional[subprocess.Popen] = None
|
429
351
|
self._stop_requested = False
|
352
|
+
self._ping_worker_stop_requested = False
|
353
|
+
self._current_remote_mode = False
|
430
354
|
|
431
355
|
self.input_files: List[str] = []
|
432
356
|
|
433
357
|
self._dnd_available = TkinterDnD is not None and DND_FILES is not None
|
434
358
|
|
435
359
|
self.simple_mode_var = tk.BooleanVar(
|
436
|
-
value=self.
|
360
|
+
value=self.preferences.get("simple_mode", True)
|
437
361
|
)
|
438
362
|
self.run_after_drop_var = tk.BooleanVar(value=True)
|
439
|
-
self.small_var = tk.BooleanVar(value=self.
|
363
|
+
self.small_var = tk.BooleanVar(value=self.preferences.get("small_video", True))
|
440
364
|
self.open_after_convert_var = tk.BooleanVar(
|
441
|
-
value=self.
|
365
|
+
value=self.preferences.get("open_after_convert", True)
|
442
366
|
)
|
443
|
-
stored_mode = str(self.
|
367
|
+
stored_mode = str(self.preferences.get("processing_mode", "local"))
|
444
368
|
if stored_mode not in {"local", "remote"}:
|
445
369
|
stored_mode = "local"
|
446
370
|
self.processing_mode_var = tk.StringVar(value=stored_mode)
|
447
371
|
self.processing_mode_var.trace_add("write", self._on_processing_mode_change)
|
448
|
-
self.theme_var = tk.StringVar(value=self.
|
372
|
+
self.theme_var = tk.StringVar(value=self.preferences.get("theme", "os"))
|
449
373
|
self.theme_var.trace_add("write", self._on_theme_change)
|
450
374
|
self.small_var.trace_add("write", self._on_small_video_change)
|
451
375
|
self.open_after_convert_var.trace_add(
|
452
376
|
"write", self._on_open_after_convert_change
|
453
377
|
)
|
454
378
|
self.server_url_var = tk.StringVar(
|
455
|
-
value=str(self.
|
379
|
+
value=str(self.preferences.get("server_url", ""))
|
456
380
|
)
|
457
381
|
self.server_url_var.trace_add("write", self._on_server_url_change)
|
458
382
|
self._discovery_thread: Optional[threading.Thread] = None
|
@@ -465,50 +389,38 @@ class TalksReducerGUI:
|
|
465
389
|
self._build_layout()
|
466
390
|
self._apply_simple_mode(initial=True)
|
467
391
|
self._apply_status_style(self._status_state)
|
468
|
-
self.
|
469
|
-
self.
|
392
|
+
self._refresh_theme()
|
393
|
+
self.preferences.save()
|
470
394
|
self._hide_stop_button()
|
471
395
|
|
472
396
|
# Ping server on startup if in remote mode
|
473
397
|
if (
|
474
398
|
self.processing_mode_var.get() == "remote"
|
475
399
|
and self.server_url_var.get().strip()
|
476
|
-
and hasattr(self, "_ping_server")
|
477
400
|
):
|
478
401
|
server_url = self.server_url_var.get().strip()
|
479
|
-
host_label = self._format_server_host(server_url)
|
480
402
|
|
481
403
|
def ping_worker() -> None:
|
482
404
|
try:
|
483
|
-
|
484
|
-
|
485
|
-
|
486
|
-
|
487
|
-
|
488
|
-
|
489
|
-
|
490
|
-
"Error", f"Server {host_label} is not reachable"
|
491
|
-
)
|
492
|
-
self._notify(
|
493
|
-
lambda: self._append_log(
|
494
|
-
f"Server {host_label} is not reachable"
|
495
|
-
)
|
496
|
-
)
|
497
|
-
ping_worker()
|
498
|
-
except Exception as exc:
|
499
|
-
self._set_status(
|
500
|
-
"Idle", f"Error pinging server {host_label}: {exc}"
|
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,
|
501
412
|
)
|
502
|
-
|
503
|
-
|
504
|
-
|
505
|
-
|
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)
|
506
421
|
)
|
507
422
|
|
508
|
-
|
509
|
-
|
510
|
-
ping_thread = threading.Thread(target=ping_worker, daemon=True)
|
511
|
-
ping_thread.start()
|
423
|
+
threading.Thread(target=ping_worker, daemon=True).start()
|
512
424
|
|
513
425
|
if not self._dnd_available:
|
514
426
|
self._append_log(
|
@@ -518,459 +430,144 @@ class TalksReducerGUI:
|
|
518
430
|
if initial_inputs:
|
519
431
|
self._populate_initial_inputs(initial_inputs, auto_run=auto_run)
|
520
432
|
|
521
|
-
|
522
|
-
|
523
|
-
|
524
|
-
|
525
|
-
base_path = Path(
|
526
|
-
getattr(sys, "_MEIPASS", Path(__file__).resolve().parent.parent)
|
527
|
-
)
|
528
|
-
|
529
|
-
icon_candidates: list[tuple[Path, str]] = []
|
530
|
-
if sys.platform.startswith("win"):
|
531
|
-
icon_candidates.append((base_path / "docs" / "assets" / "icon.ico", "ico"))
|
532
|
-
icon_candidates.append((base_path / "docs" / "assets" / "icon.png", "png"))
|
533
|
-
|
534
|
-
for icon_path, icon_type in icon_candidates:
|
535
|
-
if not icon_path.is_file():
|
536
|
-
continue
|
537
|
-
|
538
|
-
try:
|
539
|
-
if icon_type == "ico" and sys.platform.startswith("win"):
|
540
|
-
# On Windows, iconbitmap works better without the 'default' parameter
|
541
|
-
self.root.iconbitmap(str(icon_path))
|
542
|
-
else:
|
543
|
-
self.root.iconphoto(False, self.tk.PhotoImage(file=str(icon_path)))
|
544
|
-
# If we got here without exception, icon was set successfully
|
545
|
-
return
|
546
|
-
except (self.tk.TclError, Exception) as e:
|
547
|
-
# Missing Tk image support or invalid icon format - try next candidate
|
548
|
-
continue
|
549
|
-
|
550
|
-
def _build_layout(self) -> None:
|
551
|
-
main = self.ttk.Frame(self.root, padding=self.PADDING)
|
552
|
-
main.grid(row=0, column=0, sticky="nsew")
|
553
|
-
self.root.columnconfigure(0, weight=1)
|
554
|
-
self.root.rowconfigure(0, weight=1)
|
555
|
-
|
556
|
-
# Input selection frame
|
557
|
-
input_frame = self.ttk.Frame(main, padding=self.PADDING)
|
558
|
-
input_frame.grid(row=0, column=0, sticky="nsew")
|
559
|
-
main.rowconfigure(0, weight=1)
|
560
|
-
main.columnconfigure(0, weight=1)
|
561
|
-
input_frame.columnconfigure(0, weight=1)
|
562
|
-
input_frame.rowconfigure(0, weight=1)
|
563
|
-
|
564
|
-
self.drop_zone = self.tk.Label(
|
565
|
-
input_frame,
|
566
|
-
text="Drop video here",
|
567
|
-
relief=self.tk.FLAT,
|
568
|
-
borderwidth=0,
|
569
|
-
padx=self.PADDING,
|
570
|
-
pady=self.PADDING,
|
571
|
-
highlightthickness=0,
|
572
|
-
)
|
573
|
-
self.drop_zone.grid(row=0, column=0, sticky="nsew")
|
574
|
-
self._configure_drop_targets(self.drop_zone)
|
575
|
-
self.drop_zone.configure(cursor="hand2", takefocus=1)
|
576
|
-
self.drop_zone.bind("<Button-1>", self._on_drop_zone_click)
|
577
|
-
self.drop_zone.bind("<Return>", self._on_drop_zone_click)
|
578
|
-
self.drop_zone.bind("<space>", self._on_drop_zone_click)
|
579
|
-
|
580
|
-
# Options frame
|
581
|
-
self.options_frame = self.ttk.Frame(main, padding=self.PADDING)
|
582
|
-
self.options_frame.grid(row=2, column=0, pady=(0, 0), sticky="ew")
|
583
|
-
self.options_frame.columnconfigure(0, weight=1)
|
584
|
-
|
585
|
-
checkbox_frame = self.ttk.Frame(self.options_frame)
|
586
|
-
checkbox_frame.grid(row=0, column=0, columnspan=2, sticky="w")
|
587
|
-
|
588
|
-
self.ttk.Checkbutton(
|
589
|
-
checkbox_frame,
|
590
|
-
text="Small video",
|
591
|
-
variable=self.small_var,
|
592
|
-
).grid(row=0, column=0, sticky="w")
|
593
|
-
|
594
|
-
self.ttk.Checkbutton(
|
595
|
-
checkbox_frame,
|
596
|
-
text="Open after convert",
|
597
|
-
variable=self.open_after_convert_var,
|
598
|
-
).grid(row=0, column=1, sticky="w", padx=(12, 0))
|
599
|
-
|
600
|
-
self.simple_mode_check = self.ttk.Checkbutton(
|
601
|
-
checkbox_frame,
|
602
|
-
text="Simple mode",
|
603
|
-
variable=self.simple_mode_var,
|
604
|
-
command=self._toggle_simple_mode,
|
605
|
-
)
|
606
|
-
self.simple_mode_check.grid(
|
607
|
-
row=1, column=0, columnspan=3, sticky="w", pady=(8, 0)
|
608
|
-
)
|
609
|
-
|
610
|
-
self.advanced_visible = self.tk.BooleanVar(value=False)
|
611
|
-
|
612
|
-
basic_label_container = self.ttk.Frame(self.options_frame)
|
613
|
-
basic_label = self.ttk.Label(basic_label_container, text="Basic options")
|
614
|
-
basic_label.pack(side=self.tk.LEFT)
|
615
|
-
|
616
|
-
self.reset_basic_button = self.ttk.Button(
|
617
|
-
basic_label_container,
|
618
|
-
text="Reset to defaults",
|
619
|
-
command=self._reset_basic_defaults,
|
620
|
-
state=self.tk.DISABLED,
|
621
|
-
style="Link.TButton",
|
622
|
-
)
|
623
|
-
|
624
|
-
self.basic_options_frame = self.ttk.Labelframe(
|
625
|
-
self.options_frame, padding=0, labelwidget=basic_label_container
|
626
|
-
)
|
627
|
-
self.basic_options_frame.grid(
|
628
|
-
row=2, column=0, columnspan=2, sticky="ew", pady=(12, 0)
|
629
|
-
)
|
630
|
-
self.basic_options_frame.columnconfigure(1, weight=1)
|
631
|
-
|
632
|
-
self._reset_button_visible = False
|
633
|
-
|
634
|
-
self.silent_speed_var = self.tk.DoubleVar(
|
635
|
-
value=min(max(self._get_float_setting("silent_speed", 4.0), 1.0), 10.0)
|
636
|
-
)
|
637
|
-
self._add_slider(
|
638
|
-
self.basic_options_frame,
|
639
|
-
"Silent speed",
|
640
|
-
self.silent_speed_var,
|
641
|
-
row=0,
|
642
|
-
setting_key="silent_speed",
|
643
|
-
minimum=1.0,
|
644
|
-
maximum=10.0,
|
645
|
-
resolution=0.5,
|
646
|
-
display_format="{:.1f}×",
|
647
|
-
default_value=4.0,
|
648
|
-
)
|
649
|
-
|
650
|
-
self.sounded_speed_var = self.tk.DoubleVar(
|
651
|
-
value=min(max(self._get_float_setting("sounded_speed", 1.0), 0.75), 2.0)
|
652
|
-
)
|
653
|
-
self._add_slider(
|
654
|
-
self.basic_options_frame,
|
655
|
-
"Sounded speed",
|
656
|
-
self.sounded_speed_var,
|
657
|
-
row=1,
|
658
|
-
setting_key="sounded_speed",
|
659
|
-
minimum=0.75,
|
660
|
-
maximum=2.0,
|
661
|
-
resolution=0.25,
|
662
|
-
display_format="{:.2f}×",
|
663
|
-
default_value=1.0,
|
664
|
-
)
|
665
|
-
|
666
|
-
self.silent_threshold_var = self.tk.DoubleVar(
|
667
|
-
value=min(max(self._get_float_setting("silent_threshold", 0.05), 0.0), 1.0)
|
668
|
-
)
|
669
|
-
self._add_slider(
|
670
|
-
self.basic_options_frame,
|
671
|
-
"Silent threshold",
|
672
|
-
self.silent_threshold_var,
|
673
|
-
row=2,
|
674
|
-
setting_key="silent_threshold",
|
675
|
-
minimum=0.0,
|
676
|
-
maximum=1.0,
|
677
|
-
resolution=0.01,
|
678
|
-
display_format="{:.2f}",
|
679
|
-
default_value=0.05,
|
680
|
-
)
|
681
|
-
|
682
|
-
self.ttk.Label(self.basic_options_frame, text="Server URL").grid(
|
683
|
-
row=3, column=0, sticky="w", pady=4
|
684
|
-
)
|
685
|
-
stored_server_url = str(
|
686
|
-
self._get_setting("server_url", "http://localhost:9005")
|
687
|
-
)
|
688
|
-
if not stored_server_url:
|
689
|
-
stored_server_url = "http://localhost:9005"
|
690
|
-
self._update_setting("server_url", stored_server_url)
|
691
|
-
self.server_url_var.set(stored_server_url)
|
692
|
-
self.server_url_entry = self.ttk.Entry(
|
693
|
-
self.basic_options_frame, textvariable=self.server_url_var, width=30
|
694
|
-
)
|
695
|
-
self.server_url_entry.grid(row=3, column=1, sticky="w", pady=4)
|
696
|
-
self.server_discover_button = self.ttk.Button(
|
697
|
-
self.basic_options_frame, text="Discover", command=self._start_discovery
|
698
|
-
)
|
699
|
-
self.server_discover_button.grid(row=3, column=2, padx=(8, 0))
|
700
|
-
|
701
|
-
self.ttk.Label(self.basic_options_frame, text="Processing mode").grid(
|
702
|
-
row=4, column=0, sticky="w", pady=4
|
703
|
-
)
|
704
|
-
mode_choice = self.ttk.Frame(self.basic_options_frame)
|
705
|
-
mode_choice.grid(row=4, column=1, columnspan=2, sticky="w", pady=4)
|
706
|
-
self.ttk.Radiobutton(
|
707
|
-
mode_choice,
|
708
|
-
text="Local",
|
709
|
-
value="local",
|
710
|
-
variable=self.processing_mode_var,
|
711
|
-
).pack(side=self.tk.LEFT, padx=(0, 8))
|
712
|
-
self.remote_mode_button = self.ttk.Radiobutton(
|
713
|
-
mode_choice,
|
714
|
-
text="Remote",
|
715
|
-
value="remote",
|
716
|
-
variable=self.processing_mode_var,
|
717
|
-
)
|
718
|
-
self.remote_mode_button.pack(side=self.tk.LEFT, padx=(0, 8))
|
719
|
-
|
720
|
-
self.ttk.Label(self.basic_options_frame, text="Theme").grid(
|
721
|
-
row=5, column=0, sticky="w", pady=(8, 0)
|
722
|
-
)
|
723
|
-
theme_choice = self.ttk.Frame(self.basic_options_frame)
|
724
|
-
theme_choice.grid(row=5, column=1, columnspan=2, sticky="w", pady=(8, 0))
|
725
|
-
for value, label in ("os", "OS"), ("light", "Light"), ("dark", "Dark"):
|
726
|
-
self.ttk.Radiobutton(
|
727
|
-
theme_choice,
|
728
|
-
text=label,
|
729
|
-
value=value,
|
730
|
-
variable=self.theme_var,
|
731
|
-
command=self._apply_theme,
|
732
|
-
).pack(side=self.tk.LEFT, padx=(0, 8))
|
733
|
-
|
734
|
-
self.advanced_button = self.ttk.Button(
|
735
|
-
self.options_frame,
|
736
|
-
text="Advanced",
|
737
|
-
command=self._toggle_advanced,
|
738
|
-
)
|
739
|
-
self.advanced_button.grid(
|
740
|
-
row=3, column=0, columnspan=2, sticky="w", pady=(12, 0)
|
741
|
-
)
|
742
|
-
|
743
|
-
self.advanced_frame = self.ttk.Frame(self.options_frame, padding=0)
|
744
|
-
self.advanced_frame.grid(row=4, column=0, columnspan=2, sticky="nsew")
|
745
|
-
self.advanced_frame.columnconfigure(1, weight=1)
|
746
|
-
|
747
|
-
self.output_var = self.tk.StringVar()
|
748
|
-
self._add_entry(
|
749
|
-
self.advanced_frame, "Output file", self.output_var, row=0, browse=True
|
750
|
-
)
|
751
|
-
|
752
|
-
self.temp_var = self.tk.StringVar(value=str(default_temp_folder()))
|
753
|
-
self._add_entry(
|
754
|
-
self.advanced_frame, "Temp folder", self.temp_var, row=1, browse=True
|
755
|
-
)
|
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
|
756
437
|
|
757
|
-
|
758
|
-
|
438
|
+
if not self.input_files:
|
439
|
+
self.messagebox.showwarning(
|
440
|
+
"Missing input", "Please add at least one file or folder."
|
441
|
+
)
|
442
|
+
return
|
759
443
|
|
760
|
-
frame_margin_setting = self._get_setting("frame_margin", 2)
|
761
444
|
try:
|
762
|
-
|
763
|
-
except
|
764
|
-
|
765
|
-
|
445
|
+
args = self._collect_arguments()
|
446
|
+
except ValueError as exc:
|
447
|
+
self.messagebox.showerror("Invalid value", str(exc))
|
448
|
+
return
|
766
449
|
|
767
|
-
self.
|
768
|
-
self.
|
769
|
-
|
770
|
-
)
|
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)
|
771
463
|
|
772
|
-
|
773
|
-
self.
|
774
|
-
self._update_basic_reset_state()
|
775
|
-
|
776
|
-
# Action buttons and log output
|
777
|
-
status_frame = self.ttk.Frame(main, padding=self.PADDING)
|
778
|
-
status_frame.grid(row=1, column=0, sticky="ew")
|
779
|
-
status_frame.columnconfigure(0, weight=0)
|
780
|
-
status_frame.columnconfigure(1, weight=1)
|
781
|
-
status_frame.columnconfigure(2, weight=0)
|
782
|
-
self.status_frame = status_frame
|
783
|
-
|
784
|
-
self.ttk.Label(status_frame, text="Status:").grid(row=0, column=0, sticky="w")
|
785
|
-
self.status_label = self.tk.Label(
|
786
|
-
status_frame, textvariable=self.status_var, anchor="e"
|
787
|
-
)
|
788
|
-
self.status_label.grid(row=0, column=1, sticky="e")
|
789
|
-
|
790
|
-
# Progress bar
|
791
|
-
self.progress_bar = self.ttk.Progressbar(
|
792
|
-
status_frame,
|
793
|
-
variable=self.progress_var,
|
794
|
-
maximum=100,
|
795
|
-
mode="determinate",
|
796
|
-
style="Idle.Horizontal.TProgressbar",
|
797
|
-
)
|
798
|
-
self.progress_bar.grid(row=1, column=0, columnspan=3, sticky="ew", pady=(0, 0))
|
464
|
+
# Store remote_mode for use after thread starts
|
465
|
+
self._current_remote_mode = remote_mode
|
799
466
|
|
800
|
-
|
801
|
-
|
802
|
-
|
803
|
-
self.stop_button.grid(
|
804
|
-
row=2, column=0, columnspan=3, sticky="ew", pady=self.PADDING
|
805
|
-
)
|
806
|
-
self.stop_button.grid_remove() # Hidden by default
|
467
|
+
def worker() -> None:
|
468
|
+
def set_process(proc: subprocess.Popen) -> None:
|
469
|
+
self._ffmpeg_process = proc
|
807
470
|
|
808
|
-
|
809
|
-
|
810
|
-
|
811
|
-
|
812
|
-
|
813
|
-
|
814
|
-
|
815
|
-
|
816
|
-
|
817
|
-
|
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
|
818
481
|
|
819
|
-
|
820
|
-
|
821
|
-
|
822
|
-
|
823
|
-
|
824
|
-
|
825
|
-
|
826
|
-
|
827
|
-
|
828
|
-
|
829
|
-
|
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
|
830
496
|
|
831
|
-
|
832
|
-
|
833
|
-
|
834
|
-
|
835
|
-
|
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
|
836
509
|
|
837
|
-
|
838
|
-
|
839
|
-
|
840
|
-
|
841
|
-
log_scroll = self.ttk.Scrollbar(
|
842
|
-
self.log_frame, orient=self.tk.VERTICAL, command=self.log_text.yview
|
843
|
-
)
|
844
|
-
log_scroll.grid(row=0, column=1, sticky="ns")
|
845
|
-
self.log_text.configure(yscrollcommand=log_scroll.set)
|
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%})"
|
846
514
|
|
847
|
-
|
848
|
-
|
849
|
-
|
850
|
-
|
851
|
-
|
852
|
-
|
853
|
-
|
854
|
-
browse: bool = False,
|
855
|
-
) -> None:
|
856
|
-
self.ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w", pady=4)
|
857
|
-
entry = self.ttk.Entry(parent, textvariable=variable)
|
858
|
-
entry.grid(row=row, column=1, sticky="ew", pady=4)
|
859
|
-
if browse:
|
860
|
-
button = self.ttk.Button(
|
861
|
-
parent,
|
862
|
-
text="Browse",
|
863
|
-
command=lambda var=variable: self._browse_path(var, label),
|
864
|
-
)
|
865
|
-
button.grid(row=row, column=2, padx=(8, 0))
|
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
|
+
)
|
866
522
|
|
867
|
-
|
868
|
-
|
869
|
-
|
870
|
-
|
871
|
-
|
872
|
-
|
873
|
-
|
874
|
-
|
875
|
-
|
876
|
-
|
877
|
-
|
878
|
-
|
879
|
-
|
880
|
-
|
881
|
-
|
882
|
-
|
883
|
-
|
884
|
-
|
885
|
-
|
886
|
-
|
887
|
-
|
888
|
-
|
889
|
-
|
890
|
-
|
891
|
-
|
892
|
-
|
893
|
-
|
894
|
-
|
895
|
-
|
896
|
-
|
897
|
-
slider = self.tk.Scale(
|
898
|
-
parent,
|
899
|
-
variable=variable,
|
900
|
-
from_=minimum,
|
901
|
-
to=maximum,
|
902
|
-
orient=self.tk.HORIZONTAL,
|
903
|
-
resolution=resolution,
|
904
|
-
showvalue=False,
|
905
|
-
command=update,
|
906
|
-
length=240,
|
907
|
-
highlightthickness=0,
|
908
|
-
)
|
909
|
-
slider.grid(row=row, column=1, sticky="ew", pady=4, padx=(0, 8))
|
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)
|
910
552
|
|
911
|
-
|
553
|
+
self._processing_thread = threading.Thread(target=worker, daemon=True)
|
554
|
+
self._processing_thread.start()
|
912
555
|
|
913
|
-
|
914
|
-
self.
|
915
|
-
self._basic_variables[setting_key] = variable
|
916
|
-
variable.trace_add("write", lambda *_: self._update_basic_reset_state())
|
917
|
-
self._sliders.append(slider)
|
556
|
+
# Show Stop button when processing starts regardless of mode
|
557
|
+
self.stop_button.grid()
|
918
558
|
|
919
|
-
|
920
|
-
|
559
|
+
# ------------------------------------------------------------------ UI --
|
560
|
+
def _apply_window_icon(self) -> None:
|
561
|
+
layout_helpers.apply_window_icon(self)
|
921
562
|
|
922
|
-
|
923
|
-
|
563
|
+
def _build_layout(self) -> None:
|
564
|
+
layout_helpers.build_layout(self)
|
924
565
|
|
925
|
-
|
926
|
-
|
927
|
-
variable = self._basic_variables.get(key)
|
928
|
-
if variable is None:
|
929
|
-
continue
|
930
|
-
try:
|
931
|
-
current_value = float(variable.get())
|
932
|
-
except (TypeError, ValueError):
|
933
|
-
should_enable = True
|
934
|
-
break
|
935
|
-
if abs(current_value - default_value) > 1e-9:
|
936
|
-
should_enable = True
|
937
|
-
break
|
938
|
-
|
939
|
-
if should_enable:
|
940
|
-
if not getattr(self, "_reset_button_visible", False):
|
941
|
-
self.reset_basic_button.pack(side=self.tk.LEFT, padx=(8, 0))
|
942
|
-
self._reset_button_visible = True
|
943
|
-
self.reset_basic_button.configure(state=self.tk.NORMAL)
|
944
|
-
else:
|
945
|
-
if getattr(self, "_reset_button_visible", False):
|
946
|
-
self.reset_basic_button.pack_forget()
|
947
|
-
self._reset_button_visible = False
|
948
|
-
self.reset_basic_button.configure(state=self.tk.DISABLED)
|
566
|
+
def _update_basic_reset_state(self) -> None:
|
567
|
+
layout_helpers.update_basic_reset_state(self)
|
949
568
|
|
950
569
|
def _reset_basic_defaults(self) -> None:
|
951
|
-
|
952
|
-
|
953
|
-
for key, default_value in self._basic_defaults.items():
|
954
|
-
variable = self._basic_variables.get(key)
|
955
|
-
if variable is None:
|
956
|
-
continue
|
957
|
-
|
958
|
-
try:
|
959
|
-
current_value = float(variable.get())
|
960
|
-
except (TypeError, ValueError):
|
961
|
-
current_value = default_value
|
962
|
-
|
963
|
-
if abs(current_value - default_value) <= 1e-9:
|
964
|
-
continue
|
965
|
-
|
966
|
-
variable.set(default_value)
|
967
|
-
updater = self._slider_updaters.get(key)
|
968
|
-
if updater is not None:
|
969
|
-
updater(str(default_value))
|
970
|
-
else:
|
971
|
-
self._update_setting(key, float(f"{default_value:.6f}"))
|
972
|
-
|
973
|
-
self._update_basic_reset_state()
|
570
|
+
layout_helpers.reset_basic_defaults(self)
|
974
571
|
|
975
572
|
def _update_processing_mode_state(self) -> None:
|
976
573
|
has_url = bool(self.server_url_var.get().strip())
|
@@ -983,202 +580,76 @@ class TalksReducerGUI:
|
|
983
580
|
self.remote_mode_button.configure(state=state)
|
984
581
|
|
985
582
|
def _normalize_server_url(self, server_url: str) -> str:
|
986
|
-
|
987
|
-
if not parsed.scheme:
|
988
|
-
parsed = urllib.parse.urlsplit(f"http://{server_url}")
|
989
|
-
|
990
|
-
netloc = parsed.netloc or parsed.path
|
991
|
-
if not netloc:
|
992
|
-
return server_url
|
993
|
-
|
994
|
-
path = parsed.path if parsed.netloc else ""
|
995
|
-
normalized_path = path or "/"
|
996
|
-
return urllib.parse.urlunsplit((parsed.scheme, netloc, normalized_path, "", ""))
|
583
|
+
return normalize_server_url(server_url)
|
997
584
|
|
998
585
|
def _format_server_host(self, server_url: str) -> str:
|
999
|
-
|
1000
|
-
if not parsed.scheme:
|
1001
|
-
parsed = urllib.parse.urlsplit(f"http://{server_url}")
|
1002
|
-
|
1003
|
-
host = parsed.netloc or parsed.path or server_url
|
1004
|
-
if parsed.netloc and parsed.path and parsed.path not in {"", "/"}:
|
1005
|
-
host = f"{parsed.netloc}{parsed.path}"
|
1006
|
-
|
1007
|
-
host = host.rstrip("/").split(":")[0]
|
1008
|
-
return host or server_url
|
586
|
+
return format_server_host(server_url)
|
1009
587
|
|
1010
|
-
def
|
1011
|
-
|
1012
|
-
|
1013
|
-
|
1014
|
-
|
1015
|
-
|
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,
|
1016
624
|
)
|
1017
625
|
|
1018
|
-
|
1019
|
-
|
1020
|
-
status = getattr(response, "status", None)
|
1021
|
-
if status is None:
|
1022
|
-
status = response.getcode()
|
1023
|
-
if status is None:
|
1024
|
-
return False
|
1025
|
-
return 200 <= int(status) < 500
|
1026
|
-
except (urllib.error.URLError, ValueError):
|
1027
|
-
return False
|
626
|
+
def _ping_server(self, server_url: str, *, timeout: float = 5.0) -> bool:
|
627
|
+
return ping_server(server_url, timeout=timeout)
|
1028
628
|
|
1029
629
|
def _start_discovery(self) -> None:
|
1030
|
-
|
1031
|
-
|
1032
|
-
if self._discovery_thread and self._discovery_thread.is_alive():
|
1033
|
-
return
|
1034
|
-
|
1035
|
-
self.server_discover_button.configure(
|
1036
|
-
state=self.tk.DISABLED, text="Discovering…"
|
1037
|
-
)
|
1038
|
-
self._append_log("Discovering Talks Reducer servers on port 9005…")
|
1039
|
-
|
1040
|
-
def worker() -> None:
|
1041
|
-
try:
|
1042
|
-
urls = discover_servers(
|
1043
|
-
progress_callback=lambda current, total: self._notify(
|
1044
|
-
lambda c=current, t=total: self._on_discovery_progress(c, t)
|
1045
|
-
)
|
1046
|
-
)
|
1047
|
-
except Exception as exc: # pragma: no cover - network failure safeguard
|
1048
|
-
self._notify(lambda: self._on_discovery_failed(exc))
|
1049
|
-
return
|
1050
|
-
self._notify(lambda: self._on_discovery_complete(urls))
|
1051
|
-
|
1052
|
-
self._discovery_thread = threading.Thread(target=worker, daemon=True)
|
1053
|
-
self._discovery_thread.start()
|
630
|
+
discovery_helpers.start_discovery(self)
|
1054
631
|
|
1055
632
|
def _on_discovery_failed(self, exc: Exception) -> None:
|
1056
|
-
|
1057
|
-
message = f"Discovery failed: {exc}"
|
1058
|
-
self._append_log(message)
|
1059
|
-
self.messagebox.showerror("Discovery failed", message)
|
633
|
+
discovery_helpers.on_discovery_failed(self, exc)
|
1060
634
|
|
1061
635
|
def _on_discovery_progress(self, current: int, total: int) -> None:
|
1062
|
-
|
1063
|
-
bounded = max(0, min(current, total))
|
1064
|
-
label = f"{bounded} / {total}"
|
1065
|
-
else:
|
1066
|
-
label = "Discovering…"
|
1067
|
-
self.server_discover_button.configure(text=label)
|
636
|
+
discovery_helpers.on_discovery_progress(self, current, total)
|
1068
637
|
|
1069
638
|
def _on_discovery_complete(self, urls: List[str]) -> None:
|
1070
|
-
|
1071
|
-
if not urls:
|
1072
|
-
self._append_log("No Talks Reducer servers were found.")
|
1073
|
-
self.messagebox.showinfo(
|
1074
|
-
"No servers found",
|
1075
|
-
"No Talks Reducer servers responded on port 9005.",
|
1076
|
-
)
|
1077
|
-
return
|
1078
|
-
|
1079
|
-
self._append_log(
|
1080
|
-
f"Discovered {len(urls)} server{'s' if len(urls) != 1 else ''}."
|
1081
|
-
)
|
1082
|
-
|
1083
|
-
if len(urls) == 1:
|
1084
|
-
self.server_url_var.set(urls[0])
|
1085
|
-
return
|
1086
|
-
|
1087
|
-
self._show_discovery_results(urls)
|
639
|
+
discovery_helpers.on_discovery_complete(self, urls)
|
1088
640
|
|
1089
641
|
def _show_discovery_results(self, urls: List[str]) -> None:
|
1090
|
-
|
1091
|
-
dialog.title("Select server")
|
1092
|
-
dialog.transient(self.root)
|
1093
|
-
dialog.grab_set()
|
1094
|
-
|
1095
|
-
self.ttk.Label(dialog, text="Select a Talks Reducer server:").grid(
|
1096
|
-
row=0, column=0, columnspan=2, sticky="w", padx=self.PADDING, pady=(12, 4)
|
1097
|
-
)
|
1098
|
-
|
1099
|
-
listbox = self.tk.Listbox(
|
1100
|
-
dialog,
|
1101
|
-
height=min(10, len(urls)),
|
1102
|
-
selectmode=self.tk.SINGLE,
|
1103
|
-
)
|
1104
|
-
listbox.grid(
|
1105
|
-
row=1,
|
1106
|
-
column=0,
|
1107
|
-
columnspan=2,
|
1108
|
-
padx=self.PADDING,
|
1109
|
-
sticky="nsew",
|
1110
|
-
)
|
1111
|
-
dialog.columnconfigure(0, weight=1)
|
1112
|
-
dialog.columnconfigure(1, weight=1)
|
1113
|
-
dialog.rowconfigure(1, weight=1)
|
1114
|
-
|
1115
|
-
for url in urls:
|
1116
|
-
listbox.insert(self.tk.END, url)
|
1117
|
-
listbox.select_set(0)
|
1118
|
-
|
1119
|
-
def choose(_: object | None = None) -> None:
|
1120
|
-
selection = listbox.curselection()
|
1121
|
-
if not selection:
|
1122
|
-
return
|
1123
|
-
index = selection[0]
|
1124
|
-
self.server_url_var.set(urls[index])
|
1125
|
-
dialog.grab_release()
|
1126
|
-
dialog.destroy()
|
1127
|
-
|
1128
|
-
def cancel() -> None:
|
1129
|
-
dialog.grab_release()
|
1130
|
-
dialog.destroy()
|
1131
|
-
|
1132
|
-
listbox.bind("<Double-Button-1>", choose)
|
1133
|
-
listbox.bind("<Return>", choose)
|
1134
|
-
|
1135
|
-
button_frame = self.ttk.Frame(dialog)
|
1136
|
-
button_frame.grid(row=2, column=0, columnspan=2, pady=(8, 12))
|
1137
|
-
self.ttk.Button(button_frame, text="Use server", command=choose).pack(
|
1138
|
-
side=self.tk.LEFT, padx=(0, 8)
|
1139
|
-
)
|
1140
|
-
self.ttk.Button(button_frame, text="Cancel", command=cancel).pack(
|
1141
|
-
side=self.tk.LEFT
|
1142
|
-
)
|
1143
|
-
dialog.protocol("WM_DELETE_WINDOW", cancel)
|
642
|
+
discovery_helpers.show_discovery_results(self, urls)
|
1144
643
|
|
1145
644
|
def _toggle_simple_mode(self) -> None:
|
1146
|
-
self.
|
645
|
+
self.preferences.update("simple_mode", self.simple_mode_var.get())
|
1147
646
|
self._apply_simple_mode()
|
1148
647
|
|
1149
648
|
def _apply_simple_mode(self, *, initial: bool = False) -> None:
|
1150
|
-
|
1151
|
-
if simple:
|
1152
|
-
self.basic_options_frame.grid_remove()
|
1153
|
-
self.log_frame.grid_remove()
|
1154
|
-
self.stop_button.grid_remove()
|
1155
|
-
self.advanced_button.grid_remove()
|
1156
|
-
self.advanced_frame.grid_remove()
|
1157
|
-
self.run_after_drop_var.set(True)
|
1158
|
-
self._apply_window_size(simple=True)
|
1159
|
-
else:
|
1160
|
-
self.basic_options_frame.grid()
|
1161
|
-
self.log_frame.grid()
|
1162
|
-
self.advanced_button.grid()
|
1163
|
-
if self.advanced_visible.get():
|
1164
|
-
self.advanced_frame.grid()
|
1165
|
-
self._apply_window_size(simple=False)
|
1166
|
-
|
1167
|
-
if initial and simple:
|
1168
|
-
# Ensure the hidden widgets do not retain focus outlines on start.
|
1169
|
-
self.drop_zone.focus_set()
|
649
|
+
layout_helpers.apply_simple_mode(self, initial=initial)
|
1170
650
|
|
1171
651
|
def _apply_window_size(self, *, simple: bool) -> None:
|
1172
|
-
|
1173
|
-
self.root.update_idletasks()
|
1174
|
-
self.root.minsize(width, height)
|
1175
|
-
if simple:
|
1176
|
-
self.root.geometry(f"{width}x{height}")
|
1177
|
-
else:
|
1178
|
-
current_width = self.root.winfo_width()
|
1179
|
-
current_height = self.root.winfo_height()
|
1180
|
-
if current_width < width or current_height < height:
|
1181
|
-
self.root.geometry(f"{width}x{height}")
|
652
|
+
layout_helpers.apply_window_size(self, simple=simple)
|
1182
653
|
|
1183
654
|
def _toggle_advanced(self, *, initial: bool = False) -> None:
|
1184
655
|
if not initial:
|
@@ -1192,14 +663,14 @@ class TalksReducerGUI:
|
|
1192
663
|
self.advanced_button.configure(text="Advanced")
|
1193
664
|
|
1194
665
|
def _on_theme_change(self, *_: object) -> None:
|
1195
|
-
self.
|
1196
|
-
self.
|
666
|
+
self.preferences.update("theme", self.theme_var.get())
|
667
|
+
self._refresh_theme()
|
1197
668
|
|
1198
669
|
def _on_small_video_change(self, *_: object) -> None:
|
1199
|
-
self.
|
670
|
+
self.preferences.update("small_video", bool(self.small_var.get()))
|
1200
671
|
|
1201
672
|
def _on_open_after_convert_change(self, *_: object) -> None:
|
1202
|
-
self.
|
673
|
+
self.preferences.update(
|
1203
674
|
"open_after_convert", bool(self.open_after_convert_var.get())
|
1204
675
|
)
|
1205
676
|
|
@@ -1208,216 +679,63 @@ class TalksReducerGUI:
|
|
1208
679
|
if value not in {"local", "remote"}:
|
1209
680
|
self.processing_mode_var.set("local")
|
1210
681
|
return
|
1211
|
-
self.
|
682
|
+
self.preferences.update("processing_mode", value)
|
1212
683
|
self._update_processing_mode_state()
|
1213
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
|
+
|
1214
704
|
def _on_server_url_change(self, *_: object) -> None:
|
1215
705
|
value = self.server_url_var.get().strip()
|
1216
|
-
self.
|
706
|
+
self.preferences.update("server_url", value)
|
1217
707
|
self._update_processing_mode_state()
|
1218
708
|
|
1219
|
-
def
|
709
|
+
def _resolve_theme_mode(self) -> str:
|
1220
710
|
preference = self.theme_var.get().lower()
|
1221
711
|
if preference not in {"light", "dark"}:
|
1222
|
-
|
1223
|
-
|
1224
|
-
|
1225
|
-
|
1226
|
-
|
1227
|
-
|
1228
|
-
self.root.configure(bg=palette["background"])
|
1229
|
-
self.style.theme_use("clam")
|
1230
|
-
self.style.configure(
|
1231
|
-
".", background=palette["background"], foreground=palette["foreground"]
|
1232
|
-
)
|
1233
|
-
self.style.configure("TFrame", background=palette["background"])
|
1234
|
-
self.style.configure(
|
1235
|
-
"TLabelframe",
|
1236
|
-
background=palette["background"],
|
1237
|
-
foreground=palette["foreground"],
|
1238
|
-
borderwidth=0,
|
1239
|
-
relief="flat",
|
1240
|
-
)
|
1241
|
-
self.style.configure(
|
1242
|
-
"TLabelframe.Label",
|
1243
|
-
background=palette["background"],
|
1244
|
-
foreground=palette["foreground"],
|
1245
|
-
)
|
1246
|
-
self.style.configure(
|
1247
|
-
"TLabel", background=palette["background"], foreground=palette["foreground"]
|
1248
|
-
)
|
1249
|
-
self.style.configure(
|
1250
|
-
"TCheckbutton",
|
1251
|
-
background=palette["background"],
|
1252
|
-
foreground=palette["foreground"],
|
1253
|
-
)
|
1254
|
-
self.style.map(
|
1255
|
-
"TCheckbutton",
|
1256
|
-
background=[("active", palette.get("hover", palette["background"]))],
|
1257
|
-
)
|
1258
|
-
self.style.configure(
|
1259
|
-
"TRadiobutton",
|
1260
|
-
background=palette["background"],
|
1261
|
-
foreground=palette["foreground"],
|
1262
|
-
)
|
1263
|
-
self.style.map(
|
1264
|
-
"TRadiobutton",
|
1265
|
-
background=[("active", palette.get("hover", palette["background"]))],
|
1266
|
-
)
|
1267
|
-
self.style.configure(
|
1268
|
-
"Link.TButton",
|
1269
|
-
background=palette["background"],
|
1270
|
-
foreground=palette["accent"],
|
1271
|
-
borderwidth=0,
|
1272
|
-
relief="flat",
|
1273
|
-
highlightthickness=0,
|
1274
|
-
padding=2,
|
1275
|
-
font=("TkDefaultFont", 8, "underline"),
|
1276
|
-
)
|
1277
|
-
self.style.map(
|
1278
|
-
"Link.TButton",
|
1279
|
-
background=[
|
1280
|
-
("active", palette.get("hover", palette["background"])),
|
1281
|
-
("disabled", palette["background"]),
|
1282
|
-
],
|
1283
|
-
foreground=[
|
1284
|
-
("active", palette.get("accent", palette["foreground"])),
|
1285
|
-
("disabled", palette["foreground"]),
|
1286
|
-
],
|
1287
|
-
)
|
1288
|
-
self.style.configure(
|
1289
|
-
"TButton",
|
1290
|
-
background=palette["surface"],
|
1291
|
-
foreground=palette["foreground"],
|
1292
|
-
padding=4,
|
1293
|
-
font=("TkDefaultFont", 8),
|
1294
|
-
)
|
1295
|
-
self.style.map(
|
1296
|
-
"TButton",
|
1297
|
-
background=[
|
1298
|
-
("active", palette.get("hover", palette["accent"])),
|
1299
|
-
("disabled", palette["surface"]),
|
1300
|
-
],
|
1301
|
-
foreground=[
|
1302
|
-
("active", palette.get("hover_text", "#000000")),
|
1303
|
-
("disabled", palette["foreground"]),
|
1304
|
-
],
|
1305
|
-
)
|
1306
|
-
self.style.configure(
|
1307
|
-
"TEntry",
|
1308
|
-
fieldbackground=palette["surface"],
|
1309
|
-
foreground=palette["foreground"],
|
1310
|
-
)
|
1311
|
-
self.style.configure(
|
1312
|
-
"TCombobox",
|
1313
|
-
fieldbackground=palette["surface"],
|
1314
|
-
foreground=palette["foreground"],
|
1315
|
-
)
|
1316
|
-
|
1317
|
-
# Configure progress bar styles for different states
|
1318
|
-
self.style.configure(
|
1319
|
-
"Idle.Horizontal.TProgressbar",
|
1320
|
-
background=STATUS_COLORS["idle"],
|
1321
|
-
troughcolor=palette["surface"],
|
1322
|
-
borderwidth=0,
|
1323
|
-
thickness=20,
|
1324
|
-
)
|
1325
|
-
self.style.configure(
|
1326
|
-
"Processing.Horizontal.TProgressbar",
|
1327
|
-
background=STATUS_COLORS["processing"],
|
1328
|
-
troughcolor=palette["surface"],
|
1329
|
-
borderwidth=0,
|
1330
|
-
thickness=20,
|
1331
|
-
)
|
1332
|
-
self.style.configure(
|
1333
|
-
"Success.Horizontal.TProgressbar",
|
1334
|
-
background=STATUS_COLORS["success"],
|
1335
|
-
troughcolor=palette["surface"],
|
1336
|
-
borderwidth=0,
|
1337
|
-
thickness=20,
|
1338
|
-
)
|
1339
|
-
self.style.configure(
|
1340
|
-
"Error.Horizontal.TProgressbar",
|
1341
|
-
background=STATUS_COLORS["error"],
|
1342
|
-
troughcolor=palette["surface"],
|
1343
|
-
borderwidth=0,
|
1344
|
-
thickness=20,
|
1345
|
-
)
|
1346
|
-
self.style.configure(
|
1347
|
-
"Aborted.Horizontal.TProgressbar",
|
1348
|
-
background=STATUS_COLORS["aborted"],
|
1349
|
-
troughcolor=palette["surface"],
|
1350
|
-
borderwidth=0,
|
1351
|
-
thickness=20,
|
1352
|
-
)
|
1353
|
-
|
1354
|
-
self.drop_zone.configure(
|
1355
|
-
bg=palette["surface"],
|
1356
|
-
fg=palette["foreground"],
|
1357
|
-
highlightthickness=0,
|
1358
|
-
)
|
1359
|
-
|
1360
|
-
slider_relief = self.tk.FLAT if mode == "dark" else self.tk.RAISED
|
1361
|
-
active_background = (
|
1362
|
-
palette.get("accent", palette["surface"])
|
1363
|
-
if mode == "dark"
|
1364
|
-
else palette.get("hover", palette["surface"])
|
1365
|
-
)
|
1366
|
-
for slider in getattr(self, "_sliders", []):
|
1367
|
-
slider.configure(
|
1368
|
-
# background=palette["background"],
|
1369
|
-
# foreground=palette["foreground"],
|
1370
|
-
troughcolor=palette["surface"],
|
1371
|
-
# activebackground=active_background,
|
1372
|
-
# highlightbackground=palette["background"],
|
1373
|
-
highlightcolor=palette["background"],
|
1374
|
-
sliderrelief=slider_relief,
|
1375
|
-
bd=0,
|
712
|
+
return detect_system_theme(
|
713
|
+
os.environ,
|
714
|
+
sys.platform,
|
715
|
+
read_windows_theme_registry,
|
716
|
+
run_defaults_command,
|
1376
717
|
)
|
1377
|
-
|
1378
|
-
bg=palette["surface"],
|
1379
|
-
fg=palette["foreground"],
|
1380
|
-
insertbackground=palette["foreground"],
|
1381
|
-
highlightbackground=palette["border"],
|
1382
|
-
highlightcolor=palette["border"],
|
1383
|
-
)
|
1384
|
-
self.status_label.configure(bg=palette["background"])
|
1385
|
-
|
1386
|
-
self._apply_status_style(self._status_state)
|
1387
|
-
|
1388
|
-
def _detect_system_theme(self) -> str:
|
1389
|
-
if sys.platform.startswith("win"):
|
1390
|
-
try:
|
1391
|
-
import winreg # type: ignore
|
1392
|
-
|
1393
|
-
with winreg.OpenKey(
|
1394
|
-
winreg.HKEY_CURRENT_USER,
|
1395
|
-
r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
|
1396
|
-
) as key:
|
1397
|
-
value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
|
1398
|
-
return "light" if int(value) else "dark"
|
1399
|
-
except OSError:
|
1400
|
-
return "light"
|
1401
|
-
if sys.platform == "darwin":
|
1402
|
-
try:
|
1403
|
-
result = subprocess.run(
|
1404
|
-
["defaults", "read", "-g", "AppleInterfaceStyle"],
|
1405
|
-
capture_output=True,
|
1406
|
-
text=True,
|
1407
|
-
check=False,
|
1408
|
-
)
|
1409
|
-
if result.returncode == 0 and result.stdout.strip().lower() == "dark":
|
1410
|
-
return "dark"
|
1411
|
-
except Exception:
|
1412
|
-
pass
|
1413
|
-
return "light"
|
1414
|
-
|
1415
|
-
theme = os.environ.get("GTK_THEME", "").lower()
|
1416
|
-
if "dark" in theme:
|
1417
|
-
return "dark"
|
1418
|
-
return "light"
|
718
|
+
return preference
|
1419
719
|
|
1420
|
-
def
|
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:
|
1421
739
|
if not self._dnd_available:
|
1422
740
|
return
|
1423
741
|
widget.drop_target_register(DND_FILES) # type: ignore[arg-type]
|
@@ -1509,124 +827,16 @@ class TalksReducerGUI:
|
|
1509
827
|
if result:
|
1510
828
|
variable.set(result)
|
1511
829
|
|
1512
|
-
def _start_run(self) -> None:
|
1513
|
-
if self._processing_thread and self._processing_thread.is_alive():
|
1514
|
-
self.messagebox.showinfo("Processing", "A job is already running.")
|
1515
|
-
return
|
1516
|
-
|
1517
|
-
if not self.input_files:
|
1518
|
-
self.messagebox.showwarning(
|
1519
|
-
"Missing input", "Please add at least one file or folder."
|
1520
|
-
)
|
1521
|
-
return
|
1522
|
-
|
1523
|
-
try:
|
1524
|
-
args = self._collect_arguments()
|
1525
|
-
except ValueError as exc:
|
1526
|
-
self.messagebox.showerror("Invalid value", str(exc))
|
1527
|
-
return
|
1528
|
-
|
1529
|
-
self._append_log("Starting processing…")
|
1530
|
-
self._stop_requested = False
|
1531
|
-
open_after_convert = bool(self.open_after_convert_var.get())
|
1532
|
-
server_url = self.server_url_var.get().strip()
|
1533
|
-
remote_mode = self.processing_mode_var.get() == "remote"
|
1534
|
-
if remote_mode and not server_url:
|
1535
|
-
self.messagebox.showerror(
|
1536
|
-
"Missing server URL", "Remote mode requires a server URL."
|
1537
|
-
)
|
1538
|
-
return
|
1539
|
-
remote_mode = remote_mode and bool(server_url)
|
1540
|
-
|
1541
|
-
def worker() -> None:
|
1542
|
-
def set_process(proc: subprocess.Popen) -> None:
|
1543
|
-
self._ffmpeg_process = proc
|
1544
|
-
|
1545
|
-
try:
|
1546
|
-
files = gather_input_files(self.input_files)
|
1547
|
-
if not files:
|
1548
|
-
self._notify(
|
1549
|
-
lambda: self.messagebox.showwarning(
|
1550
|
-
"No files", "No supported media files were found."
|
1551
|
-
)
|
1552
|
-
)
|
1553
|
-
self._set_status("Idle")
|
1554
|
-
return
|
1555
|
-
|
1556
|
-
if remote_mode:
|
1557
|
-
success = self._process_files_via_server(
|
1558
|
-
files,
|
1559
|
-
args,
|
1560
|
-
server_url,
|
1561
|
-
open_after_convert=open_after_convert,
|
1562
|
-
)
|
1563
|
-
if success:
|
1564
|
-
self._notify(self._hide_stop_button)
|
1565
|
-
return
|
1566
|
-
|
1567
|
-
reporter = _TkProgressReporter(
|
1568
|
-
self._append_log, process_callback=set_process
|
1569
|
-
)
|
1570
|
-
for index, file in enumerate(files, start=1):
|
1571
|
-
self._append_log(
|
1572
|
-
f"Processing {index}/{len(files)}: {os.path.basename(file)}"
|
1573
|
-
)
|
1574
|
-
options = self._build_options(Path(file), args)
|
1575
|
-
result = speed_up_video(options, reporter=reporter)
|
1576
|
-
self._last_output = result.output_file
|
1577
|
-
self._last_time_ratio = result.time_ratio
|
1578
|
-
self._last_size_ratio = result.size_ratio
|
1579
|
-
|
1580
|
-
# Create completion message with ratios if available
|
1581
|
-
completion_msg = f"Completed: {result.output_file}"
|
1582
|
-
if result.time_ratio is not None and result.size_ratio is not None:
|
1583
|
-
completion_msg += f" (Time: {result.time_ratio:.2%}, Size: {result.size_ratio:.2%})"
|
1584
|
-
|
1585
|
-
self._append_log(completion_msg)
|
1586
|
-
if open_after_convert:
|
1587
|
-
self._notify(
|
1588
|
-
lambda path=result.output_file: self._open_in_file_manager(
|
1589
|
-
path
|
1590
|
-
)
|
1591
|
-
)
|
1592
|
-
|
1593
|
-
self._append_log("All jobs finished successfully.")
|
1594
|
-
self._notify(lambda: self.open_button.configure(state=self.tk.NORMAL))
|
1595
|
-
self._notify(self._clear_input_files)
|
1596
|
-
except FFmpegNotFoundError as exc:
|
1597
|
-
self._notify(
|
1598
|
-
lambda: self.messagebox.showerror("FFmpeg not found", str(exc))
|
1599
|
-
)
|
1600
|
-
self._set_status("Error")
|
1601
|
-
except Exception as exc: # pragma: no cover - GUI level safeguard
|
1602
|
-
# If stop was requested, don't show error (FFmpeg termination is expected)
|
1603
|
-
if self._stop_requested:
|
1604
|
-
self._append_log("Processing aborted by user.")
|
1605
|
-
self._set_status("Aborted")
|
1606
|
-
else:
|
1607
|
-
error_msg = f"Processing failed: {exc}"
|
1608
|
-
self._append_log(error_msg)
|
1609
|
-
print(error_msg, file=sys.stderr) # Also output to console
|
1610
|
-
self._notify(lambda: self.messagebox.showerror("Error", error_msg))
|
1611
|
-
self._set_status("Error")
|
1612
|
-
finally:
|
1613
|
-
self._notify(self._hide_stop_button)
|
1614
|
-
|
1615
|
-
self._processing_thread = threading.Thread(target=worker, daemon=True)
|
1616
|
-
self._processing_thread.start()
|
1617
|
-
|
1618
|
-
# Show Stop button when processing starts
|
1619
|
-
if remote_mode:
|
1620
|
-
self.stop_button.grid_remove()
|
1621
|
-
else:
|
1622
|
-
self.stop_button.grid()
|
1623
|
-
|
1624
830
|
def _stop_processing(self) -> None:
|
1625
831
|
"""Stop the currently running processing by terminating FFmpeg."""
|
1626
832
|
import signal
|
1627
833
|
|
1628
834
|
self._stop_requested = True
|
1629
|
-
|
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:
|
1630
840
|
self._append_log("Stopping FFmpeg process...")
|
1631
841
|
try:
|
1632
842
|
# Send SIGTERM to FFmpeg process
|
@@ -1693,101 +903,15 @@ class TalksReducerGUI:
|
|
1693
903
|
) -> bool:
|
1694
904
|
"""Send *files* to the configured server for processing."""
|
1695
905
|
|
1696
|
-
|
1697
|
-
|
1698
|
-
|
1699
|
-
|
1700
|
-
|
1701
|
-
|
1702
|
-
|
1703
|
-
|
1704
|
-
)
|
1705
|
-
)
|
1706
|
-
self._notify(lambda: self._set_status("Error"))
|
1707
|
-
return False
|
1708
|
-
|
1709
|
-
host_label = self._format_server_host(server_url)
|
1710
|
-
self._notify(
|
1711
|
-
lambda: self._set_status("waiting", f"Waiting server {host_label}...")
|
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,
|
1712
914
|
)
|
1713
|
-
if not self._ping_server(server_url):
|
1714
|
-
self._append_log(f"Server unreachable: {server_url}")
|
1715
|
-
self._notify(
|
1716
|
-
lambda: self._set_status("Error", f"Server {host_label} unreachable")
|
1717
|
-
)
|
1718
|
-
return False
|
1719
|
-
|
1720
|
-
self._notify(lambda: self._set_status("waiting", f"Server {host_label} ready"))
|
1721
|
-
|
1722
|
-
output_override = args.get("output_file") if len(files) == 1 else None
|
1723
|
-
ignored = [key for key in args if key not in {"output_file", "small"}]
|
1724
|
-
if ignored:
|
1725
|
-
ignored_options = ", ".join(sorted(ignored))
|
1726
|
-
self._append_log(
|
1727
|
-
f"Server mode ignores the following options: {ignored_options}"
|
1728
|
-
)
|
1729
|
-
|
1730
|
-
small_mode = bool(args.get("small", False))
|
1731
|
-
|
1732
|
-
for index, file in enumerate(files, start=1):
|
1733
|
-
basename = os.path.basename(file)
|
1734
|
-
self._append_log(
|
1735
|
-
f"Uploading {index}/{len(files)}: {basename} to {server_url}"
|
1736
|
-
)
|
1737
|
-
input_path = Path(file)
|
1738
|
-
|
1739
|
-
if output_override is not None:
|
1740
|
-
output_path = Path(output_override)
|
1741
|
-
if output_path.is_dir():
|
1742
|
-
output_path = (
|
1743
|
-
output_path
|
1744
|
-
/ _default_remote_destination(input_path, small=small_mode).name
|
1745
|
-
)
|
1746
|
-
else:
|
1747
|
-
output_path = _default_remote_destination(input_path, small=small_mode)
|
1748
|
-
|
1749
|
-
try:
|
1750
|
-
destination, summary, log_text = service_module.send_video(
|
1751
|
-
input_path=input_path,
|
1752
|
-
output_path=output_path,
|
1753
|
-
server_url=server_url,
|
1754
|
-
small=small_mode,
|
1755
|
-
stream_updates=True,
|
1756
|
-
log_callback=self._append_log,
|
1757
|
-
# progress_callback=self._handle_service_progress,
|
1758
|
-
)
|
1759
|
-
except Exception as exc: # pragma: no cover - network safeguard
|
1760
|
-
error_detail = f"{exc.__class__.__name__}: {exc}"
|
1761
|
-
error_msg = f"Processing failed: {error_detail}"
|
1762
|
-
self._append_log(error_msg)
|
1763
|
-
self._notify(lambda: self._set_status("Error"))
|
1764
|
-
self._notify(
|
1765
|
-
lambda: self.messagebox.showerror(
|
1766
|
-
"Server error",
|
1767
|
-
f"Failed to process {basename}: {error_detail}",
|
1768
|
-
)
|
1769
|
-
)
|
1770
|
-
return False
|
1771
|
-
|
1772
|
-
self._last_output = Path(destination)
|
1773
|
-
time_ratio, size_ratio = _parse_ratios_from_summary(summary)
|
1774
|
-
self._last_time_ratio = time_ratio
|
1775
|
-
self._last_size_ratio = size_ratio
|
1776
|
-
for line in summary.splitlines():
|
1777
|
-
self._append_log(line)
|
1778
|
-
if log_text.strip():
|
1779
|
-
self._append_log("Server log:")
|
1780
|
-
for line in log_text.splitlines():
|
1781
|
-
self._append_log(line)
|
1782
|
-
if open_after_convert:
|
1783
|
-
self._notify(
|
1784
|
-
lambda path=self._last_output: self._open_in_file_manager(path)
|
1785
|
-
)
|
1786
|
-
|
1787
|
-
self._append_log("All jobs finished successfully.")
|
1788
|
-
self._notify(lambda: self.open_button.configure(state=self.tk.NORMAL))
|
1789
|
-
self._notify(self._clear_input_files)
|
1790
|
-
return True
|
1791
915
|
|
1792
916
|
def _parse_float(self, value: str, label: str) -> float:
|
1793
917
|
try:
|
@@ -1795,7 +919,7 @@ class TalksReducerGUI:
|
|
1795
919
|
except ValueError as exc: # pragma: no cover - input validation
|
1796
920
|
raise ValueError(f"{label} must be a number.") from exc
|
1797
921
|
|
1798
|
-
def
|
922
|
+
def _create_processing_options(
|
1799
923
|
self, input_file: Path, args: dict[str, object]
|
1800
924
|
) -> ProcessingOptions:
|
1801
925
|
options = dict(args)
|
@@ -1840,7 +964,7 @@ class TalksReducerGUI:
|
|
1840
964
|
def _update_status_from_message(self, message: str) -> None:
|
1841
965
|
normalized = message.strip().lower()
|
1842
966
|
metadata_match = re.search(
|
1843
|
-
r"source metadata
|
967
|
+
r"source metadata: duration:\s*([\d.]+)s",
|
1844
968
|
message,
|
1845
969
|
re.IGNORECASE,
|
1846
970
|
)
|
@@ -1850,18 +974,48 @@ class TalksReducerGUI:
|
|
1850
974
|
except ValueError:
|
1851
975
|
self._source_duration_seconds = None
|
1852
976
|
if "all jobs finished successfully" in normalized:
|
1853
|
-
|
1854
|
-
|
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
|
+
|
1855
1003
|
if self._last_time_ratio is not None and self._last_size_ratio is not None:
|
1856
|
-
|
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)
|
1857
1009
|
|
1858
1010
|
self._reset_audio_progress_state(clear_source=True)
|
1859
1011
|
self._set_status("success", status_msg)
|
1860
1012
|
self._set_progress(100) # 100% on success
|
1013
|
+
self._run_start_time = None
|
1861
1014
|
self._video_duration_seconds = None # Reset for next video
|
1862
1015
|
self._encode_target_duration_seconds = None
|
1863
1016
|
self._encode_total_frames = None
|
1864
1017
|
self._encode_current_frame = None
|
1018
|
+
self._last_progress_seconds = None
|
1865
1019
|
elif normalized.startswith("extracting audio"):
|
1866
1020
|
self._reset_audio_progress_state(clear_source=False)
|
1867
1021
|
self._set_status("processing", "Extracting audio...")
|
@@ -1870,6 +1024,7 @@ class TalksReducerGUI:
|
|
1870
1024
|
self._encode_target_duration_seconds = None
|
1871
1025
|
self._encode_total_frames = None
|
1872
1026
|
self._encode_current_frame = None
|
1027
|
+
self._last_progress_seconds = None
|
1873
1028
|
self._start_audio_progress()
|
1874
1029
|
elif normalized.startswith("uploading"):
|
1875
1030
|
self._set_status("processing", "Uploading...")
|
@@ -1881,6 +1036,7 @@ class TalksReducerGUI:
|
|
1881
1036
|
self._encode_target_duration_seconds = None
|
1882
1037
|
self._encode_total_frames = None
|
1883
1038
|
self._encode_current_frame = None
|
1039
|
+
self._last_progress_seconds = None
|
1884
1040
|
elif normalized.startswith("processing"):
|
1885
1041
|
is_new_job = bool(re.match(r"processing \d+/\d+:", normalized))
|
1886
1042
|
should_reset = self._status_state.lower() != "processing" or is_new_job
|
@@ -1890,6 +1046,7 @@ class TalksReducerGUI:
|
|
1890
1046
|
self._encode_target_duration_seconds = None
|
1891
1047
|
self._encode_total_frames = None
|
1892
1048
|
self._encode_current_frame = None
|
1049
|
+
self._last_progress_seconds = None
|
1893
1050
|
if is_new_job:
|
1894
1051
|
self._reset_audio_progress_state(clear_source=True)
|
1895
1052
|
self._set_status("processing", "Processing")
|
@@ -1962,6 +1119,8 @@ class TalksReducerGUI:
|
|
1962
1119
|
time_str = self._format_progress_time(current_seconds)
|
1963
1120
|
speed_str = speed_match.group(1)
|
1964
1121
|
|
1122
|
+
self._last_progress_seconds = current_seconds
|
1123
|
+
|
1965
1124
|
total_seconds = (
|
1966
1125
|
self._encode_target_duration_seconds or self._video_duration_seconds
|
1967
1126
|
)
|
@@ -2010,7 +1169,7 @@ class TalksReducerGUI:
|
|
2010
1169
|
interval_ms, self._advance_audio_progress
|
2011
1170
|
)
|
2012
1171
|
|
2013
|
-
self.
|
1172
|
+
self._schedule_on_ui_thread(_start)
|
2014
1173
|
|
2015
1174
|
def _advance_audio_progress(self) -> None:
|
2016
1175
|
self._audio_progress_job = None
|
@@ -2047,7 +1206,7 @@ class TalksReducerGUI:
|
|
2047
1206
|
self._audio_progress_job = None
|
2048
1207
|
self._audio_progress_interval_ms = None
|
2049
1208
|
|
2050
|
-
self.
|
1209
|
+
self._schedule_on_ui_thread(_cancel)
|
2051
1210
|
|
2052
1211
|
def _reset_audio_progress_state(self, *, clear_source: bool) -> None:
|
2053
1212
|
if clear_source:
|
@@ -2069,7 +1228,7 @@ class TalksReducerGUI:
|
|
2069
1228
|
if current_value < self.AUDIO_PROGRESS_STEPS:
|
2070
1229
|
self._set_progress(self.AUDIO_PROGRESS_STEPS)
|
2071
1230
|
|
2072
|
-
self.
|
1231
|
+
self._schedule_on_ui_thread(_complete)
|
2073
1232
|
|
2074
1233
|
def _apply_status_style(self, status: str) -> None:
|
2075
1234
|
color = STATUS_COLORS.get(status.lower())
|
@@ -2081,7 +1240,10 @@ class TalksReducerGUI:
|
|
2081
1240
|
status_lower = status.lower()
|
2082
1241
|
if (
|
2083
1242
|
"extracting audio" in status_lower
|
2084
|
-
or re.search(
|
1243
|
+
or re.search(
|
1244
|
+
r"\d+:\d{2}(?::\d{2})?(?: / \d+:\d{2}(?::\d{2})?)?.*\d+\.?\d*x",
|
1245
|
+
status,
|
1246
|
+
)
|
2085
1247
|
or ("time:" in status_lower and "size:" in status_lower)
|
2086
1248
|
):
|
2087
1249
|
if "time:" in status_lower and "size:" in status_lower:
|
@@ -2132,7 +1294,7 @@ class TalksReducerGUI:
|
|
2132
1294
|
self.root.after(0, apply)
|
2133
1295
|
|
2134
1296
|
def _format_progress_time(self, total_seconds: float) -> str:
|
2135
|
-
"""Format a duration in seconds as h:mm or m:ss for status display."""
|
1297
|
+
"""Format a duration in seconds as h:mm:ss or m:ss for status display."""
|
2136
1298
|
|
2137
1299
|
try:
|
2138
1300
|
rounded_seconds = max(0, int(round(total_seconds)))
|
@@ -2143,7 +1305,7 @@ class TalksReducerGUI:
|
|
2143
1305
|
minutes, seconds = divmod(remainder, 60)
|
2144
1306
|
|
2145
1307
|
if hours > 0:
|
2146
|
-
return f"{hours}:{minutes:02d}"
|
1308
|
+
return f"{hours}:{minutes:02d}:{seconds:02d}"
|
2147
1309
|
|
2148
1310
|
total_minutes = rounded_seconds // 60
|
2149
1311
|
return f"{total_minutes}:{seconds:02d}"
|
@@ -2193,7 +1355,7 @@ class TalksReducerGUI:
|
|
2193
1355
|
# Update color based on percentage gradient
|
2194
1356
|
color = self._calculate_gradient_color(percentage, 0.5)
|
2195
1357
|
palette = (
|
2196
|
-
LIGHT_THEME if self.
|
1358
|
+
LIGHT_THEME if self._resolve_theme_mode() == "light" else DARK_THEME
|
2197
1359
|
)
|
2198
1360
|
if self.theme_var.get().lower() in {"light", "dark"}:
|
2199
1361
|
palette = (
|
@@ -2244,7 +1406,7 @@ class TalksReducerGUI:
|
|
2244
1406
|
|
2245
1407
|
self.root.after(0, updater)
|
2246
1408
|
|
2247
|
-
def
|
1409
|
+
def _schedule_on_ui_thread(self, callback: Callable[[], None]) -> None:
|
2248
1410
|
self.root.after(0, callback)
|
2249
1411
|
|
2250
1412
|
def run(self) -> None:
|
@@ -2265,24 +1427,34 @@ def main(argv: Optional[Sequence[str]] = None) -> bool:
|
|
2265
1427
|
|
2266
1428
|
parser = argparse.ArgumentParser(add_help=False)
|
2267
1429
|
parser.add_argument(
|
2268
|
-
"--
|
1430
|
+
"--server",
|
2269
1431
|
action="store_true",
|
2270
|
-
help="
|
1432
|
+
help="Launch the Talks Reducer server tray instead of the desktop GUI.",
|
2271
1433
|
)
|
2272
1434
|
parser.add_argument(
|
2273
|
-
"--
|
1435
|
+
"--no-tray",
|
2274
1436
|
action="store_true",
|
2275
|
-
help="
|
1437
|
+
help="Deprecated: the GUI no longer starts the server tray automatically.",
|
2276
1438
|
)
|
2277
1439
|
|
2278
1440
|
parsed_args, remaining = parser.parse_known_args(argv)
|
2279
|
-
no_tray = parsed_args.no_tray
|
2280
1441
|
if parsed_args.server:
|
2281
1442
|
package_name = __package__ or "talks_reducer"
|
2282
|
-
|
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")
|
2283
1451
|
tray_main = getattr(tray_module, "main")
|
2284
1452
|
tray_main(remaining)
|
2285
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
|
+
)
|
2286
1458
|
argv = remaining
|
2287
1459
|
|
2288
1460
|
if argv:
|
@@ -2298,8 +1470,6 @@ def main(argv: Optional[Sequence[str]] = None) -> bool:
|
|
2298
1470
|
if launch_gui:
|
2299
1471
|
try:
|
2300
1472
|
app = TalksReducerGUI(argv, auto_run=True)
|
2301
|
-
if not no_tray:
|
2302
|
-
_ensure_server_tray_running()
|
2303
1473
|
app.run()
|
2304
1474
|
return True
|
2305
1475
|
except Exception:
|
@@ -2361,8 +1531,6 @@ def main(argv: Optional[Sequence[str]] = None) -> bool:
|
|
2361
1531
|
# Catch and report any errors during GUI initialization
|
2362
1532
|
try:
|
2363
1533
|
app = TalksReducerGUI()
|
2364
|
-
if not no_tray:
|
2365
|
-
_ensure_server_tray_running()
|
2366
1534
|
app.run()
|
2367
1535
|
return True
|
2368
1536
|
except Exception as e:
|