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/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 TYPE_CHECKING, Any, Callable, Iterable, List, Optional, Sequence
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
- for column in range(5):
416
- input_frame.columnconfigure(column, weight=1)
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=1, column=0, columnspan=5, sticky="nsew")
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
- options = self.ttk.Frame(main, padding=self.PADDING)
465
- options.grid(row=2, column=0, pady=(0, 0), sticky="ew")
466
- options.columnconfigure(0, weight=1)
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(options)
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
- self.advanced_button = self.ttk.Button(
495
- options,
496
- text="Advanced",
497
- command=self._toggle_advanced,
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.advanced_frame = self.ttk.Frame(options, padding=self.PADDING)
502
- self.advanced_frame.grid(row=2, column=0, columnspan=2, sticky="nsew")
503
- self.advanced_frame.columnconfigure(1, weight=1)
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.output_var = self.tk.StringVar()
506
- self._add_entry(
507
- self.advanced_frame, "Output file", self.output_var, row=0, browse=True
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.temp_var = self.tk.StringVar(value=str(default_temp_folder()))
511
- self._add_entry(
512
- self.advanced_frame, "Temp folder", self.temp_var, row=1, browse=True
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.StringVar()
516
- self._add_entry(
517
- self.advanced_frame,
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.sounded_speed_var = self.tk.StringVar()
524
- self._add_entry(
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
- self.silent_speed_var = self.tk.StringVar()
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
- self.frame_margin_var = self.tk.StringVar()
534
- self._add_entry(
535
- self.advanced_frame, "Frame margin", self.frame_margin_var, row=5
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.sample_rate_var = self.tk.StringVar(value="48000")
539
- self._add_entry(self.advanced_frame, "Sample rate", self.sample_rate_var, row=6)
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.advanced_frame, text="Theme").grid(
542
- row=7, column=0, sticky="w", pady=(8, 0)
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.advanced_frame)
545
- theme_choice.grid(row=7, column=1, columnspan=2, sticky="w", pady=(8, 0))
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
- for widget in widgets:
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
- for widget in widgets:
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 from the list."""
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
- self.stop_button.grid()
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
- if self.silent_threshold_var.get():
1133
- args["silent_threshold"] = self._parse_float(
1134
- self.silent_threshold_var.get(), "Silent threshold"
1135
- )
1136
- if self.sounded_speed_var.get():
1137
- args["sounded_speed"] = self._parse_float(
1138
- self.sounded_speed_var.get(), "Sounded speed"
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
- elif normalized.startswith("starting processing") or normalized.startswith(
1226
- "processing"
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
- percentage = min(
1260
- 100,
1261
- int((current_frame / self._encode_total_frames) * 100),
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
- percentage = min(100, int((current_seconds / total_seconds) * 100))
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: