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/gui.py CHANGED
@@ -10,9 +10,20 @@ import re
10
10
  import subprocess
11
11
  import sys
12
12
  import threading
13
- from importlib.metadata import version
13
+ import urllib.error
14
+ import urllib.parse
15
+ import urllib.request
14
16
  from pathlib import Path
15
- from typing import TYPE_CHECKING, Any, Callable, Iterable, List, Optional, Sequence
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
- command = [sys.executable, "-m", "talks_reducer.server_tray"]
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
- try:
316
- app_version = version("talks-reducer")
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
- except Exception:
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
- for column in range(5):
424
- input_frame.columnconfigure(column, weight=1)
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=1, column=0, columnspan=5, sticky="nsew")
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
- options = self.ttk.Frame(main, padding=self.PADDING)
473
- options.grid(row=2, column=0, pady=(0, 0), sticky="ew")
474
- options.columnconfigure(0, weight=1)
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(options)
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
- self.advanced_frame = self.ttk.Frame(options, padding=self.PADDING)
510
- self.advanced_frame.grid(row=2, column=0, columnspan=2, sticky="nsew")
511
- self.advanced_frame.columnconfigure(1, weight=1)
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.output_var = self.tk.StringVar()
514
- self._add_entry(
515
- self.advanced_frame, "Output file", self.output_var, row=0, browse=True
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.temp_var = self.tk.StringVar(value=str(default_temp_folder()))
519
- self._add_entry(
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
- self.silent_threshold_var = self.tk.StringVar()
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.sounded_speed_var = self.tk.StringVar()
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.StringVar()
537
- self._add_entry(
538
- self.advanced_frame, "Silent speed", self.silent_speed_var, row=4
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.frame_margin_var = self.tk.StringVar()
542
- self._add_entry(
543
- self.advanced_frame, "Frame margin", self.frame_margin_var, row=5
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.sample_rate_var = self.tk.StringVar(value="48000")
547
- self._add_entry(self.advanced_frame, "Sample rate", self.sample_rate_var, row=6)
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.advanced_frame, text="Server URL").grid(
550
- row=7, column=0, sticky="w", pady=4
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.advanced_frame, textvariable=self.server_url_var
693
+ self.basic_options_frame, textvariable=self.server_url_var, width=30
554
694
  )
555
- self.server_url_entry.grid(row=7, column=1, sticky="ew", pady=4)
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.advanced_frame, text="Discover", command=self._start_discovery
558
- )
559
- self.server_discover_button.grid(row=7, column=2, padx=(8, 0))
560
-
561
- self.ttk.Label(self.advanced_frame, text="Theme").grid(
562
- row=8, column=0, sticky="w", pady=(8, 0)
563
- )
564
- theme_choice = self.ttk.Frame(self.advanced_frame)
565
- theme_choice.grid(row=8, column=1, columnspan=2, sticky="w", pady=(8, 0))
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
- for widget in widgets:
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
- for widget in widgets:
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=6,
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
- self.input_list.configure(
976
- bg=palette["surface"],
977
- fg=palette["foreground"],
978
- selectbackground=palette.get("selection_background", palette["accent"]),
979
- selectforeground=palette.get("selection_foreground", palette["surface"]),
980
- highlightbackground=palette["border"],
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 from the list."""
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 server_url:
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 server_url:
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
- if self.silent_threshold_var.get():
1276
- args["silent_threshold"] = self._parse_float(
1277
- self.silent_threshold_var.get(), "Silent threshold"
1278
- )
1279
- if self.sounded_speed_var.get():
1280
- args["sounded_speed"] = self._parse_float(
1281
- self.sounded_speed_var.get(), "Sounded speed"
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=Path(file),
1338
- output_path=output_override,
1751
+ input_path=input_path,
1752
+ output_path=output_path,
1339
1753
  server_url=server_url,
1340
- small=bool(args.get("small", False)),
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
- error_msg = f"Processing failed: {exc}"
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}: {exc}",
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
- self._last_time_ratio = None
1356
- self._last_size_ratio = None
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
- elif normalized.startswith("starting processing") or normalized.startswith(
1443
- "processing"
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
- percentage = min(
1477
- 100,
1478
- int((current_frame / self._encode_total_frames) * 100),
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
- percentage = min(100, int((current_seconds / total_seconds) * 100))
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"):