talks-reducer 0.5.5__py3-none-any.whl → 0.6.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 +194 -33
- talks_reducer/discovery.py +42 -12
- talks_reducer/gui.py +694 -168
- talks_reducer/pipeline.py +1 -1
- talks_reducer/server.py +110 -23
- talks_reducer/server_tray.py +230 -18
- talks_reducer/service_client.py +258 -4
- talks_reducer/version_utils.py +22 -0
- {talks_reducer-0.5.5.dist-info → talks_reducer-0.6.1.dist-info}/METADATA +18 -7
- talks_reducer-0.6.1.dist-info/RECORD +22 -0
- talks_reducer-0.5.5.dist-info/RECORD +0 -21
- {talks_reducer-0.5.5.dist-info → talks_reducer-0.6.1.dist-info}/WHEEL +0 -0
- {talks_reducer-0.5.5.dist-info → talks_reducer-0.6.1.dist-info}/entry_points.txt +0 -0
- {talks_reducer-0.5.5.dist-info → talks_reducer-0.6.1.dist-info}/licenses/LICENSE +0 -0
- {talks_reducer-0.5.5.dist-info → talks_reducer-0.6.1.dist-info}/top_level.txt +0 -0
talks_reducer/gui.py
CHANGED
@@ -10,9 +10,20 @@ import re
|
|
10
10
|
import subprocess
|
11
11
|
import sys
|
12
12
|
import threading
|
13
|
-
|
13
|
+
import urllib.error
|
14
|
+
import urllib.parse
|
15
|
+
import urllib.request
|
14
16
|
from pathlib import Path
|
15
|
-
from typing import
|
17
|
+
from typing import (
|
18
|
+
TYPE_CHECKING,
|
19
|
+
Any,
|
20
|
+
Callable,
|
21
|
+
Iterable,
|
22
|
+
List,
|
23
|
+
Optional,
|
24
|
+
Sequence,
|
25
|
+
Tuple,
|
26
|
+
)
|
16
27
|
|
17
28
|
if TYPE_CHECKING:
|
18
29
|
import tkinter as tk
|
@@ -26,6 +37,7 @@ try:
|
|
26
37
|
from .models import ProcessingOptions, default_temp_folder
|
27
38
|
from .pipeline import speed_up_video
|
28
39
|
from .progress import ProgressHandle, SignalProgressReporter
|
40
|
+
from .version_utils import resolve_version
|
29
41
|
except ImportError: # pragma: no cover - handled at runtime
|
30
42
|
if __package__ not in (None, ""):
|
31
43
|
raise
|
@@ -41,6 +53,7 @@ except ImportError: # pragma: no cover - handled at runtime
|
|
41
53
|
from talks_reducer.models import ProcessingOptions, default_temp_folder
|
42
54
|
from talks_reducer.pipeline import speed_up_video
|
43
55
|
from talks_reducer.progress import ProgressHandle, SignalProgressReporter
|
56
|
+
from talks_reducer.version_utils import resolve_version
|
44
57
|
|
45
58
|
|
46
59
|
def _check_tkinter_available() -> tuple[bool, str]:
|
@@ -122,6 +135,7 @@ except ModuleNotFoundError: # pragma: no cover - runtime dependency
|
|
122
135
|
|
123
136
|
STATUS_COLORS = {
|
124
137
|
"idle": "#9ca3af",
|
138
|
+
"waiting": "#9ca3af",
|
125
139
|
"processing": "#af8e0e",
|
126
140
|
"success": "#178941",
|
127
141
|
"error": "#ad4f4f",
|
@@ -157,6 +171,25 @@ _TRAY_LOCK = threading.Lock()
|
|
157
171
|
_TRAY_PROCESS: Optional[subprocess.Popen[Any]] = None
|
158
172
|
|
159
173
|
|
174
|
+
def _default_remote_destination(input_file: Path, *, small: bool) -> Path:
|
175
|
+
"""Return the default remote output path for *input_file*.
|
176
|
+
|
177
|
+
Mirrors the naming scheme from the local pipeline so that remote jobs save
|
178
|
+
next to the source media with the expected suffix.
|
179
|
+
"""
|
180
|
+
|
181
|
+
name = input_file.name
|
182
|
+
dot_index = name.rfind(".")
|
183
|
+
suffix = "_speedup_small" if small else "_speedup"
|
184
|
+
|
185
|
+
if dot_index != -1:
|
186
|
+
new_name = name[:dot_index] + suffix + name[dot_index:]
|
187
|
+
else:
|
188
|
+
new_name = name + suffix
|
189
|
+
|
190
|
+
return input_file.with_name(new_name)
|
191
|
+
|
192
|
+
|
160
193
|
def _ensure_server_tray_running(extra_args: Optional[Sequence[str]] = None) -> None:
|
161
194
|
"""Start the server tray in a background process if one is not active."""
|
162
195
|
|
@@ -166,7 +199,13 @@ def _ensure_server_tray_running(extra_args: Optional[Sequence[str]] = None) -> N
|
|
166
199
|
if _TRAY_PROCESS is not None and _TRAY_PROCESS.poll() is None:
|
167
200
|
return
|
168
201
|
|
169
|
-
|
202
|
+
package_name = __package__ or "talks_reducer"
|
203
|
+
|
204
|
+
if getattr(sys, "frozen", False):
|
205
|
+
command = [sys.executable, "--server"]
|
206
|
+
else:
|
207
|
+
command = [sys.executable, "-m", f"{package_name}.server_tray"]
|
208
|
+
|
170
209
|
if extra_args:
|
171
210
|
command.extend(extra_args)
|
172
211
|
|
@@ -179,6 +218,31 @@ def _ensure_server_tray_running(extra_args: Optional[Sequence[str]] = None) -> N
|
|
179
218
|
)
|
180
219
|
|
181
220
|
|
221
|
+
def _parse_ratios_from_summary(summary: str) -> Tuple[Optional[float], Optional[float]]:
|
222
|
+
"""Extract time and size ratios from a Markdown *summary* string."""
|
223
|
+
|
224
|
+
time_ratio: Optional[float] = None
|
225
|
+
size_ratio: Optional[float] = None
|
226
|
+
|
227
|
+
for line in summary.splitlines():
|
228
|
+
if "**Duration:**" in line:
|
229
|
+
match = re.search(r"—\s*([0-9]+(?:\.[0-9]+)?)% of the original", line)
|
230
|
+
if match:
|
231
|
+
try:
|
232
|
+
time_ratio = float(match.group(1)) / 100
|
233
|
+
except ValueError:
|
234
|
+
time_ratio = None
|
235
|
+
elif "**Size:**" in line:
|
236
|
+
match = re.search(r"\*\*Size:\*\*\s*([0-9]+(?:\.[0-9]+)?)%", line)
|
237
|
+
if match:
|
238
|
+
try:
|
239
|
+
size_ratio = float(match.group(1)) / 100
|
240
|
+
except ValueError:
|
241
|
+
size_ratio = None
|
242
|
+
|
243
|
+
return time_ratio, size_ratio
|
244
|
+
|
245
|
+
|
182
246
|
class _GuiProgressHandle(ProgressHandle):
|
183
247
|
"""Simple progress handle that records totals but only logs milestones."""
|
184
248
|
|
@@ -243,6 +307,10 @@ class TalksReducerGUI:
|
|
243
307
|
"""Tkinter application mirroring the CLI options with form controls."""
|
244
308
|
|
245
309
|
PADDING = 10
|
310
|
+
AUDIO_PROCESSING_RATIO = 0.02
|
311
|
+
AUDIO_PROGRESS_STEPS = 20
|
312
|
+
MIN_AUDIO_INTERVAL_MS = 10
|
313
|
+
DEFAULT_AUDIO_INTERVAL_MS = 200
|
246
314
|
|
247
315
|
def _determine_config_path(self) -> Path:
|
248
316
|
if sys.platform == "win32":
|
@@ -281,6 +349,21 @@ class TalksReducerGUI:
|
|
281
349
|
self._settings[key] = value
|
282
350
|
return value
|
283
351
|
|
352
|
+
def _get_float_setting(self, key: str, default: float) -> float:
|
353
|
+
"""Return *key* as a float, coercing stored strings when necessary."""
|
354
|
+
|
355
|
+
raw_value = self._get_setting(key, default)
|
356
|
+
try:
|
357
|
+
number = float(raw_value)
|
358
|
+
except (TypeError, ValueError):
|
359
|
+
number = float(default)
|
360
|
+
|
361
|
+
if self._settings.get(key) != number:
|
362
|
+
self._settings[key] = number
|
363
|
+
self._save_settings()
|
364
|
+
|
365
|
+
return number
|
366
|
+
|
284
367
|
def _update_setting(self, key: str, value: object) -> None:
|
285
368
|
if self._settings.get(key) == value:
|
286
369
|
return
|
@@ -311,11 +394,11 @@ class TalksReducerGUI:
|
|
311
394
|
else:
|
312
395
|
self.root = tk.Tk()
|
313
396
|
|
314
|
-
# Set window title with version
|
315
|
-
|
316
|
-
|
397
|
+
# Set window title with version information
|
398
|
+
app_version = resolve_version()
|
399
|
+
if app_version and app_version != "unknown":
|
317
400
|
self.root.title(f"Talks Reducer v{app_version}")
|
318
|
-
|
401
|
+
else:
|
319
402
|
self.root.title("Talks Reducer")
|
320
403
|
|
321
404
|
self._apply_window_icon()
|
@@ -337,6 +420,10 @@ class TalksReducerGUI:
|
|
337
420
|
self._encode_target_duration_seconds: Optional[float] = None
|
338
421
|
self._encode_total_frames: Optional[int] = None
|
339
422
|
self._encode_current_frame: Optional[int] = None
|
423
|
+
self._source_duration_seconds: Optional[float] = None
|
424
|
+
self._audio_progress_job: Optional[str] = None
|
425
|
+
self._audio_progress_interval_ms: Optional[int] = None
|
426
|
+
self._audio_progress_steps_completed = 0
|
340
427
|
self.progress_var = tk.IntVar(value=0)
|
341
428
|
self._ffmpeg_process: Optional[subprocess.Popen] = None
|
342
429
|
self._stop_requested = False
|
@@ -353,6 +440,11 @@ class TalksReducerGUI:
|
|
353
440
|
self.open_after_convert_var = tk.BooleanVar(
|
354
441
|
value=self._get_setting("open_after_convert", True)
|
355
442
|
)
|
443
|
+
stored_mode = str(self._get_setting("processing_mode", "local"))
|
444
|
+
if stored_mode not in {"local", "remote"}:
|
445
|
+
stored_mode = "local"
|
446
|
+
self.processing_mode_var = tk.StringVar(value=stored_mode)
|
447
|
+
self.processing_mode_var.trace_add("write", self._on_processing_mode_change)
|
356
448
|
self.theme_var = tk.StringVar(value=self._get_setting("theme", "os"))
|
357
449
|
self.theme_var.trace_add("write", self._on_theme_change)
|
358
450
|
self.small_var.trace_add("write", self._on_small_video_change)
|
@@ -365,6 +457,11 @@ class TalksReducerGUI:
|
|
365
457
|
self.server_url_var.trace_add("write", self._on_server_url_change)
|
366
458
|
self._discovery_thread: Optional[threading.Thread] = None
|
367
459
|
|
460
|
+
self._basic_defaults: dict[str, float] = {}
|
461
|
+
self._basic_variables: dict[str, tk.DoubleVar] = {}
|
462
|
+
self._slider_updaters: dict[str, Callable[[str], None]] = {}
|
463
|
+
self._sliders: list[tk.Scale] = []
|
464
|
+
|
368
465
|
self._build_layout()
|
369
466
|
self._apply_simple_mode(initial=True)
|
370
467
|
self._apply_status_style(self._status_state)
|
@@ -372,6 +469,47 @@ class TalksReducerGUI:
|
|
372
469
|
self._save_settings()
|
373
470
|
self._hide_stop_button()
|
374
471
|
|
472
|
+
# Ping server on startup if in remote mode
|
473
|
+
if (
|
474
|
+
self.processing_mode_var.get() == "remote"
|
475
|
+
and self.server_url_var.get().strip()
|
476
|
+
and hasattr(self, "_ping_server")
|
477
|
+
):
|
478
|
+
server_url = self.server_url_var.get().strip()
|
479
|
+
host_label = self._format_server_host(server_url)
|
480
|
+
|
481
|
+
def ping_worker() -> None:
|
482
|
+
try:
|
483
|
+
if self._ping_server(server_url):
|
484
|
+
self._set_status("Idle", f"Server {host_label} is ready")
|
485
|
+
self._notify(
|
486
|
+
lambda: self._append_log(f"Server {host_label} ready")
|
487
|
+
)
|
488
|
+
else:
|
489
|
+
self._set_status(
|
490
|
+
"Error", f"Server {host_label} is not reachable"
|
491
|
+
)
|
492
|
+
self._notify(
|
493
|
+
lambda: self._append_log(
|
494
|
+
f"Server {host_label} is not reachable"
|
495
|
+
)
|
496
|
+
)
|
497
|
+
ping_worker()
|
498
|
+
except Exception as exc:
|
499
|
+
self._set_status(
|
500
|
+
"Idle", f"Error pinging server {host_label}: {exc}"
|
501
|
+
)
|
502
|
+
self._notify(
|
503
|
+
lambda: self._append_log(
|
504
|
+
f"Error pinging server {host_label}: {exc}"
|
505
|
+
)
|
506
|
+
)
|
507
|
+
|
508
|
+
import threading
|
509
|
+
|
510
|
+
ping_thread = threading.Thread(target=ping_worker, daemon=True)
|
511
|
+
ping_thread.start()
|
512
|
+
|
375
513
|
if not self._dnd_available:
|
376
514
|
self._append_log(
|
377
515
|
"Drag and drop requires the tkinterdnd2 package. Install it to enable the drop zone."
|
@@ -420,16 +558,8 @@ class TalksReducerGUI:
|
|
420
558
|
input_frame.grid(row=0, column=0, sticky="nsew")
|
421
559
|
main.rowconfigure(0, weight=1)
|
422
560
|
main.columnconfigure(0, weight=1)
|
423
|
-
|
424
|
-
|
425
|
-
|
426
|
-
self.input_list = self.tk.Listbox(input_frame, height=5)
|
427
|
-
self.input_list.grid(row=0, column=0, columnspan=4, sticky="nsew", pady=(0, 12))
|
428
|
-
self.input_scrollbar = self.ttk.Scrollbar(
|
429
|
-
input_frame, orient=self.tk.VERTICAL, command=self.input_list.yview
|
430
|
-
)
|
431
|
-
self.input_scrollbar.grid(row=0, column=4, sticky="ns", pady=(0, 12))
|
432
|
-
self.input_list.configure(yscrollcommand=self.input_scrollbar.set)
|
561
|
+
input_frame.columnconfigure(0, weight=1)
|
562
|
+
input_frame.rowconfigure(0, weight=1)
|
433
563
|
|
434
564
|
self.drop_zone = self.tk.Label(
|
435
565
|
input_frame,
|
@@ -440,40 +570,19 @@ class TalksReducerGUI:
|
|
440
570
|
pady=self.PADDING,
|
441
571
|
highlightthickness=0,
|
442
572
|
)
|
443
|
-
self.drop_zone.grid(row=
|
444
|
-
input_frame.rowconfigure(1, weight=1)
|
573
|
+
self.drop_zone.grid(row=0, column=0, sticky="nsew")
|
445
574
|
self._configure_drop_targets(self.drop_zone)
|
446
|
-
self._configure_drop_targets(self.input_list)
|
447
575
|
self.drop_zone.configure(cursor="hand2", takefocus=1)
|
448
576
|
self.drop_zone.bind("<Button-1>", self._on_drop_zone_click)
|
449
577
|
self.drop_zone.bind("<Return>", self._on_drop_zone_click)
|
450
578
|
self.drop_zone.bind("<space>", self._on_drop_zone_click)
|
451
579
|
|
452
|
-
self.add_files_button = self.ttk.Button(
|
453
|
-
input_frame, text="Add files", command=self._add_files
|
454
|
-
)
|
455
|
-
self.add_files_button.grid(row=2, column=0, pady=8, sticky="w")
|
456
|
-
self.add_folder_button = self.ttk.Button(
|
457
|
-
input_frame, text="Add folder", command=self._add_directory
|
458
|
-
)
|
459
|
-
self.add_folder_button.grid(row=2, column=1, pady=8)
|
460
|
-
self.remove_selected_button = self.ttk.Button(
|
461
|
-
input_frame, text="Remove selected", command=self._remove_selected
|
462
|
-
)
|
463
|
-
self.remove_selected_button.grid(row=2, column=2, pady=8, sticky="w")
|
464
|
-
self.run_after_drop_check = self.ttk.Checkbutton(
|
465
|
-
input_frame,
|
466
|
-
text="Run after drop",
|
467
|
-
variable=self.run_after_drop_var,
|
468
|
-
)
|
469
|
-
self.run_after_drop_check.grid(row=2, column=3, pady=8, sticky="e")
|
470
|
-
|
471
580
|
# Options frame
|
472
|
-
|
473
|
-
|
474
|
-
|
581
|
+
self.options_frame = self.ttk.Frame(main, padding=self.PADDING)
|
582
|
+
self.options_frame.grid(row=2, column=0, pady=(0, 0), sticky="ew")
|
583
|
+
self.options_frame.columnconfigure(0, weight=1)
|
475
584
|
|
476
|
-
checkbox_frame = self.ttk.Frame(
|
585
|
+
checkbox_frame = self.ttk.Frame(self.options_frame)
|
477
586
|
checkbox_frame.grid(row=0, column=0, columnspan=2, sticky="w")
|
478
587
|
|
479
588
|
self.ttk.Checkbutton(
|
@@ -499,70 +608,120 @@ class TalksReducerGUI:
|
|
499
608
|
)
|
500
609
|
|
501
610
|
self.advanced_visible = self.tk.BooleanVar(value=False)
|
502
|
-
self.advanced_button = self.ttk.Button(
|
503
|
-
options,
|
504
|
-
text="Advanced",
|
505
|
-
command=self._toggle_advanced,
|
506
|
-
)
|
507
|
-
self.advanced_button.grid(row=1, column=1, sticky="e")
|
508
611
|
|
509
|
-
|
510
|
-
self.
|
511
|
-
self.
|
612
|
+
basic_label_container = self.ttk.Frame(self.options_frame)
|
613
|
+
basic_label = self.ttk.Label(basic_label_container, text="Basic options")
|
614
|
+
basic_label.pack(side=self.tk.LEFT)
|
512
615
|
|
513
|
-
self.
|
514
|
-
|
515
|
-
|
616
|
+
self.reset_basic_button = self.ttk.Button(
|
617
|
+
basic_label_container,
|
618
|
+
text="Reset to defaults",
|
619
|
+
command=self._reset_basic_defaults,
|
620
|
+
state=self.tk.DISABLED,
|
621
|
+
style="Link.TButton",
|
516
622
|
)
|
517
623
|
|
518
|
-
self.
|
519
|
-
|
520
|
-
self.advanced_frame, "Temp folder", self.temp_var, row=1, browse=True
|
624
|
+
self.basic_options_frame = self.ttk.Labelframe(
|
625
|
+
self.options_frame, padding=0, labelwidget=basic_label_container
|
521
626
|
)
|
522
|
-
|
523
|
-
|
524
|
-
self._add_entry(
|
525
|
-
self.advanced_frame,
|
526
|
-
"Silent threshold",
|
527
|
-
self.silent_threshold_var,
|
528
|
-
row=2,
|
627
|
+
self.basic_options_frame.grid(
|
628
|
+
row=2, column=0, columnspan=2, sticky="ew", pady=(12, 0)
|
529
629
|
)
|
630
|
+
self.basic_options_frame.columnconfigure(1, weight=1)
|
530
631
|
|
531
|
-
self.
|
532
|
-
self._add_entry(
|
533
|
-
self.advanced_frame, "Sounded speed", self.sounded_speed_var, row=3
|
534
|
-
)
|
632
|
+
self._reset_button_visible = False
|
535
633
|
|
536
|
-
self.silent_speed_var = self.tk.
|
537
|
-
|
538
|
-
|
634
|
+
self.silent_speed_var = self.tk.DoubleVar(
|
635
|
+
value=min(max(self._get_float_setting("silent_speed", 4.0), 1.0), 10.0)
|
636
|
+
)
|
637
|
+
self._add_slider(
|
638
|
+
self.basic_options_frame,
|
639
|
+
"Silent speed",
|
640
|
+
self.silent_speed_var,
|
641
|
+
row=0,
|
642
|
+
setting_key="silent_speed",
|
643
|
+
minimum=1.0,
|
644
|
+
maximum=10.0,
|
645
|
+
resolution=0.5,
|
646
|
+
display_format="{:.1f}×",
|
647
|
+
default_value=4.0,
|
539
648
|
)
|
540
649
|
|
541
|
-
self.
|
542
|
-
|
543
|
-
|
650
|
+
self.sounded_speed_var = self.tk.DoubleVar(
|
651
|
+
value=min(max(self._get_float_setting("sounded_speed", 1.0), 0.75), 2.0)
|
652
|
+
)
|
653
|
+
self._add_slider(
|
654
|
+
self.basic_options_frame,
|
655
|
+
"Sounded speed",
|
656
|
+
self.sounded_speed_var,
|
657
|
+
row=1,
|
658
|
+
setting_key="sounded_speed",
|
659
|
+
minimum=0.75,
|
660
|
+
maximum=2.0,
|
661
|
+
resolution=0.25,
|
662
|
+
display_format="{:.2f}×",
|
663
|
+
default_value=1.0,
|
544
664
|
)
|
545
665
|
|
546
|
-
self.
|
547
|
-
|
666
|
+
self.silent_threshold_var = self.tk.DoubleVar(
|
667
|
+
value=min(max(self._get_float_setting("silent_threshold", 0.05), 0.0), 1.0)
|
668
|
+
)
|
669
|
+
self._add_slider(
|
670
|
+
self.basic_options_frame,
|
671
|
+
"Silent threshold",
|
672
|
+
self.silent_threshold_var,
|
673
|
+
row=2,
|
674
|
+
setting_key="silent_threshold",
|
675
|
+
minimum=0.0,
|
676
|
+
maximum=1.0,
|
677
|
+
resolution=0.01,
|
678
|
+
display_format="{:.2f}",
|
679
|
+
default_value=0.05,
|
680
|
+
)
|
548
681
|
|
549
|
-
self.ttk.Label(self.
|
550
|
-
row=
|
682
|
+
self.ttk.Label(self.basic_options_frame, text="Server URL").grid(
|
683
|
+
row=3, column=0, sticky="w", pady=4
|
551
684
|
)
|
685
|
+
stored_server_url = str(
|
686
|
+
self._get_setting("server_url", "http://localhost:9005")
|
687
|
+
)
|
688
|
+
if not stored_server_url:
|
689
|
+
stored_server_url = "http://localhost:9005"
|
690
|
+
self._update_setting("server_url", stored_server_url)
|
691
|
+
self.server_url_var.set(stored_server_url)
|
552
692
|
self.server_url_entry = self.ttk.Entry(
|
553
|
-
self.
|
693
|
+
self.basic_options_frame, textvariable=self.server_url_var, width=30
|
554
694
|
)
|
555
|
-
self.server_url_entry.grid(row=
|
695
|
+
self.server_url_entry.grid(row=3, column=1, sticky="w", pady=4)
|
556
696
|
self.server_discover_button = self.ttk.Button(
|
557
|
-
self.
|
558
|
-
)
|
559
|
-
self.server_discover_button.grid(row=
|
560
|
-
|
561
|
-
self.ttk.Label(self.
|
562
|
-
row=
|
563
|
-
)
|
564
|
-
|
565
|
-
|
697
|
+
self.basic_options_frame, text="Discover", command=self._start_discovery
|
698
|
+
)
|
699
|
+
self.server_discover_button.grid(row=3, column=2, padx=(8, 0))
|
700
|
+
|
701
|
+
self.ttk.Label(self.basic_options_frame, text="Processing mode").grid(
|
702
|
+
row=4, column=0, sticky="w", pady=4
|
703
|
+
)
|
704
|
+
mode_choice = self.ttk.Frame(self.basic_options_frame)
|
705
|
+
mode_choice.grid(row=4, column=1, columnspan=2, sticky="w", pady=4)
|
706
|
+
self.ttk.Radiobutton(
|
707
|
+
mode_choice,
|
708
|
+
text="Local",
|
709
|
+
value="local",
|
710
|
+
variable=self.processing_mode_var,
|
711
|
+
).pack(side=self.tk.LEFT, padx=(0, 8))
|
712
|
+
self.remote_mode_button = self.ttk.Radiobutton(
|
713
|
+
mode_choice,
|
714
|
+
text="Remote",
|
715
|
+
value="remote",
|
716
|
+
variable=self.processing_mode_var,
|
717
|
+
)
|
718
|
+
self.remote_mode_button.pack(side=self.tk.LEFT, padx=(0, 8))
|
719
|
+
|
720
|
+
self.ttk.Label(self.basic_options_frame, text="Theme").grid(
|
721
|
+
row=5, column=0, sticky="w", pady=(8, 0)
|
722
|
+
)
|
723
|
+
theme_choice = self.ttk.Frame(self.basic_options_frame)
|
724
|
+
theme_choice.grid(row=5, column=1, columnspan=2, sticky="w", pady=(8, 0))
|
566
725
|
for value, label in ("os", "OS"), ("light", "Light"), ("dark", "Dark"):
|
567
726
|
self.ttk.Radiobutton(
|
568
727
|
theme_choice,
|
@@ -572,7 +731,47 @@ class TalksReducerGUI:
|
|
572
731
|
command=self._apply_theme,
|
573
732
|
).pack(side=self.tk.LEFT, padx=(0, 8))
|
574
733
|
|
734
|
+
self.advanced_button = self.ttk.Button(
|
735
|
+
self.options_frame,
|
736
|
+
text="Advanced",
|
737
|
+
command=self._toggle_advanced,
|
738
|
+
)
|
739
|
+
self.advanced_button.grid(
|
740
|
+
row=3, column=0, columnspan=2, sticky="w", pady=(12, 0)
|
741
|
+
)
|
742
|
+
|
743
|
+
self.advanced_frame = self.ttk.Frame(self.options_frame, padding=0)
|
744
|
+
self.advanced_frame.grid(row=4, column=0, columnspan=2, sticky="nsew")
|
745
|
+
self.advanced_frame.columnconfigure(1, weight=1)
|
746
|
+
|
747
|
+
self.output_var = self.tk.StringVar()
|
748
|
+
self._add_entry(
|
749
|
+
self.advanced_frame, "Output file", self.output_var, row=0, browse=True
|
750
|
+
)
|
751
|
+
|
752
|
+
self.temp_var = self.tk.StringVar(value=str(default_temp_folder()))
|
753
|
+
self._add_entry(
|
754
|
+
self.advanced_frame, "Temp folder", self.temp_var, row=1, browse=True
|
755
|
+
)
|
756
|
+
|
757
|
+
self.sample_rate_var = self.tk.StringVar(value="48000")
|
758
|
+
self._add_entry(self.advanced_frame, "Sample rate", self.sample_rate_var, row=2)
|
759
|
+
|
760
|
+
frame_margin_setting = self._get_setting("frame_margin", 2)
|
761
|
+
try:
|
762
|
+
frame_margin_default = int(frame_margin_setting)
|
763
|
+
except (TypeError, ValueError):
|
764
|
+
frame_margin_default = 2
|
765
|
+
self._update_setting("frame_margin", frame_margin_default)
|
766
|
+
|
767
|
+
self.frame_margin_var = self.tk.StringVar(value=str(frame_margin_default))
|
768
|
+
self._add_entry(
|
769
|
+
self.advanced_frame, "Frame margin", self.frame_margin_var, row=3
|
770
|
+
)
|
771
|
+
|
575
772
|
self._toggle_advanced(initial=True)
|
773
|
+
self._update_processing_mode_state()
|
774
|
+
self._update_basic_reset_state()
|
576
775
|
|
577
776
|
# Action buttons and log output
|
578
777
|
status_frame = self.ttk.Frame(main, padding=self.PADDING)
|
@@ -580,6 +779,7 @@ class TalksReducerGUI:
|
|
580
779
|
status_frame.columnconfigure(0, weight=0)
|
581
780
|
status_frame.columnconfigure(1, weight=1)
|
582
781
|
status_frame.columnconfigure(2, weight=0)
|
782
|
+
self.status_frame = status_frame
|
583
783
|
|
584
784
|
self.ttk.Label(status_frame, text="Status:").grid(row=0, column=0, sticky="w")
|
585
785
|
self.status_label = self.tk.Label(
|
@@ -664,6 +864,168 @@ class TalksReducerGUI:
|
|
664
864
|
)
|
665
865
|
button.grid(row=row, column=2, padx=(8, 0))
|
666
866
|
|
867
|
+
def _add_slider(
|
868
|
+
self,
|
869
|
+
parent, # type: tk.Misc
|
870
|
+
label: str,
|
871
|
+
variable, # type: tk.DoubleVar
|
872
|
+
*,
|
873
|
+
row: int,
|
874
|
+
setting_key: str,
|
875
|
+
minimum: float,
|
876
|
+
maximum: float,
|
877
|
+
resolution: float,
|
878
|
+
display_format: str,
|
879
|
+
default_value: float,
|
880
|
+
) -> None:
|
881
|
+
self.ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w", pady=4)
|
882
|
+
|
883
|
+
value_label = self.ttk.Label(parent)
|
884
|
+
value_label.grid(row=row, column=2, sticky="e", pady=4)
|
885
|
+
|
886
|
+
def update(value: str) -> None:
|
887
|
+
numeric = float(value)
|
888
|
+
clamped = max(minimum, min(maximum, numeric))
|
889
|
+
steps = round((clamped - minimum) / resolution)
|
890
|
+
quantized = minimum + steps * resolution
|
891
|
+
if abs(variable.get() - quantized) > 1e-9:
|
892
|
+
variable.set(quantized)
|
893
|
+
value_label.configure(text=display_format.format(quantized))
|
894
|
+
self._update_setting(setting_key, float(f"{quantized:.6f}"))
|
895
|
+
self._update_basic_reset_state()
|
896
|
+
|
897
|
+
slider = self.tk.Scale(
|
898
|
+
parent,
|
899
|
+
variable=variable,
|
900
|
+
from_=minimum,
|
901
|
+
to=maximum,
|
902
|
+
orient=self.tk.HORIZONTAL,
|
903
|
+
resolution=resolution,
|
904
|
+
showvalue=False,
|
905
|
+
command=update,
|
906
|
+
length=240,
|
907
|
+
highlightthickness=0,
|
908
|
+
)
|
909
|
+
slider.grid(row=row, column=1, sticky="ew", pady=4, padx=(0, 8))
|
910
|
+
|
911
|
+
update(str(variable.get()))
|
912
|
+
|
913
|
+
self._slider_updaters[setting_key] = update
|
914
|
+
self._basic_defaults[setting_key] = default_value
|
915
|
+
self._basic_variables[setting_key] = variable
|
916
|
+
variable.trace_add("write", lambda *_: self._update_basic_reset_state())
|
917
|
+
self._sliders.append(slider)
|
918
|
+
|
919
|
+
def _update_basic_reset_state(self) -> None:
|
920
|
+
"""Enable or disable the reset control based on slider values."""
|
921
|
+
|
922
|
+
if not hasattr(self, "reset_basic_button"):
|
923
|
+
return
|
924
|
+
|
925
|
+
should_enable = False
|
926
|
+
for key, default_value in self._basic_defaults.items():
|
927
|
+
variable = self._basic_variables.get(key)
|
928
|
+
if variable is None:
|
929
|
+
continue
|
930
|
+
try:
|
931
|
+
current_value = float(variable.get())
|
932
|
+
except (TypeError, ValueError):
|
933
|
+
should_enable = True
|
934
|
+
break
|
935
|
+
if abs(current_value - default_value) > 1e-9:
|
936
|
+
should_enable = True
|
937
|
+
break
|
938
|
+
|
939
|
+
if should_enable:
|
940
|
+
if not getattr(self, "_reset_button_visible", False):
|
941
|
+
self.reset_basic_button.pack(side=self.tk.LEFT, padx=(8, 0))
|
942
|
+
self._reset_button_visible = True
|
943
|
+
self.reset_basic_button.configure(state=self.tk.NORMAL)
|
944
|
+
else:
|
945
|
+
if getattr(self, "_reset_button_visible", False):
|
946
|
+
self.reset_basic_button.pack_forget()
|
947
|
+
self._reset_button_visible = False
|
948
|
+
self.reset_basic_button.configure(state=self.tk.DISABLED)
|
949
|
+
|
950
|
+
def _reset_basic_defaults(self) -> None:
|
951
|
+
"""Restore the basic numeric controls to their default values."""
|
952
|
+
|
953
|
+
for key, default_value in self._basic_defaults.items():
|
954
|
+
variable = self._basic_variables.get(key)
|
955
|
+
if variable is None:
|
956
|
+
continue
|
957
|
+
|
958
|
+
try:
|
959
|
+
current_value = float(variable.get())
|
960
|
+
except (TypeError, ValueError):
|
961
|
+
current_value = default_value
|
962
|
+
|
963
|
+
if abs(current_value - default_value) <= 1e-9:
|
964
|
+
continue
|
965
|
+
|
966
|
+
variable.set(default_value)
|
967
|
+
updater = self._slider_updaters.get(key)
|
968
|
+
if updater is not None:
|
969
|
+
updater(str(default_value))
|
970
|
+
else:
|
971
|
+
self._update_setting(key, float(f"{default_value:.6f}"))
|
972
|
+
|
973
|
+
self._update_basic_reset_state()
|
974
|
+
|
975
|
+
def _update_processing_mode_state(self) -> None:
|
976
|
+
has_url = bool(self.server_url_var.get().strip())
|
977
|
+
if not has_url and self.processing_mode_var.get() == "remote":
|
978
|
+
self.processing_mode_var.set("local")
|
979
|
+
return
|
980
|
+
|
981
|
+
if hasattr(self, "remote_mode_button"):
|
982
|
+
state = self.tk.NORMAL if has_url else self.tk.DISABLED
|
983
|
+
self.remote_mode_button.configure(state=state)
|
984
|
+
|
985
|
+
def _normalize_server_url(self, server_url: str) -> str:
|
986
|
+
parsed = urllib.parse.urlsplit(server_url)
|
987
|
+
if not parsed.scheme:
|
988
|
+
parsed = urllib.parse.urlsplit(f"http://{server_url}")
|
989
|
+
|
990
|
+
netloc = parsed.netloc or parsed.path
|
991
|
+
if not netloc:
|
992
|
+
return server_url
|
993
|
+
|
994
|
+
path = parsed.path if parsed.netloc else ""
|
995
|
+
normalized_path = path or "/"
|
996
|
+
return urllib.parse.urlunsplit((parsed.scheme, netloc, normalized_path, "", ""))
|
997
|
+
|
998
|
+
def _format_server_host(self, server_url: str) -> str:
|
999
|
+
parsed = urllib.parse.urlsplit(server_url)
|
1000
|
+
if not parsed.scheme:
|
1001
|
+
parsed = urllib.parse.urlsplit(f"http://{server_url}")
|
1002
|
+
|
1003
|
+
host = parsed.netloc or parsed.path or server_url
|
1004
|
+
if parsed.netloc and parsed.path and parsed.path not in {"", "/"}:
|
1005
|
+
host = f"{parsed.netloc}{parsed.path}"
|
1006
|
+
|
1007
|
+
host = host.rstrip("/").split(":")[0]
|
1008
|
+
return host or server_url
|
1009
|
+
|
1010
|
+
def _ping_server(self, server_url: str, *, timeout: float = 5.0) -> bool:
|
1011
|
+
normalized = self._normalize_server_url(server_url)
|
1012
|
+
request = urllib.request.Request(
|
1013
|
+
normalized,
|
1014
|
+
headers={"User-Agent": "talks-reducer-gui"},
|
1015
|
+
method="GET",
|
1016
|
+
)
|
1017
|
+
|
1018
|
+
try:
|
1019
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
1020
|
+
status = getattr(response, "status", None)
|
1021
|
+
if status is None:
|
1022
|
+
status = response.getcode()
|
1023
|
+
if status is None:
|
1024
|
+
return False
|
1025
|
+
return 200 <= int(status) < 500
|
1026
|
+
except (urllib.error.URLError, ValueError):
|
1027
|
+
return False
|
1028
|
+
|
667
1029
|
def _start_discovery(self) -> None:
|
668
1030
|
"""Search the local network for running Talks Reducer servers."""
|
669
1031
|
|
@@ -677,7 +1039,11 @@ class TalksReducerGUI:
|
|
677
1039
|
|
678
1040
|
def worker() -> None:
|
679
1041
|
try:
|
680
|
-
urls = discover_servers(
|
1042
|
+
urls = discover_servers(
|
1043
|
+
progress_callback=lambda current, total: self._notify(
|
1044
|
+
lambda c=current, t=total: self._on_discovery_progress(c, t)
|
1045
|
+
)
|
1046
|
+
)
|
681
1047
|
except Exception as exc: # pragma: no cover - network failure safeguard
|
682
1048
|
self._notify(lambda: self._on_discovery_failed(exc))
|
683
1049
|
return
|
@@ -692,6 +1058,14 @@ class TalksReducerGUI:
|
|
692
1058
|
self._append_log(message)
|
693
1059
|
self.messagebox.showerror("Discovery failed", message)
|
694
1060
|
|
1061
|
+
def _on_discovery_progress(self, current: int, total: int) -> None:
|
1062
|
+
if total > 0:
|
1063
|
+
bounded = max(0, min(current, total))
|
1064
|
+
label = f"{bounded} / {total}"
|
1065
|
+
else:
|
1066
|
+
label = "Discovering…"
|
1067
|
+
self.server_discover_button.configure(text=label)
|
1068
|
+
|
695
1069
|
def _on_discovery_complete(self, urls: List[str]) -> None:
|
696
1070
|
self.server_discover_button.configure(state=self.tk.NORMAL, text="Discover")
|
697
1071
|
if not urls:
|
@@ -774,38 +1148,17 @@ class TalksReducerGUI:
|
|
774
1148
|
|
775
1149
|
def _apply_simple_mode(self, *, initial: bool = False) -> None:
|
776
1150
|
simple = self.simple_mode_var.get()
|
777
|
-
widgets = [
|
778
|
-
self.input_list,
|
779
|
-
self.input_scrollbar,
|
780
|
-
self.add_files_button,
|
781
|
-
self.add_folder_button,
|
782
|
-
self.remove_selected_button,
|
783
|
-
self.run_after_drop_check,
|
784
|
-
]
|
785
|
-
|
786
1151
|
if simple:
|
787
|
-
|
788
|
-
widget.grid_remove()
|
1152
|
+
self.basic_options_frame.grid_remove()
|
789
1153
|
self.log_frame.grid_remove()
|
790
1154
|
self.stop_button.grid_remove()
|
791
1155
|
self.advanced_button.grid_remove()
|
792
1156
|
self.advanced_frame.grid_remove()
|
793
|
-
if hasattr(self, "status_frame"):
|
794
|
-
self.status_frame.grid_remove()
|
795
1157
|
self.run_after_drop_var.set(True)
|
796
1158
|
self._apply_window_size(simple=True)
|
797
|
-
if self.status_var.get().lower() == "success" and hasattr(
|
798
|
-
self, "status_frame"
|
799
|
-
):
|
800
|
-
self.status_frame.grid()
|
801
|
-
self.open_button.grid()
|
802
|
-
self.drop_hint_button.grid_remove()
|
803
1159
|
else:
|
804
|
-
|
805
|
-
widget.grid()
|
1160
|
+
self.basic_options_frame.grid()
|
806
1161
|
self.log_frame.grid()
|
807
|
-
if hasattr(self, "status_frame"):
|
808
|
-
self.status_frame.grid()
|
809
1162
|
self.advanced_button.grid()
|
810
1163
|
if self.advanced_visible.get():
|
811
1164
|
self.advanced_frame.grid()
|
@@ -850,9 +1203,18 @@ class TalksReducerGUI:
|
|
850
1203
|
"open_after_convert", bool(self.open_after_convert_var.get())
|
851
1204
|
)
|
852
1205
|
|
1206
|
+
def _on_processing_mode_change(self, *_: object) -> None:
|
1207
|
+
value = self.processing_mode_var.get()
|
1208
|
+
if value not in {"local", "remote"}:
|
1209
|
+
self.processing_mode_var.set("local")
|
1210
|
+
return
|
1211
|
+
self._update_setting("processing_mode", value)
|
1212
|
+
self._update_processing_mode_state()
|
1213
|
+
|
853
1214
|
def _on_server_url_change(self, *_: object) -> None:
|
854
1215
|
value = self.server_url_var.get().strip()
|
855
1216
|
self._update_setting("server_url", value)
|
1217
|
+
self._update_processing_mode_state()
|
856
1218
|
|
857
1219
|
def _apply_theme(self) -> None:
|
858
1220
|
preference = self.theme_var.get().lower()
|
@@ -902,11 +1264,33 @@ class TalksReducerGUI:
|
|
902
1264
|
"TRadiobutton",
|
903
1265
|
background=[("active", palette.get("hover", palette["background"]))],
|
904
1266
|
)
|
1267
|
+
self.style.configure(
|
1268
|
+
"Link.TButton",
|
1269
|
+
background=palette["background"],
|
1270
|
+
foreground=palette["accent"],
|
1271
|
+
borderwidth=0,
|
1272
|
+
relief="flat",
|
1273
|
+
highlightthickness=0,
|
1274
|
+
padding=2,
|
1275
|
+
font=("TkDefaultFont", 8, "underline"),
|
1276
|
+
)
|
1277
|
+
self.style.map(
|
1278
|
+
"Link.TButton",
|
1279
|
+
background=[
|
1280
|
+
("active", palette.get("hover", palette["background"])),
|
1281
|
+
("disabled", palette["background"]),
|
1282
|
+
],
|
1283
|
+
foreground=[
|
1284
|
+
("active", palette.get("accent", palette["foreground"])),
|
1285
|
+
("disabled", palette["foreground"]),
|
1286
|
+
],
|
1287
|
+
)
|
905
1288
|
self.style.configure(
|
906
1289
|
"TButton",
|
907
1290
|
background=palette["surface"],
|
908
1291
|
foreground=palette["foreground"],
|
909
|
-
padding=
|
1292
|
+
padding=4,
|
1293
|
+
font=("TkDefaultFont", 8),
|
910
1294
|
)
|
911
1295
|
self.style.map(
|
912
1296
|
"TButton",
|
@@ -972,14 +1356,24 @@ class TalksReducerGUI:
|
|
972
1356
|
fg=palette["foreground"],
|
973
1357
|
highlightthickness=0,
|
974
1358
|
)
|
975
|
-
|
976
|
-
|
977
|
-
|
978
|
-
|
979
|
-
|
980
|
-
|
981
|
-
highlightcolor=palette["border"],
|
1359
|
+
|
1360
|
+
slider_relief = self.tk.FLAT if mode == "dark" else self.tk.RAISED
|
1361
|
+
active_background = (
|
1362
|
+
palette.get("accent", palette["surface"])
|
1363
|
+
if mode == "dark"
|
1364
|
+
else palette.get("hover", palette["surface"])
|
982
1365
|
)
|
1366
|
+
for slider in getattr(self, "_sliders", []):
|
1367
|
+
slider.configure(
|
1368
|
+
# background=palette["background"],
|
1369
|
+
# foreground=palette["foreground"],
|
1370
|
+
troughcolor=palette["surface"],
|
1371
|
+
# activebackground=active_background,
|
1372
|
+
# highlightbackground=palette["background"],
|
1373
|
+
highlightcolor=palette["background"],
|
1374
|
+
sliderrelief=slider_relief,
|
1375
|
+
bd=0,
|
1376
|
+
)
|
983
1377
|
self.log_text.configure(
|
984
1378
|
bg=palette["surface"],
|
985
1379
|
fg=palette["foreground"],
|
@@ -1041,7 +1435,6 @@ class TalksReducerGUI:
|
|
1041
1435
|
resolved = os.fspath(Path(path))
|
1042
1436
|
if resolved not in self.input_files:
|
1043
1437
|
self.input_files.append(resolved)
|
1044
|
-
self.input_list.insert(self.tk.END, resolved)
|
1045
1438
|
normalized.append(resolved)
|
1046
1439
|
|
1047
1440
|
if auto_run and normalized:
|
@@ -1075,21 +1468,13 @@ class TalksReducerGUI:
|
|
1075
1468
|
for path in paths:
|
1076
1469
|
if path and path not in self.input_files:
|
1077
1470
|
self.input_files.append(path)
|
1078
|
-
self.input_list.insert(self.tk.END, path)
|
1079
1471
|
added = True
|
1080
1472
|
if auto_run and added and self.run_after_drop_var.get():
|
1081
1473
|
self._start_run()
|
1082
1474
|
|
1083
|
-
def _remove_selected(self) -> None:
|
1084
|
-
selection = list(self.input_list.curselection())
|
1085
|
-
for index in reversed(selection):
|
1086
|
-
self.input_list.delete(index)
|
1087
|
-
del self.input_files[index]
|
1088
|
-
|
1089
1475
|
def _clear_input_files(self) -> None:
|
1090
|
-
"""Clear all input files
|
1476
|
+
"""Clear all queued input files."""
|
1091
1477
|
self.input_files.clear()
|
1092
|
-
self.input_list.delete(0, self.tk.END)
|
1093
1478
|
|
1094
1479
|
def _on_drop(self, event: object) -> None:
|
1095
1480
|
data = getattr(event, "data", "")
|
@@ -1099,7 +1484,6 @@ class TalksReducerGUI:
|
|
1099
1484
|
cleaned = [path.strip("{}") for path in paths]
|
1100
1485
|
# Clear existing files before adding dropped files
|
1101
1486
|
self.input_files.clear()
|
1102
|
-
self.input_list.delete(0, self.tk.END)
|
1103
1487
|
self._extend_inputs(cleaned, auto_run=True)
|
1104
1488
|
|
1105
1489
|
def _on_drop_zone_click(self, event: object) -> str | None:
|
@@ -1146,6 +1530,13 @@ class TalksReducerGUI:
|
|
1146
1530
|
self._stop_requested = False
|
1147
1531
|
open_after_convert = bool(self.open_after_convert_var.get())
|
1148
1532
|
server_url = self.server_url_var.get().strip()
|
1533
|
+
remote_mode = self.processing_mode_var.get() == "remote"
|
1534
|
+
if remote_mode and not server_url:
|
1535
|
+
self.messagebox.showerror(
|
1536
|
+
"Missing server URL", "Remote mode requires a server URL."
|
1537
|
+
)
|
1538
|
+
return
|
1539
|
+
remote_mode = remote_mode and bool(server_url)
|
1149
1540
|
|
1150
1541
|
def worker() -> None:
|
1151
1542
|
def set_process(proc: subprocess.Popen) -> None:
|
@@ -1162,7 +1553,7 @@ class TalksReducerGUI:
|
|
1162
1553
|
self._set_status("Idle")
|
1163
1554
|
return
|
1164
1555
|
|
1165
|
-
if
|
1556
|
+
if remote_mode:
|
1166
1557
|
success = self._process_files_via_server(
|
1167
1558
|
files,
|
1168
1559
|
args,
|
@@ -1225,7 +1616,7 @@ class TalksReducerGUI:
|
|
1225
1616
|
self._processing_thread.start()
|
1226
1617
|
|
1227
1618
|
# Show Stop button when processing starts
|
1228
|
-
if
|
1619
|
+
if remote_mode:
|
1229
1620
|
self.stop_button.grid_remove()
|
1230
1621
|
else:
|
1231
1622
|
self.stop_button.grid()
|
@@ -1272,18 +1663,14 @@ class TalksReducerGUI:
|
|
1272
1663
|
args["output_file"] = Path(self.output_var.get())
|
1273
1664
|
if self.temp_var.get():
|
1274
1665
|
args["temp_folder"] = Path(self.temp_var.get())
|
1275
|
-
|
1276
|
-
|
1277
|
-
|
1278
|
-
|
1279
|
-
|
1280
|
-
|
1281
|
-
|
1282
|
-
|
1283
|
-
if self.silent_speed_var.get():
|
1284
|
-
args["silent_speed"] = self._parse_float(
|
1285
|
-
self.silent_speed_var.get(), "Silent speed"
|
1286
|
-
)
|
1666
|
+
silent_threshold = float(self.silent_threshold_var.get())
|
1667
|
+
args["silent_threshold"] = round(silent_threshold, 2)
|
1668
|
+
|
1669
|
+
sounded_speed = float(self.sounded_speed_var.get())
|
1670
|
+
args["sounded_speed"] = round(sounded_speed, 2)
|
1671
|
+
|
1672
|
+
silent_speed = float(self.silent_speed_var.get())
|
1673
|
+
args["silent_speed"] = round(silent_speed, 2)
|
1287
1674
|
if self.frame_margin_var.get():
|
1288
1675
|
args["frame_spreadage"] = int(
|
1289
1676
|
round(self._parse_float(self.frame_margin_var.get(), "Frame margin"))
|
@@ -1319,6 +1706,19 @@ class TalksReducerGUI:
|
|
1319
1706
|
self._notify(lambda: self._set_status("Error"))
|
1320
1707
|
return False
|
1321
1708
|
|
1709
|
+
host_label = self._format_server_host(server_url)
|
1710
|
+
self._notify(
|
1711
|
+
lambda: self._set_status("waiting", f"Waiting server {host_label}...")
|
1712
|
+
)
|
1713
|
+
if not self._ping_server(server_url):
|
1714
|
+
self._append_log(f"Server unreachable: {server_url}")
|
1715
|
+
self._notify(
|
1716
|
+
lambda: self._set_status("Error", f"Server {host_label} unreachable")
|
1717
|
+
)
|
1718
|
+
return False
|
1719
|
+
|
1720
|
+
self._notify(lambda: self._set_status("waiting", f"Server {host_label} ready"))
|
1721
|
+
|
1322
1722
|
output_override = args.get("output_file") if len(files) == 1 else None
|
1323
1723
|
ignored = [key for key in args if key not in {"output_file", "small"}]
|
1324
1724
|
if ignored:
|
@@ -1327,33 +1727,52 @@ class TalksReducerGUI:
|
|
1327
1727
|
f"Server mode ignores the following options: {ignored_options}"
|
1328
1728
|
)
|
1329
1729
|
|
1730
|
+
small_mode = bool(args.get("small", False))
|
1731
|
+
|
1330
1732
|
for index, file in enumerate(files, start=1):
|
1331
1733
|
basename = os.path.basename(file)
|
1332
1734
|
self._append_log(
|
1333
1735
|
f"Uploading {index}/{len(files)}: {basename} to {server_url}"
|
1334
1736
|
)
|
1737
|
+
input_path = Path(file)
|
1738
|
+
|
1739
|
+
if output_override is not None:
|
1740
|
+
output_path = Path(output_override)
|
1741
|
+
if output_path.is_dir():
|
1742
|
+
output_path = (
|
1743
|
+
output_path
|
1744
|
+
/ _default_remote_destination(input_path, small=small_mode).name
|
1745
|
+
)
|
1746
|
+
else:
|
1747
|
+
output_path = _default_remote_destination(input_path, small=small_mode)
|
1748
|
+
|
1335
1749
|
try:
|
1336
1750
|
destination, summary, log_text = service_module.send_video(
|
1337
|
-
input_path=
|
1338
|
-
output_path=
|
1751
|
+
input_path=input_path,
|
1752
|
+
output_path=output_path,
|
1339
1753
|
server_url=server_url,
|
1340
|
-
small=
|
1754
|
+
small=small_mode,
|
1755
|
+
stream_updates=True,
|
1756
|
+
log_callback=self._append_log,
|
1757
|
+
# progress_callback=self._handle_service_progress,
|
1341
1758
|
)
|
1342
1759
|
except Exception as exc: # pragma: no cover - network safeguard
|
1343
|
-
|
1760
|
+
error_detail = f"{exc.__class__.__name__}: {exc}"
|
1761
|
+
error_msg = f"Processing failed: {error_detail}"
|
1344
1762
|
self._append_log(error_msg)
|
1345
1763
|
self._notify(lambda: self._set_status("Error"))
|
1346
1764
|
self._notify(
|
1347
1765
|
lambda: self.messagebox.showerror(
|
1348
1766
|
"Server error",
|
1349
|
-
f"Failed to process {basename}: {
|
1767
|
+
f"Failed to process {basename}: {error_detail}",
|
1350
1768
|
)
|
1351
1769
|
)
|
1352
1770
|
return False
|
1353
1771
|
|
1354
1772
|
self._last_output = Path(destination)
|
1355
|
-
|
1356
|
-
self.
|
1773
|
+
time_ratio, size_ratio = _parse_ratios_from_summary(summary)
|
1774
|
+
self._last_time_ratio = time_ratio
|
1775
|
+
self._last_size_ratio = size_ratio
|
1357
1776
|
for line in summary.splitlines():
|
1358
1777
|
self._append_log(line)
|
1359
1778
|
if log_text.strip():
|
@@ -1420,12 +1839,23 @@ class TalksReducerGUI:
|
|
1420
1839
|
|
1421
1840
|
def _update_status_from_message(self, message: str) -> None:
|
1422
1841
|
normalized = message.strip().lower()
|
1842
|
+
metadata_match = re.search(
|
1843
|
+
r"source metadata [—-] duration:\s*([\d.]+)s",
|
1844
|
+
message,
|
1845
|
+
re.IGNORECASE,
|
1846
|
+
)
|
1847
|
+
if metadata_match:
|
1848
|
+
try:
|
1849
|
+
self._source_duration_seconds = float(metadata_match.group(1))
|
1850
|
+
except ValueError:
|
1851
|
+
self._source_duration_seconds = None
|
1423
1852
|
if "all jobs finished successfully" in normalized:
|
1424
1853
|
# Create status message with ratios if available
|
1425
1854
|
status_msg = "Success"
|
1426
1855
|
if self._last_time_ratio is not None and self._last_size_ratio is not None:
|
1427
1856
|
status_msg = f"Time: {self._last_time_ratio:.0%}, Size: {self._last_size_ratio:.0%}"
|
1428
1857
|
|
1858
|
+
self._reset_audio_progress_state(clear_source=True)
|
1429
1859
|
self._set_status("success", status_msg)
|
1430
1860
|
self._set_progress(100) # 100% on success
|
1431
1861
|
self._video_duration_seconds = None # Reset for next video
|
@@ -1433,21 +1863,36 @@ class TalksReducerGUI:
|
|
1433
1863
|
self._encode_total_frames = None
|
1434
1864
|
self._encode_current_frame = None
|
1435
1865
|
elif normalized.startswith("extracting audio"):
|
1866
|
+
self._reset_audio_progress_state(clear_source=False)
|
1436
1867
|
self._set_status("processing", "Extracting audio...")
|
1437
1868
|
self._set_progress(0) # 0% on start
|
1438
1869
|
self._video_duration_seconds = None # Reset for new processing
|
1439
1870
|
self._encode_target_duration_seconds = None
|
1440
1871
|
self._encode_total_frames = None
|
1441
1872
|
self._encode_current_frame = None
|
1442
|
-
|
1443
|
-
|
1444
|
-
|
1873
|
+
self._start_audio_progress()
|
1874
|
+
elif normalized.startswith("uploading"):
|
1875
|
+
self._set_status("processing", "Uploading...")
|
1876
|
+
elif normalized.startswith("starting processing"):
|
1877
|
+
self._reset_audio_progress_state(clear_source=True)
|
1445
1878
|
self._set_status("processing", "Processing")
|
1446
1879
|
self._set_progress(0) # 0% on start
|
1447
1880
|
self._video_duration_seconds = None # Reset for new processing
|
1448
1881
|
self._encode_target_duration_seconds = None
|
1449
1882
|
self._encode_total_frames = None
|
1450
1883
|
self._encode_current_frame = None
|
1884
|
+
elif normalized.startswith("processing"):
|
1885
|
+
is_new_job = bool(re.match(r"processing \d+/\d+:", normalized))
|
1886
|
+
should_reset = self._status_state.lower() != "processing" or is_new_job
|
1887
|
+
if should_reset:
|
1888
|
+
self._set_progress(0) # 0% on start
|
1889
|
+
self._video_duration_seconds = None # Reset for new processing
|
1890
|
+
self._encode_target_duration_seconds = None
|
1891
|
+
self._encode_total_frames = None
|
1892
|
+
self._encode_current_frame = None
|
1893
|
+
if is_new_job:
|
1894
|
+
self._reset_audio_progress_state(clear_source=True)
|
1895
|
+
self._set_status("processing", "Processing")
|
1451
1896
|
|
1452
1897
|
frame_total_match = re.search(
|
1453
1898
|
r"Final encode target frames(?: \(fallback\))?:\s*(\d+)", message
|
@@ -1473,12 +1918,12 @@ class TalksReducerGUI:
|
|
1473
1918
|
|
1474
1919
|
self._encode_current_frame = current_frame
|
1475
1920
|
if self._encode_total_frames and self._encode_total_frames > 0:
|
1476
|
-
|
1477
|
-
|
1478
|
-
|
1479
|
-
)
|
1921
|
+
self._complete_audio_phase()
|
1922
|
+
frame_ratio = min(current_frame / self._encode_total_frames, 1.0)
|
1923
|
+
percentage = min(100, 5 + int(frame_ratio * 95))
|
1480
1924
|
self._set_progress(percentage)
|
1481
1925
|
else:
|
1926
|
+
self._complete_audio_phase()
|
1482
1927
|
self._set_status("processing", f"{current_frame} frames encoded")
|
1483
1928
|
|
1484
1929
|
# Parse encode target duration reported by the pipeline
|
@@ -1537,11 +1982,95 @@ class TalksReducerGUI:
|
|
1537
1982
|
and total_seconds
|
1538
1983
|
and total_seconds > 0
|
1539
1984
|
):
|
1540
|
-
|
1985
|
+
self._complete_audio_phase()
|
1986
|
+
time_ratio = min(current_seconds / total_seconds, 1.0)
|
1987
|
+
percentage = min(100, 5 + int(time_ratio * 95))
|
1541
1988
|
self._set_progress(percentage)
|
1542
1989
|
|
1543
1990
|
self._set_status("processing", status_msg)
|
1544
1991
|
|
1992
|
+
def _compute_audio_progress_interval(self) -> int:
|
1993
|
+
duration = self._source_duration_seconds or self._video_duration_seconds
|
1994
|
+
if duration and duration > 0:
|
1995
|
+
audio_seconds = max(duration * self.AUDIO_PROCESSING_RATIO, 0.0)
|
1996
|
+
interval_seconds = audio_seconds / self.AUDIO_PROGRESS_STEPS
|
1997
|
+
interval_ms = int(round(interval_seconds * 1000))
|
1998
|
+
return max(self.MIN_AUDIO_INTERVAL_MS, interval_ms)
|
1999
|
+
return self.DEFAULT_AUDIO_INTERVAL_MS
|
2000
|
+
|
2001
|
+
def _start_audio_progress(self) -> None:
|
2002
|
+
interval_ms = self._compute_audio_progress_interval()
|
2003
|
+
|
2004
|
+
def _start() -> None:
|
2005
|
+
if self._audio_progress_job is not None:
|
2006
|
+
self.root.after_cancel(self._audio_progress_job)
|
2007
|
+
self._audio_progress_steps_completed = 0
|
2008
|
+
self._audio_progress_interval_ms = interval_ms
|
2009
|
+
self._audio_progress_job = self.root.after(
|
2010
|
+
interval_ms, self._advance_audio_progress
|
2011
|
+
)
|
2012
|
+
|
2013
|
+
self._notify(_start)
|
2014
|
+
|
2015
|
+
def _advance_audio_progress(self) -> None:
|
2016
|
+
self._audio_progress_job = None
|
2017
|
+
if self._audio_progress_steps_completed >= self.AUDIO_PROGRESS_STEPS:
|
2018
|
+
self._audio_progress_interval_ms = None
|
2019
|
+
return
|
2020
|
+
|
2021
|
+
self._audio_progress_steps_completed += 1
|
2022
|
+
audio_percentage = (
|
2023
|
+
self._audio_progress_steps_completed / self.AUDIO_PROGRESS_STEPS * 100
|
2024
|
+
)
|
2025
|
+
percentage = (audio_percentage / 100) * 5
|
2026
|
+
self._set_progress(percentage)
|
2027
|
+
self._set_status("processing", "Audio processing: %d%%" % (audio_percentage))
|
2028
|
+
|
2029
|
+
if self._audio_progress_steps_completed < self.AUDIO_PROGRESS_STEPS:
|
2030
|
+
interval_ms = (
|
2031
|
+
self._audio_progress_interval_ms or self.DEFAULT_AUDIO_INTERVAL_MS
|
2032
|
+
)
|
2033
|
+
self._audio_progress_job = self.root.after(
|
2034
|
+
interval_ms, self._advance_audio_progress
|
2035
|
+
)
|
2036
|
+
else:
|
2037
|
+
self._audio_progress_interval_ms = None
|
2038
|
+
|
2039
|
+
def _cancel_audio_progress(self) -> None:
|
2040
|
+
if self._audio_progress_job is None:
|
2041
|
+
self._audio_progress_interval_ms = None
|
2042
|
+
return
|
2043
|
+
|
2044
|
+
def _cancel() -> None:
|
2045
|
+
if self._audio_progress_job is not None:
|
2046
|
+
self.root.after_cancel(self._audio_progress_job)
|
2047
|
+
self._audio_progress_job = None
|
2048
|
+
self._audio_progress_interval_ms = None
|
2049
|
+
|
2050
|
+
self._notify(_cancel)
|
2051
|
+
|
2052
|
+
def _reset_audio_progress_state(self, *, clear_source: bool) -> None:
|
2053
|
+
if clear_source:
|
2054
|
+
self._source_duration_seconds = None
|
2055
|
+
self._audio_progress_steps_completed = 0
|
2056
|
+
self._audio_progress_interval_ms = None
|
2057
|
+
if self._audio_progress_job is not None:
|
2058
|
+
self._cancel_audio_progress()
|
2059
|
+
|
2060
|
+
def _complete_audio_phase(self) -> None:
|
2061
|
+
def _complete() -> None:
|
2062
|
+
if self._audio_progress_job is not None:
|
2063
|
+
self.root.after_cancel(self._audio_progress_job)
|
2064
|
+
self._audio_progress_job = None
|
2065
|
+
self._audio_progress_interval_ms = None
|
2066
|
+
if self._audio_progress_steps_completed < self.AUDIO_PROGRESS_STEPS:
|
2067
|
+
self._audio_progress_steps_completed = self.AUDIO_PROGRESS_STEPS
|
2068
|
+
current_value = self.progress_var.get()
|
2069
|
+
if current_value < self.AUDIO_PROGRESS_STEPS:
|
2070
|
+
self._set_progress(self.AUDIO_PROGRESS_STEPS)
|
2071
|
+
|
2072
|
+
self._notify(_complete)
|
2073
|
+
|
1545
2074
|
def _apply_status_style(self, status: str) -> None:
|
1546
2075
|
color = STATUS_COLORS.get(status.lower())
|
1547
2076
|
if color:
|
@@ -1580,6 +2109,8 @@ class TalksReducerGUI:
|
|
1580
2109
|
self.status_frame.grid()
|
1581
2110
|
self.stop_button.grid()
|
1582
2111
|
self.drop_hint_button.grid_remove()
|
2112
|
+
else:
|
2113
|
+
self._reset_audio_progress_state(clear_source=True)
|
1583
2114
|
|
1584
2115
|
if lowered == "success" or "time:" in lowered and "size:" in lowered:
|
1585
2116
|
if self.simple_mode_var.get() and hasattr(self, "status_frame"):
|
@@ -1592,12 +2123,7 @@ class TalksReducerGUI:
|
|
1592
2123
|
else:
|
1593
2124
|
self.open_button.grid_remove()
|
1594
2125
|
# print("not success status")
|
1595
|
-
if (
|
1596
|
-
self.simple_mode_var.get()
|
1597
|
-
and not is_processing
|
1598
|
-
and hasattr(self, "status_frame")
|
1599
|
-
):
|
1600
|
-
self.status_frame.grid_remove()
|
2126
|
+
if self.simple_mode_var.get() and not is_processing:
|
1601
2127
|
self.stop_button.grid_remove()
|
1602
2128
|
# Show drop hint when no other buttons are visible
|
1603
2129
|
if hasattr(self, "drop_hint_button"):
|