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