talks-reducer 0.5.3__py3-none-any.whl → 0.6.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 +270 -7
- talks_reducer/discovery.py +149 -0
- talks_reducer/gui.py +846 -143
- talks_reducer/pipeline.py +1 -1
- talks_reducer/server.py +104 -23
- talks_reducer/server_tray.py +215 -16
- talks_reducer/service_client.py +258 -4
- {talks_reducer-0.5.3.dist-info → talks_reducer-0.6.0.dist-info}/METADATA +28 -1
- talks_reducer-0.6.0.dist-info/RECORD +21 -0
- talks_reducer-0.5.3.dist-info/RECORD +0 -20
- {talks_reducer-0.5.3.dist-info → talks_reducer-0.6.0.dist-info}/WHEEL +0 -0
- {talks_reducer-0.5.3.dist-info → talks_reducer-0.6.0.dist-info}/entry_points.txt +0 -0
- {talks_reducer-0.5.3.dist-info → talks_reducer-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {talks_reducer-0.5.3.dist-info → talks_reducer-0.6.0.dist-info}/top_level.txt +0 -0
talks_reducer/gui.py
CHANGED
@@ -3,15 +3,28 @@
|
|
3
3
|
from __future__ import annotations
|
4
4
|
|
5
5
|
import argparse
|
6
|
+
import importlib
|
6
7
|
import json
|
7
8
|
import os
|
8
9
|
import re
|
9
10
|
import subprocess
|
10
11
|
import sys
|
11
12
|
import threading
|
13
|
+
import urllib.error
|
14
|
+
import urllib.parse
|
15
|
+
import urllib.request
|
12
16
|
from importlib.metadata import version
|
13
17
|
from pathlib import Path
|
14
|
-
from typing import
|
18
|
+
from typing import (
|
19
|
+
TYPE_CHECKING,
|
20
|
+
Any,
|
21
|
+
Callable,
|
22
|
+
Iterable,
|
23
|
+
List,
|
24
|
+
Optional,
|
25
|
+
Sequence,
|
26
|
+
Tuple,
|
27
|
+
)
|
15
28
|
|
16
29
|
if TYPE_CHECKING:
|
17
30
|
import tkinter as tk
|
@@ -20,6 +33,7 @@ if TYPE_CHECKING:
|
|
20
33
|
try:
|
21
34
|
from .cli import gather_input_files
|
22
35
|
from .cli import main as cli_main
|
36
|
+
from .discovery import discover_servers
|
23
37
|
from .ffmpeg import FFmpegNotFoundError
|
24
38
|
from .models import ProcessingOptions, default_temp_folder
|
25
39
|
from .pipeline import speed_up_video
|
@@ -34,6 +48,7 @@ except ImportError: # pragma: no cover - handled at runtime
|
|
34
48
|
|
35
49
|
from talks_reducer.cli import gather_input_files
|
36
50
|
from talks_reducer.cli import main as cli_main
|
51
|
+
from talks_reducer.discovery import discover_servers
|
37
52
|
from talks_reducer.ffmpeg import FFmpegNotFoundError
|
38
53
|
from talks_reducer.models import ProcessingOptions, default_temp_folder
|
39
54
|
from talks_reducer.pipeline import speed_up_video
|
@@ -119,6 +134,7 @@ except ModuleNotFoundError: # pragma: no cover - runtime dependency
|
|
119
134
|
|
120
135
|
STATUS_COLORS = {
|
121
136
|
"idle": "#9ca3af",
|
137
|
+
"waiting": "#9ca3af",
|
122
138
|
"processing": "#af8e0e",
|
123
139
|
"success": "#178941",
|
124
140
|
"error": "#ad4f4f",
|
@@ -154,6 +170,25 @@ _TRAY_LOCK = threading.Lock()
|
|
154
170
|
_TRAY_PROCESS: Optional[subprocess.Popen[Any]] = None
|
155
171
|
|
156
172
|
|
173
|
+
def _default_remote_destination(input_file: Path, *, small: bool) -> Path:
|
174
|
+
"""Return the default remote output path for *input_file*.
|
175
|
+
|
176
|
+
Mirrors the naming scheme from the local pipeline so that remote jobs save
|
177
|
+
next to the source media with the expected suffix.
|
178
|
+
"""
|
179
|
+
|
180
|
+
name = input_file.name
|
181
|
+
dot_index = name.rfind(".")
|
182
|
+
suffix = "_speedup_small" if small else "_speedup"
|
183
|
+
|
184
|
+
if dot_index != -1:
|
185
|
+
new_name = name[:dot_index] + suffix + name[dot_index:]
|
186
|
+
else:
|
187
|
+
new_name = name + suffix
|
188
|
+
|
189
|
+
return input_file.with_name(new_name)
|
190
|
+
|
191
|
+
|
157
192
|
def _ensure_server_tray_running(extra_args: Optional[Sequence[str]] = None) -> None:
|
158
193
|
"""Start the server tray in a background process if one is not active."""
|
159
194
|
|
@@ -176,6 +211,31 @@ def _ensure_server_tray_running(extra_args: Optional[Sequence[str]] = None) -> N
|
|
176
211
|
)
|
177
212
|
|
178
213
|
|
214
|
+
def _parse_ratios_from_summary(summary: str) -> Tuple[Optional[float], Optional[float]]:
|
215
|
+
"""Extract time and size ratios from a Markdown *summary* string."""
|
216
|
+
|
217
|
+
time_ratio: Optional[float] = None
|
218
|
+
size_ratio: Optional[float] = None
|
219
|
+
|
220
|
+
for line in summary.splitlines():
|
221
|
+
if "**Duration:**" in line:
|
222
|
+
match = re.search(r"—\s*([0-9]+(?:\.[0-9]+)?)% of the original", line)
|
223
|
+
if match:
|
224
|
+
try:
|
225
|
+
time_ratio = float(match.group(1)) / 100
|
226
|
+
except ValueError:
|
227
|
+
time_ratio = None
|
228
|
+
elif "**Size:**" in line:
|
229
|
+
match = re.search(r"\*\*Size:\*\*\s*([0-9]+(?:\.[0-9]+)?)%", line)
|
230
|
+
if match:
|
231
|
+
try:
|
232
|
+
size_ratio = float(match.group(1)) / 100
|
233
|
+
except ValueError:
|
234
|
+
size_ratio = None
|
235
|
+
|
236
|
+
return time_ratio, size_ratio
|
237
|
+
|
238
|
+
|
179
239
|
class _GuiProgressHandle(ProgressHandle):
|
180
240
|
"""Simple progress handle that records totals but only logs milestones."""
|
181
241
|
|
@@ -240,6 +300,10 @@ class TalksReducerGUI:
|
|
240
300
|
"""Tkinter application mirroring the CLI options with form controls."""
|
241
301
|
|
242
302
|
PADDING = 10
|
303
|
+
AUDIO_PROCESSING_RATIO = 0.02
|
304
|
+
AUDIO_PROGRESS_STEPS = 20
|
305
|
+
MIN_AUDIO_INTERVAL_MS = 10
|
306
|
+
DEFAULT_AUDIO_INTERVAL_MS = 200
|
243
307
|
|
244
308
|
def _determine_config_path(self) -> Path:
|
245
309
|
if sys.platform == "win32":
|
@@ -278,6 +342,21 @@ class TalksReducerGUI:
|
|
278
342
|
self._settings[key] = value
|
279
343
|
return value
|
280
344
|
|
345
|
+
def _get_float_setting(self, key: str, default: float) -> float:
|
346
|
+
"""Return *key* as a float, coercing stored strings when necessary."""
|
347
|
+
|
348
|
+
raw_value = self._get_setting(key, default)
|
349
|
+
try:
|
350
|
+
number = float(raw_value)
|
351
|
+
except (TypeError, ValueError):
|
352
|
+
number = float(default)
|
353
|
+
|
354
|
+
if self._settings.get(key) != number:
|
355
|
+
self._settings[key] = number
|
356
|
+
self._save_settings()
|
357
|
+
|
358
|
+
return number
|
359
|
+
|
281
360
|
def _update_setting(self, key: str, value: object) -> None:
|
282
361
|
if self._settings.get(key) == value:
|
283
362
|
return
|
@@ -334,6 +413,10 @@ class TalksReducerGUI:
|
|
334
413
|
self._encode_target_duration_seconds: Optional[float] = None
|
335
414
|
self._encode_total_frames: Optional[int] = None
|
336
415
|
self._encode_current_frame: Optional[int] = None
|
416
|
+
self._source_duration_seconds: Optional[float] = None
|
417
|
+
self._audio_progress_job: Optional[str] = None
|
418
|
+
self._audio_progress_interval_ms: Optional[int] = None
|
419
|
+
self._audio_progress_steps_completed = 0
|
337
420
|
self.progress_var = tk.IntVar(value=0)
|
338
421
|
self._ffmpeg_process: Optional[subprocess.Popen] = None
|
339
422
|
self._stop_requested = False
|
@@ -350,12 +433,26 @@ class TalksReducerGUI:
|
|
350
433
|
self.open_after_convert_var = tk.BooleanVar(
|
351
434
|
value=self._get_setting("open_after_convert", True)
|
352
435
|
)
|
436
|
+
stored_mode = str(self._get_setting("processing_mode", "local"))
|
437
|
+
if stored_mode not in {"local", "remote"}:
|
438
|
+
stored_mode = "local"
|
439
|
+
self.processing_mode_var = tk.StringVar(value=stored_mode)
|
440
|
+
self.processing_mode_var.trace_add("write", self._on_processing_mode_change)
|
353
441
|
self.theme_var = tk.StringVar(value=self._get_setting("theme", "os"))
|
354
442
|
self.theme_var.trace_add("write", self._on_theme_change)
|
355
443
|
self.small_var.trace_add("write", self._on_small_video_change)
|
356
444
|
self.open_after_convert_var.trace_add(
|
357
445
|
"write", self._on_open_after_convert_change
|
358
446
|
)
|
447
|
+
self.server_url_var = tk.StringVar(
|
448
|
+
value=str(self._get_setting("server_url", ""))
|
449
|
+
)
|
450
|
+
self.server_url_var.trace_add("write", self._on_server_url_change)
|
451
|
+
self._discovery_thread: Optional[threading.Thread] = None
|
452
|
+
|
453
|
+
self._basic_defaults: dict[str, float] = {}
|
454
|
+
self._basic_variables: dict[str, tk.DoubleVar] = {}
|
455
|
+
self._slider_updaters: dict[str, Callable[[str], None]] = {}
|
359
456
|
|
360
457
|
self._build_layout()
|
361
458
|
self._apply_simple_mode(initial=True)
|
@@ -364,6 +461,47 @@ class TalksReducerGUI:
|
|
364
461
|
self._save_settings()
|
365
462
|
self._hide_stop_button()
|
366
463
|
|
464
|
+
# Ping server on startup if in remote mode
|
465
|
+
if (
|
466
|
+
self.processing_mode_var.get() == "remote"
|
467
|
+
and self.server_url_var.get().strip()
|
468
|
+
and hasattr(self, "_ping_server")
|
469
|
+
):
|
470
|
+
server_url = self.server_url_var.get().strip()
|
471
|
+
host_label = self._format_server_host(server_url)
|
472
|
+
|
473
|
+
def ping_worker() -> None:
|
474
|
+
try:
|
475
|
+
if self._ping_server(server_url):
|
476
|
+
self._set_status("Idle", f"Server {host_label} is reachable")
|
477
|
+
self._notify(
|
478
|
+
lambda: self._append_log(f"Server {host_label} ready")
|
479
|
+
)
|
480
|
+
else:
|
481
|
+
self._set_status(
|
482
|
+
"Error", f"Server {host_label} is not reachable"
|
483
|
+
)
|
484
|
+
self._notify(
|
485
|
+
lambda: self._append_log(
|
486
|
+
f"Server {host_label} is not reachable"
|
487
|
+
)
|
488
|
+
)
|
489
|
+
ping_worker()
|
490
|
+
except Exception as exc:
|
491
|
+
self._set_status(
|
492
|
+
"Idle", f"Error pinging server {host_label}: {exc}"
|
493
|
+
)
|
494
|
+
self._notify(
|
495
|
+
lambda: self._append_log(
|
496
|
+
f"Error pinging server {host_label}: {exc}"
|
497
|
+
)
|
498
|
+
)
|
499
|
+
|
500
|
+
import threading
|
501
|
+
|
502
|
+
ping_thread = threading.Thread(target=ping_worker, daemon=True)
|
503
|
+
ping_thread.start()
|
504
|
+
|
367
505
|
if not self._dnd_available:
|
368
506
|
self._append_log(
|
369
507
|
"Drag and drop requires the tkinterdnd2 package. Install it to enable the drop zone."
|
@@ -412,16 +550,8 @@ class TalksReducerGUI:
|
|
412
550
|
input_frame.grid(row=0, column=0, sticky="nsew")
|
413
551
|
main.rowconfigure(0, weight=1)
|
414
552
|
main.columnconfigure(0, weight=1)
|
415
|
-
|
416
|
-
|
417
|
-
|
418
|
-
self.input_list = self.tk.Listbox(input_frame, height=5)
|
419
|
-
self.input_list.grid(row=0, column=0, columnspan=4, sticky="nsew", pady=(0, 12))
|
420
|
-
self.input_scrollbar = self.ttk.Scrollbar(
|
421
|
-
input_frame, orient=self.tk.VERTICAL, command=self.input_list.yview
|
422
|
-
)
|
423
|
-
self.input_scrollbar.grid(row=0, column=4, sticky="ns", pady=(0, 12))
|
424
|
-
self.input_list.configure(yscrollcommand=self.input_scrollbar.set)
|
553
|
+
input_frame.columnconfigure(0, weight=1)
|
554
|
+
input_frame.rowconfigure(0, weight=1)
|
425
555
|
|
426
556
|
self.drop_zone = self.tk.Label(
|
427
557
|
input_frame,
|
@@ -432,40 +562,19 @@ class TalksReducerGUI:
|
|
432
562
|
pady=self.PADDING,
|
433
563
|
highlightthickness=0,
|
434
564
|
)
|
435
|
-
self.drop_zone.grid(row=
|
436
|
-
input_frame.rowconfigure(1, weight=1)
|
565
|
+
self.drop_zone.grid(row=0, column=0, sticky="nsew")
|
437
566
|
self._configure_drop_targets(self.drop_zone)
|
438
|
-
self._configure_drop_targets(self.input_list)
|
439
567
|
self.drop_zone.configure(cursor="hand2", takefocus=1)
|
440
568
|
self.drop_zone.bind("<Button-1>", self._on_drop_zone_click)
|
441
569
|
self.drop_zone.bind("<Return>", self._on_drop_zone_click)
|
442
570
|
self.drop_zone.bind("<space>", self._on_drop_zone_click)
|
443
571
|
|
444
|
-
self.add_files_button = self.ttk.Button(
|
445
|
-
input_frame, text="Add files", command=self._add_files
|
446
|
-
)
|
447
|
-
self.add_files_button.grid(row=2, column=0, pady=8, sticky="w")
|
448
|
-
self.add_folder_button = self.ttk.Button(
|
449
|
-
input_frame, text="Add folder", command=self._add_directory
|
450
|
-
)
|
451
|
-
self.add_folder_button.grid(row=2, column=1, pady=8)
|
452
|
-
self.remove_selected_button = self.ttk.Button(
|
453
|
-
input_frame, text="Remove selected", command=self._remove_selected
|
454
|
-
)
|
455
|
-
self.remove_selected_button.grid(row=2, column=2, pady=8, sticky="w")
|
456
|
-
self.run_after_drop_check = self.ttk.Checkbutton(
|
457
|
-
input_frame,
|
458
|
-
text="Run after drop",
|
459
|
-
variable=self.run_after_drop_var,
|
460
|
-
)
|
461
|
-
self.run_after_drop_check.grid(row=2, column=3, pady=8, sticky="e")
|
462
|
-
|
463
572
|
# Options frame
|
464
|
-
|
465
|
-
|
466
|
-
|
573
|
+
self.options_frame = self.ttk.Frame(main, padding=self.PADDING)
|
574
|
+
self.options_frame.grid(row=2, column=0, pady=(0, 0), sticky="ew")
|
575
|
+
self.options_frame.columnconfigure(0, weight=1)
|
467
576
|
|
468
|
-
checkbox_frame = self.ttk.Frame(
|
577
|
+
checkbox_frame = self.ttk.Frame(self.options_frame)
|
469
578
|
checkbox_frame.grid(row=0, column=0, columnspan=2, sticky="w")
|
470
579
|
|
471
580
|
self.ttk.Checkbutton(
|
@@ -491,58 +600,119 @@ class TalksReducerGUI:
|
|
491
600
|
)
|
492
601
|
|
493
602
|
self.advanced_visible = self.tk.BooleanVar(value=False)
|
494
|
-
|
495
|
-
|
496
|
-
|
497
|
-
|
603
|
+
|
604
|
+
basic_label_container = self.ttk.Frame(self.options_frame)
|
605
|
+
basic_label = self.ttk.Label(basic_label_container, text="Basic options")
|
606
|
+
basic_label.pack(side=self.tk.LEFT)
|
607
|
+
|
608
|
+
self.reset_basic_button = self.ttk.Button(
|
609
|
+
basic_label_container,
|
610
|
+
text="Reset to defaults",
|
611
|
+
command=self._reset_basic_defaults,
|
612
|
+
state=self.tk.DISABLED,
|
498
613
|
)
|
499
|
-
self.advanced_button.grid(row=1, column=1, sticky="e")
|
500
614
|
|
501
|
-
self.
|
502
|
-
|
503
|
-
|
615
|
+
self.basic_options_frame = self.ttk.Labelframe(
|
616
|
+
self.options_frame, padding=0, labelwidget=basic_label_container
|
617
|
+
)
|
618
|
+
self.basic_options_frame.grid(
|
619
|
+
row=2, column=0, columnspan=2, sticky="ew", pady=(12, 0)
|
620
|
+
)
|
621
|
+
self.basic_options_frame.columnconfigure(1, weight=1)
|
504
622
|
|
505
|
-
self.
|
506
|
-
|
507
|
-
|
623
|
+
self._reset_button_visible = False
|
624
|
+
|
625
|
+
self.silent_speed_var = self.tk.DoubleVar(
|
626
|
+
value=min(max(self._get_float_setting("silent_speed", 4.0), 1.0), 10.0)
|
627
|
+
)
|
628
|
+
self._add_slider(
|
629
|
+
self.basic_options_frame,
|
630
|
+
"Silent speed",
|
631
|
+
self.silent_speed_var,
|
632
|
+
row=0,
|
633
|
+
setting_key="silent_speed",
|
634
|
+
minimum=1.0,
|
635
|
+
maximum=10.0,
|
636
|
+
resolution=0.5,
|
637
|
+
display_format="{:.1f}×",
|
638
|
+
default_value=4.0,
|
508
639
|
)
|
509
640
|
|
510
|
-
self.
|
511
|
-
|
512
|
-
|
641
|
+
self.sounded_speed_var = self.tk.DoubleVar(
|
642
|
+
value=min(max(self._get_float_setting("sounded_speed", 1.0), 0.75), 2.0)
|
643
|
+
)
|
644
|
+
self._add_slider(
|
645
|
+
self.basic_options_frame,
|
646
|
+
"Sounded speed",
|
647
|
+
self.sounded_speed_var,
|
648
|
+
row=1,
|
649
|
+
setting_key="sounded_speed",
|
650
|
+
minimum=0.75,
|
651
|
+
maximum=2.0,
|
652
|
+
resolution=0.25,
|
653
|
+
display_format="{:.2f}×",
|
654
|
+
default_value=1.0,
|
513
655
|
)
|
514
656
|
|
515
|
-
self.silent_threshold_var = self.tk.
|
516
|
-
|
517
|
-
|
657
|
+
self.silent_threshold_var = self.tk.DoubleVar(
|
658
|
+
value=min(max(self._get_float_setting("silent_threshold", 0.05), 0.0), 1.0)
|
659
|
+
)
|
660
|
+
self._add_slider(
|
661
|
+
self.basic_options_frame,
|
518
662
|
"Silent threshold",
|
519
663
|
self.silent_threshold_var,
|
520
664
|
row=2,
|
665
|
+
setting_key="silent_threshold",
|
666
|
+
minimum=0.0,
|
667
|
+
maximum=1.0,
|
668
|
+
resolution=0.01,
|
669
|
+
display_format="{:.2f}",
|
670
|
+
default_value=0.05,
|
521
671
|
)
|
522
672
|
|
523
|
-
self.
|
524
|
-
|
525
|
-
self.advanced_frame, "Sounded speed", self.sounded_speed_var, row=3
|
673
|
+
self.ttk.Label(self.basic_options_frame, text="Server URL").grid(
|
674
|
+
row=3, column=0, sticky="w", pady=4
|
526
675
|
)
|
527
|
-
|
528
|
-
|
529
|
-
self._add_entry(
|
530
|
-
self.advanced_frame, "Silent speed", self.silent_speed_var, row=4
|
676
|
+
stored_server_url = str(
|
677
|
+
self._get_setting("server_url", "http://localhost:9005")
|
531
678
|
)
|
532
|
-
|
533
|
-
|
534
|
-
|
535
|
-
|
679
|
+
if not stored_server_url:
|
680
|
+
stored_server_url = "http://localhost:9005"
|
681
|
+
self._update_setting("server_url", stored_server_url)
|
682
|
+
self.server_url_var.set(stored_server_url)
|
683
|
+
self.server_url_entry = self.ttk.Entry(
|
684
|
+
self.basic_options_frame, textvariable=self.server_url_var, width=30
|
685
|
+
)
|
686
|
+
self.server_url_entry.grid(row=3, column=1, sticky="w", pady=4)
|
687
|
+
self.server_discover_button = self.ttk.Button(
|
688
|
+
self.basic_options_frame, text="Discover", command=self._start_discovery
|
536
689
|
)
|
690
|
+
self.server_discover_button.grid(row=3, column=2, padx=(8, 0))
|
537
691
|
|
538
|
-
self.
|
539
|
-
|
692
|
+
self.ttk.Label(self.basic_options_frame, text="Processing mode").grid(
|
693
|
+
row=4, column=0, sticky="w", pady=4
|
694
|
+
)
|
695
|
+
mode_choice = self.ttk.Frame(self.basic_options_frame)
|
696
|
+
mode_choice.grid(row=4, column=1, columnspan=2, sticky="w", pady=4)
|
697
|
+
self.ttk.Radiobutton(
|
698
|
+
mode_choice,
|
699
|
+
text="Local",
|
700
|
+
value="local",
|
701
|
+
variable=self.processing_mode_var,
|
702
|
+
).pack(side=self.tk.LEFT, padx=(0, 8))
|
703
|
+
self.remote_mode_button = self.ttk.Radiobutton(
|
704
|
+
mode_choice,
|
705
|
+
text="Remote",
|
706
|
+
value="remote",
|
707
|
+
variable=self.processing_mode_var,
|
708
|
+
)
|
709
|
+
self.remote_mode_button.pack(side=self.tk.LEFT, padx=(0, 8))
|
540
710
|
|
541
|
-
self.ttk.Label(self.
|
542
|
-
row=
|
711
|
+
self.ttk.Label(self.basic_options_frame, text="Theme").grid(
|
712
|
+
row=5, column=0, sticky="w", pady=(8, 0)
|
543
713
|
)
|
544
|
-
theme_choice = self.ttk.Frame(self.
|
545
|
-
theme_choice.grid(row=
|
714
|
+
theme_choice = self.ttk.Frame(self.basic_options_frame)
|
715
|
+
theme_choice.grid(row=5, column=1, columnspan=2, sticky="w", pady=(8, 0))
|
546
716
|
for value, label in ("os", "OS"), ("light", "Light"), ("dark", "Dark"):
|
547
717
|
self.ttk.Radiobutton(
|
548
718
|
theme_choice,
|
@@ -552,7 +722,47 @@ class TalksReducerGUI:
|
|
552
722
|
command=self._apply_theme,
|
553
723
|
).pack(side=self.tk.LEFT, padx=(0, 8))
|
554
724
|
|
725
|
+
self.advanced_button = self.ttk.Button(
|
726
|
+
self.options_frame,
|
727
|
+
text="Advanced",
|
728
|
+
command=self._toggle_advanced,
|
729
|
+
)
|
730
|
+
self.advanced_button.grid(
|
731
|
+
row=3, column=0, columnspan=2, sticky="w", pady=(12, 0)
|
732
|
+
)
|
733
|
+
|
734
|
+
self.advanced_frame = self.ttk.Frame(self.options_frame, padding=0)
|
735
|
+
self.advanced_frame.grid(row=4, column=0, columnspan=2, sticky="nsew")
|
736
|
+
self.advanced_frame.columnconfigure(1, weight=1)
|
737
|
+
|
738
|
+
self.output_var = self.tk.StringVar()
|
739
|
+
self._add_entry(
|
740
|
+
self.advanced_frame, "Output file", self.output_var, row=0, browse=True
|
741
|
+
)
|
742
|
+
|
743
|
+
self.temp_var = self.tk.StringVar(value=str(default_temp_folder()))
|
744
|
+
self._add_entry(
|
745
|
+
self.advanced_frame, "Temp folder", self.temp_var, row=1, browse=True
|
746
|
+
)
|
747
|
+
|
748
|
+
self.sample_rate_var = self.tk.StringVar(value="48000")
|
749
|
+
self._add_entry(self.advanced_frame, "Sample rate", self.sample_rate_var, row=2)
|
750
|
+
|
751
|
+
frame_margin_setting = self._get_setting("frame_margin", 2)
|
752
|
+
try:
|
753
|
+
frame_margin_default = int(frame_margin_setting)
|
754
|
+
except (TypeError, ValueError):
|
755
|
+
frame_margin_default = 2
|
756
|
+
self._update_setting("frame_margin", frame_margin_default)
|
757
|
+
|
758
|
+
self.frame_margin_var = self.tk.StringVar(value=str(frame_margin_default))
|
759
|
+
self._add_entry(
|
760
|
+
self.advanced_frame, "Frame margin", self.frame_margin_var, row=3
|
761
|
+
)
|
762
|
+
|
555
763
|
self._toggle_advanced(initial=True)
|
764
|
+
self._update_processing_mode_state()
|
765
|
+
self._update_basic_reset_state()
|
556
766
|
|
557
767
|
# Action buttons and log output
|
558
768
|
status_frame = self.ttk.Frame(main, padding=self.PADDING)
|
@@ -560,6 +770,7 @@ class TalksReducerGUI:
|
|
560
770
|
status_frame.columnconfigure(0, weight=0)
|
561
771
|
status_frame.columnconfigure(1, weight=1)
|
562
772
|
status_frame.columnconfigure(2, weight=0)
|
773
|
+
self.status_frame = status_frame
|
563
774
|
|
564
775
|
self.ttk.Label(status_frame, text="Status:").grid(row=0, column=0, sticky="w")
|
565
776
|
self.status_label = self.tk.Label(
|
@@ -644,44 +855,299 @@ class TalksReducerGUI:
|
|
644
855
|
)
|
645
856
|
button.grid(row=row, column=2, padx=(8, 0))
|
646
857
|
|
858
|
+
def _add_slider(
|
859
|
+
self,
|
860
|
+
parent, # type: tk.Misc
|
861
|
+
label: str,
|
862
|
+
variable, # type: tk.DoubleVar
|
863
|
+
*,
|
864
|
+
row: int,
|
865
|
+
setting_key: str,
|
866
|
+
minimum: float,
|
867
|
+
maximum: float,
|
868
|
+
resolution: float,
|
869
|
+
display_format: str,
|
870
|
+
default_value: float,
|
871
|
+
) -> None:
|
872
|
+
self.ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w", pady=4)
|
873
|
+
|
874
|
+
value_label = self.ttk.Label(parent)
|
875
|
+
value_label.grid(row=row, column=2, sticky="e", pady=4)
|
876
|
+
|
877
|
+
def update(value: str) -> None:
|
878
|
+
numeric = float(value)
|
879
|
+
clamped = max(minimum, min(maximum, numeric))
|
880
|
+
steps = round((clamped - minimum) / resolution)
|
881
|
+
quantized = minimum + steps * resolution
|
882
|
+
if abs(variable.get() - quantized) > 1e-9:
|
883
|
+
variable.set(quantized)
|
884
|
+
value_label.configure(text=display_format.format(quantized))
|
885
|
+
self._update_setting(setting_key, float(f"{quantized:.6f}"))
|
886
|
+
self._update_basic_reset_state()
|
887
|
+
|
888
|
+
slider = self.tk.Scale(
|
889
|
+
parent,
|
890
|
+
variable=variable,
|
891
|
+
from_=minimum,
|
892
|
+
to=maximum,
|
893
|
+
orient=self.tk.HORIZONTAL,
|
894
|
+
resolution=resolution,
|
895
|
+
showvalue=False,
|
896
|
+
command=update,
|
897
|
+
length=240,
|
898
|
+
)
|
899
|
+
slider.grid(row=row, column=1, sticky="ew", pady=4, padx=(0, 8))
|
900
|
+
|
901
|
+
update(str(variable.get()))
|
902
|
+
|
903
|
+
self._slider_updaters[setting_key] = update
|
904
|
+
self._basic_defaults[setting_key] = default_value
|
905
|
+
self._basic_variables[setting_key] = variable
|
906
|
+
variable.trace_add("write", lambda *_: self._update_basic_reset_state())
|
907
|
+
|
908
|
+
def _update_basic_reset_state(self) -> None:
|
909
|
+
"""Enable or disable the reset control based on slider values."""
|
910
|
+
|
911
|
+
if not hasattr(self, "reset_basic_button"):
|
912
|
+
return
|
913
|
+
|
914
|
+
should_enable = False
|
915
|
+
for key, default_value in self._basic_defaults.items():
|
916
|
+
variable = self._basic_variables.get(key)
|
917
|
+
if variable is None:
|
918
|
+
continue
|
919
|
+
try:
|
920
|
+
current_value = float(variable.get())
|
921
|
+
except (TypeError, ValueError):
|
922
|
+
should_enable = True
|
923
|
+
break
|
924
|
+
if abs(current_value - default_value) > 1e-9:
|
925
|
+
should_enable = True
|
926
|
+
break
|
927
|
+
|
928
|
+
if should_enable:
|
929
|
+
if not getattr(self, "_reset_button_visible", False):
|
930
|
+
self.reset_basic_button.pack(side=self.tk.LEFT, padx=(8, 0))
|
931
|
+
self._reset_button_visible = True
|
932
|
+
self.reset_basic_button.configure(state=self.tk.NORMAL)
|
933
|
+
else:
|
934
|
+
if getattr(self, "_reset_button_visible", False):
|
935
|
+
self.reset_basic_button.pack_forget()
|
936
|
+
self._reset_button_visible = False
|
937
|
+
self.reset_basic_button.configure(state=self.tk.DISABLED)
|
938
|
+
|
939
|
+
def _reset_basic_defaults(self) -> None:
|
940
|
+
"""Restore the basic numeric controls to their default values."""
|
941
|
+
|
942
|
+
for key, default_value in self._basic_defaults.items():
|
943
|
+
variable = self._basic_variables.get(key)
|
944
|
+
if variable is None:
|
945
|
+
continue
|
946
|
+
|
947
|
+
try:
|
948
|
+
current_value = float(variable.get())
|
949
|
+
except (TypeError, ValueError):
|
950
|
+
current_value = default_value
|
951
|
+
|
952
|
+
if abs(current_value - default_value) <= 1e-9:
|
953
|
+
continue
|
954
|
+
|
955
|
+
variable.set(default_value)
|
956
|
+
updater = self._slider_updaters.get(key)
|
957
|
+
if updater is not None:
|
958
|
+
updater(str(default_value))
|
959
|
+
else:
|
960
|
+
self._update_setting(key, float(f"{default_value:.6f}"))
|
961
|
+
|
962
|
+
self._update_basic_reset_state()
|
963
|
+
|
964
|
+
def _update_processing_mode_state(self) -> None:
|
965
|
+
has_url = bool(self.server_url_var.get().strip())
|
966
|
+
if not has_url and self.processing_mode_var.get() == "remote":
|
967
|
+
self.processing_mode_var.set("local")
|
968
|
+
return
|
969
|
+
|
970
|
+
if hasattr(self, "remote_mode_button"):
|
971
|
+
state = self.tk.NORMAL if has_url else self.tk.DISABLED
|
972
|
+
self.remote_mode_button.configure(state=state)
|
973
|
+
|
974
|
+
def _normalize_server_url(self, server_url: str) -> str:
|
975
|
+
parsed = urllib.parse.urlsplit(server_url)
|
976
|
+
if not parsed.scheme:
|
977
|
+
parsed = urllib.parse.urlsplit(f"http://{server_url}")
|
978
|
+
|
979
|
+
netloc = parsed.netloc or parsed.path
|
980
|
+
if not netloc:
|
981
|
+
return server_url
|
982
|
+
|
983
|
+
path = parsed.path if parsed.netloc else ""
|
984
|
+
normalized_path = path or "/"
|
985
|
+
return urllib.parse.urlunsplit((parsed.scheme, netloc, normalized_path, "", ""))
|
986
|
+
|
987
|
+
def _format_server_host(self, server_url: str) -> str:
|
988
|
+
parsed = urllib.parse.urlsplit(server_url)
|
989
|
+
if not parsed.scheme:
|
990
|
+
parsed = urllib.parse.urlsplit(f"http://{server_url}")
|
991
|
+
|
992
|
+
host = parsed.netloc or parsed.path or server_url
|
993
|
+
if parsed.netloc and parsed.path and parsed.path not in {"", "/"}:
|
994
|
+
host = f"{parsed.netloc}{parsed.path}"
|
995
|
+
|
996
|
+
host = host.rstrip("/").split(":")[0]
|
997
|
+
return host or server_url
|
998
|
+
|
999
|
+
def _ping_server(self, server_url: str, *, timeout: float = 5.0) -> bool:
|
1000
|
+
normalized = self._normalize_server_url(server_url)
|
1001
|
+
request = urllib.request.Request(
|
1002
|
+
normalized,
|
1003
|
+
headers={"User-Agent": "talks-reducer-gui"},
|
1004
|
+
method="GET",
|
1005
|
+
)
|
1006
|
+
|
1007
|
+
try:
|
1008
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
1009
|
+
status = getattr(response, "status", None)
|
1010
|
+
if status is None:
|
1011
|
+
status = response.getcode()
|
1012
|
+
if status is None:
|
1013
|
+
return False
|
1014
|
+
return 200 <= int(status) < 500
|
1015
|
+
except (urllib.error.URLError, ValueError):
|
1016
|
+
return False
|
1017
|
+
|
1018
|
+
def _start_discovery(self) -> None:
|
1019
|
+
"""Search the local network for running Talks Reducer servers."""
|
1020
|
+
|
1021
|
+
if self._discovery_thread and self._discovery_thread.is_alive():
|
1022
|
+
return
|
1023
|
+
|
1024
|
+
self.server_discover_button.configure(
|
1025
|
+
state=self.tk.DISABLED, text="Discovering…"
|
1026
|
+
)
|
1027
|
+
self._append_log("Discovering Talks Reducer servers on port 9005…")
|
1028
|
+
|
1029
|
+
def worker() -> None:
|
1030
|
+
try:
|
1031
|
+
urls = discover_servers(
|
1032
|
+
progress_callback=lambda current, total: self._notify(
|
1033
|
+
lambda c=current, t=total: self._on_discovery_progress(c, t)
|
1034
|
+
)
|
1035
|
+
)
|
1036
|
+
except Exception as exc: # pragma: no cover - network failure safeguard
|
1037
|
+
self._notify(lambda: self._on_discovery_failed(exc))
|
1038
|
+
return
|
1039
|
+
self._notify(lambda: self._on_discovery_complete(urls))
|
1040
|
+
|
1041
|
+
self._discovery_thread = threading.Thread(target=worker, daemon=True)
|
1042
|
+
self._discovery_thread.start()
|
1043
|
+
|
1044
|
+
def _on_discovery_failed(self, exc: Exception) -> None:
|
1045
|
+
self.server_discover_button.configure(state=self.tk.NORMAL, text="Discover")
|
1046
|
+
message = f"Discovery failed: {exc}"
|
1047
|
+
self._append_log(message)
|
1048
|
+
self.messagebox.showerror("Discovery failed", message)
|
1049
|
+
|
1050
|
+
def _on_discovery_progress(self, current: int, total: int) -> None:
|
1051
|
+
if total > 0:
|
1052
|
+
bounded = max(0, min(current, total))
|
1053
|
+
label = f"{bounded} / {total}"
|
1054
|
+
else:
|
1055
|
+
label = "Discovering…"
|
1056
|
+
self.server_discover_button.configure(text=label)
|
1057
|
+
|
1058
|
+
def _on_discovery_complete(self, urls: List[str]) -> None:
|
1059
|
+
self.server_discover_button.configure(state=self.tk.NORMAL, text="Discover")
|
1060
|
+
if not urls:
|
1061
|
+
self._append_log("No Talks Reducer servers were found.")
|
1062
|
+
self.messagebox.showinfo(
|
1063
|
+
"No servers found",
|
1064
|
+
"No Talks Reducer servers responded on port 9005.",
|
1065
|
+
)
|
1066
|
+
return
|
1067
|
+
|
1068
|
+
self._append_log(
|
1069
|
+
f"Discovered {len(urls)} server{'s' if len(urls) != 1 else ''}."
|
1070
|
+
)
|
1071
|
+
|
1072
|
+
if len(urls) == 1:
|
1073
|
+
self.server_url_var.set(urls[0])
|
1074
|
+
return
|
1075
|
+
|
1076
|
+
self._show_discovery_results(urls)
|
1077
|
+
|
1078
|
+
def _show_discovery_results(self, urls: List[str]) -> None:
|
1079
|
+
dialog = self.tk.Toplevel(self.root)
|
1080
|
+
dialog.title("Select server")
|
1081
|
+
dialog.transient(self.root)
|
1082
|
+
dialog.grab_set()
|
1083
|
+
|
1084
|
+
self.ttk.Label(dialog, text="Select a Talks Reducer server:").grid(
|
1085
|
+
row=0, column=0, columnspan=2, sticky="w", padx=self.PADDING, pady=(12, 4)
|
1086
|
+
)
|
1087
|
+
|
1088
|
+
listbox = self.tk.Listbox(
|
1089
|
+
dialog,
|
1090
|
+
height=min(10, len(urls)),
|
1091
|
+
selectmode=self.tk.SINGLE,
|
1092
|
+
)
|
1093
|
+
listbox.grid(
|
1094
|
+
row=1,
|
1095
|
+
column=0,
|
1096
|
+
columnspan=2,
|
1097
|
+
padx=self.PADDING,
|
1098
|
+
sticky="nsew",
|
1099
|
+
)
|
1100
|
+
dialog.columnconfigure(0, weight=1)
|
1101
|
+
dialog.columnconfigure(1, weight=1)
|
1102
|
+
dialog.rowconfigure(1, weight=1)
|
1103
|
+
|
1104
|
+
for url in urls:
|
1105
|
+
listbox.insert(self.tk.END, url)
|
1106
|
+
listbox.select_set(0)
|
1107
|
+
|
1108
|
+
def choose(_: object | None = None) -> None:
|
1109
|
+
selection = listbox.curselection()
|
1110
|
+
if not selection:
|
1111
|
+
return
|
1112
|
+
index = selection[0]
|
1113
|
+
self.server_url_var.set(urls[index])
|
1114
|
+
dialog.grab_release()
|
1115
|
+
dialog.destroy()
|
1116
|
+
|
1117
|
+
def cancel() -> None:
|
1118
|
+
dialog.grab_release()
|
1119
|
+
dialog.destroy()
|
1120
|
+
|
1121
|
+
listbox.bind("<Double-Button-1>", choose)
|
1122
|
+
listbox.bind("<Return>", choose)
|
1123
|
+
|
1124
|
+
button_frame = self.ttk.Frame(dialog)
|
1125
|
+
button_frame.grid(row=2, column=0, columnspan=2, pady=(8, 12))
|
1126
|
+
self.ttk.Button(button_frame, text="Use server", command=choose).pack(
|
1127
|
+
side=self.tk.LEFT, padx=(0, 8)
|
1128
|
+
)
|
1129
|
+
self.ttk.Button(button_frame, text="Cancel", command=cancel).pack(
|
1130
|
+
side=self.tk.LEFT
|
1131
|
+
)
|
1132
|
+
dialog.protocol("WM_DELETE_WINDOW", cancel)
|
1133
|
+
|
647
1134
|
def _toggle_simple_mode(self) -> None:
|
648
1135
|
self._update_setting("simple_mode", self.simple_mode_var.get())
|
649
1136
|
self._apply_simple_mode()
|
650
1137
|
|
651
1138
|
def _apply_simple_mode(self, *, initial: bool = False) -> None:
|
652
1139
|
simple = self.simple_mode_var.get()
|
653
|
-
widgets = [
|
654
|
-
self.input_list,
|
655
|
-
self.input_scrollbar,
|
656
|
-
self.add_files_button,
|
657
|
-
self.add_folder_button,
|
658
|
-
self.remove_selected_button,
|
659
|
-
self.run_after_drop_check,
|
660
|
-
]
|
661
|
-
|
662
1140
|
if simple:
|
663
|
-
|
664
|
-
widget.grid_remove()
|
1141
|
+
self.basic_options_frame.grid_remove()
|
665
1142
|
self.log_frame.grid_remove()
|
666
1143
|
self.stop_button.grid_remove()
|
667
1144
|
self.advanced_button.grid_remove()
|
668
1145
|
self.advanced_frame.grid_remove()
|
669
|
-
if hasattr(self, "status_frame"):
|
670
|
-
self.status_frame.grid_remove()
|
671
1146
|
self.run_after_drop_var.set(True)
|
672
1147
|
self._apply_window_size(simple=True)
|
673
|
-
if self.status_var.get().lower() == "success" and hasattr(
|
674
|
-
self, "status_frame"
|
675
|
-
):
|
676
|
-
self.status_frame.grid()
|
677
|
-
self.open_button.grid()
|
678
|
-
self.drop_hint_button.grid_remove()
|
679
1148
|
else:
|
680
|
-
|
681
|
-
widget.grid()
|
1149
|
+
self.basic_options_frame.grid()
|
682
1150
|
self.log_frame.grid()
|
683
|
-
if hasattr(self, "status_frame"):
|
684
|
-
self.status_frame.grid()
|
685
1151
|
self.advanced_button.grid()
|
686
1152
|
if self.advanced_visible.get():
|
687
1153
|
self.advanced_frame.grid()
|
@@ -726,6 +1192,19 @@ class TalksReducerGUI:
|
|
726
1192
|
"open_after_convert", bool(self.open_after_convert_var.get())
|
727
1193
|
)
|
728
1194
|
|
1195
|
+
def _on_processing_mode_change(self, *_: object) -> None:
|
1196
|
+
value = self.processing_mode_var.get()
|
1197
|
+
if value not in {"local", "remote"}:
|
1198
|
+
self.processing_mode_var.set("local")
|
1199
|
+
return
|
1200
|
+
self._update_setting("processing_mode", value)
|
1201
|
+
self._update_processing_mode_state()
|
1202
|
+
|
1203
|
+
def _on_server_url_change(self, *_: object) -> None:
|
1204
|
+
value = self.server_url_var.get().strip()
|
1205
|
+
self._update_setting("server_url", value)
|
1206
|
+
self._update_processing_mode_state()
|
1207
|
+
|
729
1208
|
def _apply_theme(self) -> None:
|
730
1209
|
preference = self.theme_var.get().lower()
|
731
1210
|
if preference not in {"light", "dark"}:
|
@@ -844,14 +1323,6 @@ class TalksReducerGUI:
|
|
844
1323
|
fg=palette["foreground"],
|
845
1324
|
highlightthickness=0,
|
846
1325
|
)
|
847
|
-
self.input_list.configure(
|
848
|
-
bg=palette["surface"],
|
849
|
-
fg=palette["foreground"],
|
850
|
-
selectbackground=palette.get("selection_background", palette["accent"]),
|
851
|
-
selectforeground=palette.get("selection_foreground", palette["surface"]),
|
852
|
-
highlightbackground=palette["border"],
|
853
|
-
highlightcolor=palette["border"],
|
854
|
-
)
|
855
1326
|
self.log_text.configure(
|
856
1327
|
bg=palette["surface"],
|
857
1328
|
fg=palette["foreground"],
|
@@ -913,7 +1384,6 @@ class TalksReducerGUI:
|
|
913
1384
|
resolved = os.fspath(Path(path))
|
914
1385
|
if resolved not in self.input_files:
|
915
1386
|
self.input_files.append(resolved)
|
916
|
-
self.input_list.insert(self.tk.END, resolved)
|
917
1387
|
normalized.append(resolved)
|
918
1388
|
|
919
1389
|
if auto_run and normalized:
|
@@ -947,21 +1417,13 @@ class TalksReducerGUI:
|
|
947
1417
|
for path in paths:
|
948
1418
|
if path and path not in self.input_files:
|
949
1419
|
self.input_files.append(path)
|
950
|
-
self.input_list.insert(self.tk.END, path)
|
951
1420
|
added = True
|
952
1421
|
if auto_run and added and self.run_after_drop_var.get():
|
953
1422
|
self._start_run()
|
954
1423
|
|
955
|
-
def _remove_selected(self) -> None:
|
956
|
-
selection = list(self.input_list.curselection())
|
957
|
-
for index in reversed(selection):
|
958
|
-
self.input_list.delete(index)
|
959
|
-
del self.input_files[index]
|
960
|
-
|
961
1424
|
def _clear_input_files(self) -> None:
|
962
|
-
"""Clear all input files
|
1425
|
+
"""Clear all queued input files."""
|
963
1426
|
self.input_files.clear()
|
964
|
-
self.input_list.delete(0, self.tk.END)
|
965
1427
|
|
966
1428
|
def _on_drop(self, event: object) -> None:
|
967
1429
|
data = getattr(event, "data", "")
|
@@ -971,7 +1433,6 @@ class TalksReducerGUI:
|
|
971
1433
|
cleaned = [path.strip("{}") for path in paths]
|
972
1434
|
# Clear existing files before adding dropped files
|
973
1435
|
self.input_files.clear()
|
974
|
-
self.input_list.delete(0, self.tk.END)
|
975
1436
|
self._extend_inputs(cleaned, auto_run=True)
|
976
1437
|
|
977
1438
|
def _on_drop_zone_click(self, event: object) -> str | None:
|
@@ -1017,14 +1478,19 @@ class TalksReducerGUI:
|
|
1017
1478
|
self._append_log("Starting processing…")
|
1018
1479
|
self._stop_requested = False
|
1019
1480
|
open_after_convert = bool(self.open_after_convert_var.get())
|
1481
|
+
server_url = self.server_url_var.get().strip()
|
1482
|
+
remote_mode = self.processing_mode_var.get() == "remote"
|
1483
|
+
if remote_mode and not server_url:
|
1484
|
+
self.messagebox.showerror(
|
1485
|
+
"Missing server URL", "Remote mode requires a server URL."
|
1486
|
+
)
|
1487
|
+
return
|
1488
|
+
remote_mode = remote_mode and bool(server_url)
|
1020
1489
|
|
1021
1490
|
def worker() -> None:
|
1022
1491
|
def set_process(proc: subprocess.Popen) -> None:
|
1023
1492
|
self._ffmpeg_process = proc
|
1024
1493
|
|
1025
|
-
reporter = _TkProgressReporter(
|
1026
|
-
self._append_log, process_callback=set_process
|
1027
|
-
)
|
1028
1494
|
try:
|
1029
1495
|
files = gather_input_files(self.input_files)
|
1030
1496
|
if not files:
|
@@ -1036,6 +1502,20 @@ class TalksReducerGUI:
|
|
1036
1502
|
self._set_status("Idle")
|
1037
1503
|
return
|
1038
1504
|
|
1505
|
+
if remote_mode:
|
1506
|
+
success = self._process_files_via_server(
|
1507
|
+
files,
|
1508
|
+
args,
|
1509
|
+
server_url,
|
1510
|
+
open_after_convert=open_after_convert,
|
1511
|
+
)
|
1512
|
+
if success:
|
1513
|
+
self._notify(self._hide_stop_button)
|
1514
|
+
return
|
1515
|
+
|
1516
|
+
reporter = _TkProgressReporter(
|
1517
|
+
self._append_log, process_callback=set_process
|
1518
|
+
)
|
1039
1519
|
for index, file in enumerate(files, start=1):
|
1040
1520
|
self._append_log(
|
1041
1521
|
f"Processing {index}/{len(files)}: {os.path.basename(file)}"
|
@@ -1085,7 +1565,10 @@ class TalksReducerGUI:
|
|
1085
1565
|
self._processing_thread.start()
|
1086
1566
|
|
1087
1567
|
# Show Stop button when processing starts
|
1088
|
-
|
1568
|
+
if remote_mode:
|
1569
|
+
self.stop_button.grid_remove()
|
1570
|
+
else:
|
1571
|
+
self.stop_button.grid()
|
1089
1572
|
|
1090
1573
|
def _stop_processing(self) -> None:
|
1091
1574
|
"""Stop the currently running processing by terminating FFmpeg."""
|
@@ -1129,18 +1612,14 @@ class TalksReducerGUI:
|
|
1129
1612
|
args["output_file"] = Path(self.output_var.get())
|
1130
1613
|
if self.temp_var.get():
|
1131
1614
|
args["temp_folder"] = Path(self.temp_var.get())
|
1132
|
-
|
1133
|
-
|
1134
|
-
|
1135
|
-
|
1136
|
-
|
1137
|
-
|
1138
|
-
|
1139
|
-
|
1140
|
-
if self.silent_speed_var.get():
|
1141
|
-
args["silent_speed"] = self._parse_float(
|
1142
|
-
self.silent_speed_var.get(), "Silent speed"
|
1143
|
-
)
|
1615
|
+
silent_threshold = float(self.silent_threshold_var.get())
|
1616
|
+
args["silent_threshold"] = round(silent_threshold, 2)
|
1617
|
+
|
1618
|
+
sounded_speed = float(self.sounded_speed_var.get())
|
1619
|
+
args["sounded_speed"] = round(sounded_speed, 2)
|
1620
|
+
|
1621
|
+
silent_speed = float(self.silent_speed_var.get())
|
1622
|
+
args["silent_speed"] = round(silent_speed, 2)
|
1144
1623
|
if self.frame_margin_var.get():
|
1145
1624
|
args["frame_spreadage"] = int(
|
1146
1625
|
round(self._parse_float(self.frame_margin_var.get(), "Frame margin"))
|
@@ -1153,6 +1632,112 @@ class TalksReducerGUI:
|
|
1153
1632
|
args["small"] = True
|
1154
1633
|
return args
|
1155
1634
|
|
1635
|
+
def _process_files_via_server(
|
1636
|
+
self,
|
1637
|
+
files: List[str],
|
1638
|
+
args: dict[str, object],
|
1639
|
+
server_url: str,
|
1640
|
+
*,
|
1641
|
+
open_after_convert: bool,
|
1642
|
+
) -> bool:
|
1643
|
+
"""Send *files* to the configured server for processing."""
|
1644
|
+
|
1645
|
+
try:
|
1646
|
+
service_module = importlib.import_module("talks_reducer.service_client")
|
1647
|
+
except ModuleNotFoundError as exc:
|
1648
|
+
self._append_log(f"Server client unavailable: {exc}")
|
1649
|
+
self._notify(
|
1650
|
+
lambda: self.messagebox.showerror(
|
1651
|
+
"Server unavailable",
|
1652
|
+
"Remote processing requires the gradio_client package.",
|
1653
|
+
)
|
1654
|
+
)
|
1655
|
+
self._notify(lambda: self._set_status("Error"))
|
1656
|
+
return False
|
1657
|
+
|
1658
|
+
host_label = self._format_server_host(server_url)
|
1659
|
+
self._notify(
|
1660
|
+
lambda: self._set_status("waiting", f"Waiting server {host_label}...")
|
1661
|
+
)
|
1662
|
+
if not self._ping_server(server_url):
|
1663
|
+
self._append_log(f"Server unreachable: {server_url}")
|
1664
|
+
self._notify(
|
1665
|
+
lambda: self._set_status("Error", f"Server {host_label} unreachable")
|
1666
|
+
)
|
1667
|
+
return False
|
1668
|
+
|
1669
|
+
self._notify(lambda: self._set_status("waiting", f"Server {host_label} ready"))
|
1670
|
+
|
1671
|
+
output_override = args.get("output_file") if len(files) == 1 else None
|
1672
|
+
ignored = [key for key in args if key not in {"output_file", "small"}]
|
1673
|
+
if ignored:
|
1674
|
+
ignored_options = ", ".join(sorted(ignored))
|
1675
|
+
self._append_log(
|
1676
|
+
f"Server mode ignores the following options: {ignored_options}"
|
1677
|
+
)
|
1678
|
+
|
1679
|
+
small_mode = bool(args.get("small", False))
|
1680
|
+
|
1681
|
+
for index, file in enumerate(files, start=1):
|
1682
|
+
basename = os.path.basename(file)
|
1683
|
+
self._append_log(
|
1684
|
+
f"Uploading {index}/{len(files)}: {basename} to {server_url}"
|
1685
|
+
)
|
1686
|
+
input_path = Path(file)
|
1687
|
+
|
1688
|
+
if output_override is not None:
|
1689
|
+
output_path = Path(output_override)
|
1690
|
+
if output_path.is_dir():
|
1691
|
+
output_path = (
|
1692
|
+
output_path
|
1693
|
+
/ _default_remote_destination(input_path, small=small_mode).name
|
1694
|
+
)
|
1695
|
+
else:
|
1696
|
+
output_path = _default_remote_destination(input_path, small=small_mode)
|
1697
|
+
|
1698
|
+
try:
|
1699
|
+
destination, summary, log_text = service_module.send_video(
|
1700
|
+
input_path=input_path,
|
1701
|
+
output_path=output_path,
|
1702
|
+
server_url=server_url,
|
1703
|
+
small=small_mode,
|
1704
|
+
stream_updates=True,
|
1705
|
+
log_callback=self._append_log,
|
1706
|
+
# progress_callback=self._handle_service_progress,
|
1707
|
+
)
|
1708
|
+
except Exception as exc: # pragma: no cover - network safeguard
|
1709
|
+
error_detail = f"{exc.__class__.__name__}: {exc}"
|
1710
|
+
error_msg = f"Processing failed: {error_detail}"
|
1711
|
+
self._append_log(error_msg)
|
1712
|
+
self._notify(lambda: self._set_status("Error"))
|
1713
|
+
self._notify(
|
1714
|
+
lambda: self.messagebox.showerror(
|
1715
|
+
"Server error",
|
1716
|
+
f"Failed to process {basename}: {error_detail}",
|
1717
|
+
)
|
1718
|
+
)
|
1719
|
+
return False
|
1720
|
+
|
1721
|
+
self._last_output = Path(destination)
|
1722
|
+
time_ratio, size_ratio = _parse_ratios_from_summary(summary)
|
1723
|
+
self._last_time_ratio = time_ratio
|
1724
|
+
self._last_size_ratio = size_ratio
|
1725
|
+
for line in summary.splitlines():
|
1726
|
+
self._append_log(line)
|
1727
|
+
if log_text.strip():
|
1728
|
+
self._append_log("Server log:")
|
1729
|
+
for line in log_text.splitlines():
|
1730
|
+
self._append_log(line)
|
1731
|
+
if open_after_convert:
|
1732
|
+
self._notify(
|
1733
|
+
lambda path=self._last_output: self._open_in_file_manager(path)
|
1734
|
+
)
|
1735
|
+
|
1736
|
+
self._append_log("All jobs finished successfully.")
|
1737
|
+
self._notify(lambda: self.open_button.configure(state=self.tk.NORMAL))
|
1738
|
+
self._notify(self._clear_input_files)
|
1739
|
+
return True
|
1740
|
+
|
1156
1741
|
def _parse_float(self, value: str, label: str) -> float:
|
1157
1742
|
try:
|
1158
1743
|
return float(value)
|
@@ -1203,12 +1788,23 @@ class TalksReducerGUI:
|
|
1203
1788
|
|
1204
1789
|
def _update_status_from_message(self, message: str) -> None:
|
1205
1790
|
normalized = message.strip().lower()
|
1791
|
+
metadata_match = re.search(
|
1792
|
+
r"source metadata [—-] duration:\s*([\d.]+)s",
|
1793
|
+
message,
|
1794
|
+
re.IGNORECASE,
|
1795
|
+
)
|
1796
|
+
if metadata_match:
|
1797
|
+
try:
|
1798
|
+
self._source_duration_seconds = float(metadata_match.group(1))
|
1799
|
+
except ValueError:
|
1800
|
+
self._source_duration_seconds = None
|
1206
1801
|
if "all jobs finished successfully" in normalized:
|
1207
1802
|
# Create status message with ratios if available
|
1208
1803
|
status_msg = "Success"
|
1209
1804
|
if self._last_time_ratio is not None and self._last_size_ratio is not None:
|
1210
1805
|
status_msg = f"Time: {self._last_time_ratio:.0%}, Size: {self._last_size_ratio:.0%}"
|
1211
1806
|
|
1807
|
+
self._reset_audio_progress_state(clear_source=True)
|
1212
1808
|
self._set_status("success", status_msg)
|
1213
1809
|
self._set_progress(100) # 100% on success
|
1214
1810
|
self._video_duration_seconds = None # Reset for next video
|
@@ -1216,21 +1812,36 @@ class TalksReducerGUI:
|
|
1216
1812
|
self._encode_total_frames = None
|
1217
1813
|
self._encode_current_frame = None
|
1218
1814
|
elif normalized.startswith("extracting audio"):
|
1815
|
+
self._reset_audio_progress_state(clear_source=False)
|
1219
1816
|
self._set_status("processing", "Extracting audio...")
|
1220
1817
|
self._set_progress(0) # 0% on start
|
1221
1818
|
self._video_duration_seconds = None # Reset for new processing
|
1222
1819
|
self._encode_target_duration_seconds = None
|
1223
1820
|
self._encode_total_frames = None
|
1224
1821
|
self._encode_current_frame = None
|
1225
|
-
|
1226
|
-
|
1227
|
-
|
1822
|
+
self._start_audio_progress()
|
1823
|
+
elif normalized.startswith("uploading"):
|
1824
|
+
self._set_status("processing", "Uploading...")
|
1825
|
+
elif normalized.startswith("starting processing"):
|
1826
|
+
self._reset_audio_progress_state(clear_source=True)
|
1228
1827
|
self._set_status("processing", "Processing")
|
1229
1828
|
self._set_progress(0) # 0% on start
|
1230
1829
|
self._video_duration_seconds = None # Reset for new processing
|
1231
1830
|
self._encode_target_duration_seconds = None
|
1232
1831
|
self._encode_total_frames = None
|
1233
1832
|
self._encode_current_frame = None
|
1833
|
+
elif normalized.startswith("processing"):
|
1834
|
+
is_new_job = bool(re.match(r"processing \d+/\d+:", normalized))
|
1835
|
+
should_reset = self._status_state.lower() != "processing" or is_new_job
|
1836
|
+
if should_reset:
|
1837
|
+
self._set_progress(0) # 0% on start
|
1838
|
+
self._video_duration_seconds = None # Reset for new processing
|
1839
|
+
self._encode_target_duration_seconds = None
|
1840
|
+
self._encode_total_frames = None
|
1841
|
+
self._encode_current_frame = None
|
1842
|
+
if is_new_job:
|
1843
|
+
self._reset_audio_progress_state(clear_source=True)
|
1844
|
+
self._set_status("processing", "Processing")
|
1234
1845
|
|
1235
1846
|
frame_total_match = re.search(
|
1236
1847
|
r"Final encode target frames(?: \(fallback\))?:\s*(\d+)", message
|
@@ -1256,12 +1867,12 @@ class TalksReducerGUI:
|
|
1256
1867
|
|
1257
1868
|
self._encode_current_frame = current_frame
|
1258
1869
|
if self._encode_total_frames and self._encode_total_frames > 0:
|
1259
|
-
|
1260
|
-
|
1261
|
-
|
1262
|
-
)
|
1870
|
+
self._complete_audio_phase()
|
1871
|
+
frame_ratio = min(current_frame / self._encode_total_frames, 1.0)
|
1872
|
+
percentage = min(100, 5 + int(frame_ratio * 95))
|
1263
1873
|
self._set_progress(percentage)
|
1264
1874
|
else:
|
1875
|
+
self._complete_audio_phase()
|
1265
1876
|
self._set_status("processing", f"{current_frame} frames encoded")
|
1266
1877
|
|
1267
1878
|
# Parse encode target duration reported by the pipeline
|
@@ -1320,11 +1931,95 @@ class TalksReducerGUI:
|
|
1320
1931
|
and total_seconds
|
1321
1932
|
and total_seconds > 0
|
1322
1933
|
):
|
1323
|
-
|
1934
|
+
self._complete_audio_phase()
|
1935
|
+
time_ratio = min(current_seconds / total_seconds, 1.0)
|
1936
|
+
percentage = min(100, 5 + int(time_ratio * 95))
|
1324
1937
|
self._set_progress(percentage)
|
1325
1938
|
|
1326
1939
|
self._set_status("processing", status_msg)
|
1327
1940
|
|
1941
|
+
def _compute_audio_progress_interval(self) -> int:
|
1942
|
+
duration = self._source_duration_seconds or self._video_duration_seconds
|
1943
|
+
if duration and duration > 0:
|
1944
|
+
audio_seconds = max(duration * self.AUDIO_PROCESSING_RATIO, 0.0)
|
1945
|
+
interval_seconds = audio_seconds / self.AUDIO_PROGRESS_STEPS
|
1946
|
+
interval_ms = int(round(interval_seconds * 1000))
|
1947
|
+
return max(self.MIN_AUDIO_INTERVAL_MS, interval_ms)
|
1948
|
+
return self.DEFAULT_AUDIO_INTERVAL_MS
|
1949
|
+
|
1950
|
+
def _start_audio_progress(self) -> None:
|
1951
|
+
interval_ms = self._compute_audio_progress_interval()
|
1952
|
+
|
1953
|
+
def _start() -> None:
|
1954
|
+
if self._audio_progress_job is not None:
|
1955
|
+
self.root.after_cancel(self._audio_progress_job)
|
1956
|
+
self._audio_progress_steps_completed = 0
|
1957
|
+
self._audio_progress_interval_ms = interval_ms
|
1958
|
+
self._audio_progress_job = self.root.after(
|
1959
|
+
interval_ms, self._advance_audio_progress
|
1960
|
+
)
|
1961
|
+
|
1962
|
+
self._notify(_start)
|
1963
|
+
|
1964
|
+
def _advance_audio_progress(self) -> None:
|
1965
|
+
self._audio_progress_job = None
|
1966
|
+
if self._audio_progress_steps_completed >= self.AUDIO_PROGRESS_STEPS:
|
1967
|
+
self._audio_progress_interval_ms = None
|
1968
|
+
return
|
1969
|
+
|
1970
|
+
self._audio_progress_steps_completed += 1
|
1971
|
+
audio_percentage = (
|
1972
|
+
self._audio_progress_steps_completed / self.AUDIO_PROGRESS_STEPS * 100
|
1973
|
+
)
|
1974
|
+
percentage = (audio_percentage / 100) * 5
|
1975
|
+
self._set_progress(percentage)
|
1976
|
+
self._set_status("processing", "Audio processing: %d%%" % (audio_percentage))
|
1977
|
+
|
1978
|
+
if self._audio_progress_steps_completed < self.AUDIO_PROGRESS_STEPS:
|
1979
|
+
interval_ms = (
|
1980
|
+
self._audio_progress_interval_ms or self.DEFAULT_AUDIO_INTERVAL_MS
|
1981
|
+
)
|
1982
|
+
self._audio_progress_job = self.root.after(
|
1983
|
+
interval_ms, self._advance_audio_progress
|
1984
|
+
)
|
1985
|
+
else:
|
1986
|
+
self._audio_progress_interval_ms = None
|
1987
|
+
|
1988
|
+
def _cancel_audio_progress(self) -> None:
|
1989
|
+
if self._audio_progress_job is None:
|
1990
|
+
self._audio_progress_interval_ms = None
|
1991
|
+
return
|
1992
|
+
|
1993
|
+
def _cancel() -> None:
|
1994
|
+
if self._audio_progress_job is not None:
|
1995
|
+
self.root.after_cancel(self._audio_progress_job)
|
1996
|
+
self._audio_progress_job = None
|
1997
|
+
self._audio_progress_interval_ms = None
|
1998
|
+
|
1999
|
+
self._notify(_cancel)
|
2000
|
+
|
2001
|
+
def _reset_audio_progress_state(self, *, clear_source: bool) -> None:
|
2002
|
+
if clear_source:
|
2003
|
+
self._source_duration_seconds = None
|
2004
|
+
self._audio_progress_steps_completed = 0
|
2005
|
+
self._audio_progress_interval_ms = None
|
2006
|
+
if self._audio_progress_job is not None:
|
2007
|
+
self._cancel_audio_progress()
|
2008
|
+
|
2009
|
+
def _complete_audio_phase(self) -> None:
|
2010
|
+
def _complete() -> None:
|
2011
|
+
if self._audio_progress_job is not None:
|
2012
|
+
self.root.after_cancel(self._audio_progress_job)
|
2013
|
+
self._audio_progress_job = None
|
2014
|
+
self._audio_progress_interval_ms = None
|
2015
|
+
if self._audio_progress_steps_completed < self.AUDIO_PROGRESS_STEPS:
|
2016
|
+
self._audio_progress_steps_completed = self.AUDIO_PROGRESS_STEPS
|
2017
|
+
current_value = self.progress_var.get()
|
2018
|
+
if current_value < self.AUDIO_PROGRESS_STEPS:
|
2019
|
+
self._set_progress(self.AUDIO_PROGRESS_STEPS)
|
2020
|
+
|
2021
|
+
self._notify(_complete)
|
2022
|
+
|
1328
2023
|
def _apply_status_style(self, status: str) -> None:
|
1329
2024
|
color = STATUS_COLORS.get(status.lower())
|
1330
2025
|
if color:
|
@@ -1363,6 +2058,8 @@ class TalksReducerGUI:
|
|
1363
2058
|
self.status_frame.grid()
|
1364
2059
|
self.stop_button.grid()
|
1365
2060
|
self.drop_hint_button.grid_remove()
|
2061
|
+
else:
|
2062
|
+
self._reset_audio_progress_state(clear_source=True)
|
1366
2063
|
|
1367
2064
|
if lowered == "success" or "time:" in lowered and "size:" in lowered:
|
1368
2065
|
if self.simple_mode_var.get() and hasattr(self, "status_frame"):
|
@@ -1375,12 +2072,7 @@ class TalksReducerGUI:
|
|
1375
2072
|
else:
|
1376
2073
|
self.open_button.grid_remove()
|
1377
2074
|
# print("not success status")
|
1378
|
-
if (
|
1379
|
-
self.simple_mode_var.get()
|
1380
|
-
and not is_processing
|
1381
|
-
and hasattr(self, "status_frame")
|
1382
|
-
):
|
1383
|
-
self.status_frame.grid_remove()
|
2075
|
+
if self.simple_mode_var.get() and not is_processing:
|
1384
2076
|
self.stop_button.grid_remove()
|
1385
2077
|
# Show drop hint when no other buttons are visible
|
1386
2078
|
if hasattr(self, "drop_hint_button"):
|
@@ -1526,9 +2218,20 @@ def main(argv: Optional[Sequence[str]] = None) -> bool:
|
|
1526
2218
|
action="store_true",
|
1527
2219
|
help="Do not start the Talks Reducer server tray alongside the GUI.",
|
1528
2220
|
)
|
2221
|
+
parser.add_argument(
|
2222
|
+
"--server",
|
2223
|
+
action="store_true",
|
2224
|
+
help="Launch the Talks Reducer server tray instead of the desktop GUI.",
|
2225
|
+
)
|
1529
2226
|
|
1530
2227
|
parsed_args, remaining = parser.parse_known_args(argv)
|
1531
2228
|
no_tray = parsed_args.no_tray
|
2229
|
+
if parsed_args.server:
|
2230
|
+
package_name = __package__ or "talks_reducer"
|
2231
|
+
tray_module = importlib.import_module(f"{package_name}.server_tray")
|
2232
|
+
tray_main = getattr(tray_module, "main")
|
2233
|
+
tray_main(remaining)
|
2234
|
+
return False
|
1532
2235
|
argv = remaining
|
1533
2236
|
|
1534
2237
|
if argv:
|