talks-reducer 0.7.1__py3-none-any.whl → 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- talks_reducer/__about__.py +1 -1
- talks_reducer/cli.py +225 -181
- talks_reducer/discovery.py +78 -22
- talks_reducer/gui/__init__.py +17 -1546
- talks_reducer/gui/__main__.py +1 -1
- talks_reducer/gui/app.py +1385 -0
- talks_reducer/gui/discovery.py +1 -1
- talks_reducer/gui/layout.py +18 -31
- talks_reducer/gui/progress.py +80 -0
- talks_reducer/gui/remote.py +11 -3
- talks_reducer/gui/startup.py +202 -0
- talks_reducer/icons.py +123 -0
- talks_reducer/pipeline.py +65 -31
- talks_reducer/server.py +111 -47
- talks_reducer/server_tray.py +192 -236
- talks_reducer/service_client.py +77 -14
- {talks_reducer-0.7.1.dist-info → talks_reducer-0.8.0.dist-info}/METADATA +24 -2
- talks_reducer-0.8.0.dist-info/RECORD +33 -0
- talks_reducer-0.7.1.dist-info/RECORD +0 -29
- {talks_reducer-0.7.1.dist-info → talks_reducer-0.8.0.dist-info}/WHEEL +0 -0
- {talks_reducer-0.7.1.dist-info → talks_reducer-0.8.0.dist-info}/entry_points.txt +0 -0
- {talks_reducer-0.7.1.dist-info → talks_reducer-0.8.0.dist-info}/licenses/LICENSE +0 -0
- {talks_reducer-0.7.1.dist-info → talks_reducer-0.8.0.dist-info}/top_level.txt +0 -0
talks_reducer/gui/app.py
ADDED
@@ -0,0 +1,1385 @@
|
|
1
|
+
"""Tkinter GUI application for the talks reducer pipeline."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import os
|
6
|
+
import re
|
7
|
+
import subprocess
|
8
|
+
import sys
|
9
|
+
import threading
|
10
|
+
import time
|
11
|
+
from pathlib import Path
|
12
|
+
from typing import (
|
13
|
+
TYPE_CHECKING,
|
14
|
+
Any,
|
15
|
+
Callable,
|
16
|
+
Iterable,
|
17
|
+
List,
|
18
|
+
Optional,
|
19
|
+
Sequence,
|
20
|
+
Tuple,
|
21
|
+
)
|
22
|
+
|
23
|
+
if TYPE_CHECKING:
|
24
|
+
import tkinter as tk
|
25
|
+
from tkinter import filedialog, messagebox, ttk
|
26
|
+
|
27
|
+
try:
|
28
|
+
from ..cli import gather_input_files
|
29
|
+
from ..ffmpeg import FFmpegNotFoundError
|
30
|
+
from ..models import ProcessingOptions
|
31
|
+
from ..pipeline import ProcessingAborted, speed_up_video
|
32
|
+
from ..progress import ProgressHandle
|
33
|
+
from ..version_utils import resolve_version
|
34
|
+
from . import discovery as discovery_helpers
|
35
|
+
from . import layout as layout_helpers
|
36
|
+
from .preferences import GUIPreferences, determine_config_path
|
37
|
+
from .progress import _TkProgressReporter
|
38
|
+
from .remote import (
|
39
|
+
check_remote_server_for_gui,
|
40
|
+
format_server_host,
|
41
|
+
normalize_server_url,
|
42
|
+
ping_server,
|
43
|
+
process_files_via_server,
|
44
|
+
)
|
45
|
+
from .theme import (
|
46
|
+
DARK_THEME,
|
47
|
+
LIGHT_THEME,
|
48
|
+
STATUS_COLORS,
|
49
|
+
apply_theme,
|
50
|
+
detect_system_theme,
|
51
|
+
read_windows_theme_registry,
|
52
|
+
run_defaults_command,
|
53
|
+
)
|
54
|
+
except ImportError: # pragma: no cover - handled at runtime
|
55
|
+
if __package__ not in (None, ""):
|
56
|
+
raise
|
57
|
+
|
58
|
+
PACKAGE_ROOT = Path(__file__).resolve().parent.parent
|
59
|
+
if str(PACKAGE_ROOT) not in sys.path:
|
60
|
+
sys.path.insert(0, str(PACKAGE_ROOT))
|
61
|
+
|
62
|
+
from talks_reducer.cli import gather_input_files
|
63
|
+
from talks_reducer.ffmpeg import FFmpegNotFoundError
|
64
|
+
from talks_reducer.gui import discovery as discovery_helpers
|
65
|
+
from talks_reducer.gui import layout as layout_helpers
|
66
|
+
from talks_reducer.gui.preferences import GUIPreferences, determine_config_path
|
67
|
+
from talks_reducer.gui.progress import _TkProgressReporter
|
68
|
+
from talks_reducer.gui.remote import (
|
69
|
+
check_remote_server_for_gui,
|
70
|
+
format_server_host,
|
71
|
+
normalize_server_url,
|
72
|
+
ping_server,
|
73
|
+
process_files_via_server,
|
74
|
+
)
|
75
|
+
from talks_reducer.gui.theme import (
|
76
|
+
DARK_THEME,
|
77
|
+
LIGHT_THEME,
|
78
|
+
STATUS_COLORS,
|
79
|
+
apply_theme,
|
80
|
+
detect_system_theme,
|
81
|
+
read_windows_theme_registry,
|
82
|
+
run_defaults_command,
|
83
|
+
)
|
84
|
+
from talks_reducer.models import ProcessingOptions
|
85
|
+
from talks_reducer.pipeline import ProcessingAborted, speed_up_video
|
86
|
+
from talks_reducer.progress import ProgressHandle
|
87
|
+
from talks_reducer.version_utils import resolve_version
|
88
|
+
|
89
|
+
try:
|
90
|
+
from tkinterdnd2 import DND_FILES, TkinterDnD
|
91
|
+
except ModuleNotFoundError: # pragma: no cover - runtime dependency
|
92
|
+
DND_FILES = None # type: ignore[assignment]
|
93
|
+
TkinterDnD = None # type: ignore[assignment]
|
94
|
+
|
95
|
+
|
96
|
+
def _default_remote_destination(input_file: Path, *, small: bool) -> Path:
|
97
|
+
"""Return the default remote output path for *input_file*."""
|
98
|
+
|
99
|
+
name = input_file.name
|
100
|
+
dot_index = name.rfind(".")
|
101
|
+
suffix = "_speedup_small" if small else "_speedup"
|
102
|
+
|
103
|
+
if dot_index != -1:
|
104
|
+
new_name = name[:dot_index] + suffix + name[dot_index:]
|
105
|
+
else:
|
106
|
+
new_name = name + suffix
|
107
|
+
|
108
|
+
return input_file.with_name(new_name)
|
109
|
+
|
110
|
+
|
111
|
+
def _parse_ratios_from_summary(summary: str) -> Tuple[Optional[float], Optional[float]]:
|
112
|
+
"""Extract time and size ratios from a Markdown *summary* string."""
|
113
|
+
|
114
|
+
time_ratio: Optional[float] = None
|
115
|
+
size_ratio: Optional[float] = None
|
116
|
+
|
117
|
+
for line in summary.splitlines():
|
118
|
+
if "**Duration:**" in line:
|
119
|
+
match = re.search(r"—\s*([0-9]+(?:\.[0-9]+)?)% of the original", line)
|
120
|
+
if match:
|
121
|
+
try:
|
122
|
+
time_ratio = float(match.group(1)) / 100
|
123
|
+
except ValueError:
|
124
|
+
time_ratio = None
|
125
|
+
elif "**Size:**" in line:
|
126
|
+
match = re.search(r"\*\*Size:\*\*\s*([0-9]+(?:\.[0-9]+)?)%", line)
|
127
|
+
if match:
|
128
|
+
try:
|
129
|
+
size_ratio = float(match.group(1)) / 100
|
130
|
+
except ValueError:
|
131
|
+
size_ratio = None
|
132
|
+
|
133
|
+
return time_ratio, size_ratio
|
134
|
+
|
135
|
+
|
136
|
+
def _parse_source_duration_seconds(message: str) -> tuple[bool, Optional[float]]:
|
137
|
+
"""Return whether *message* includes source duration metadata."""
|
138
|
+
|
139
|
+
metadata_match = re.search(
|
140
|
+
r"source metadata: duration:\s*([\d.]+)s",
|
141
|
+
message,
|
142
|
+
re.IGNORECASE,
|
143
|
+
)
|
144
|
+
if not metadata_match:
|
145
|
+
return False, None
|
146
|
+
|
147
|
+
try:
|
148
|
+
return True, float(metadata_match.group(1))
|
149
|
+
except ValueError:
|
150
|
+
return True, None
|
151
|
+
|
152
|
+
|
153
|
+
def _parse_encode_total_frames(message: str) -> tuple[bool, Optional[int]]:
|
154
|
+
"""Extract final encode frame totals from *message* when present."""
|
155
|
+
|
156
|
+
frame_total_match = re.search(
|
157
|
+
r"Final encode target frames(?: \(fallback\))?:\s*(\d+)", message
|
158
|
+
)
|
159
|
+
if not frame_total_match:
|
160
|
+
return False, None
|
161
|
+
|
162
|
+
try:
|
163
|
+
return True, int(frame_total_match.group(1))
|
164
|
+
except ValueError:
|
165
|
+
return True, None
|
166
|
+
|
167
|
+
|
168
|
+
def _is_encode_total_frames_unknown(normalized_message: str) -> bool:
|
169
|
+
"""Return ``True`` if *normalized_message* marks encode frame totals unknown."""
|
170
|
+
|
171
|
+
return (
|
172
|
+
"final encode target frames" in normalized_message
|
173
|
+
and "unknown" in normalized_message
|
174
|
+
)
|
175
|
+
|
176
|
+
|
177
|
+
def _parse_current_frame(message: str) -> tuple[bool, Optional[int]]:
|
178
|
+
"""Extract the current encode frame from *message* when available."""
|
179
|
+
|
180
|
+
frame_match = re.search(r"frame=\s*(\d+)", message)
|
181
|
+
if not frame_match:
|
182
|
+
return False, None
|
183
|
+
|
184
|
+
try:
|
185
|
+
return True, int(frame_match.group(1))
|
186
|
+
except ValueError:
|
187
|
+
return True, None
|
188
|
+
|
189
|
+
|
190
|
+
def _parse_encode_target_duration(message: str) -> tuple[bool, Optional[float]]:
|
191
|
+
"""Extract encode target duration from *message* if reported."""
|
192
|
+
|
193
|
+
encode_duration_match = re.search(
|
194
|
+
r"Final encode target duration(?: \(fallback\))?:\s*([\d.]+)s",
|
195
|
+
message,
|
196
|
+
)
|
197
|
+
if not encode_duration_match:
|
198
|
+
return False, None
|
199
|
+
|
200
|
+
try:
|
201
|
+
return True, float(encode_duration_match.group(1))
|
202
|
+
except ValueError:
|
203
|
+
return True, None
|
204
|
+
|
205
|
+
|
206
|
+
def _is_encode_target_duration_unknown(normalized_message: str) -> bool:
|
207
|
+
"""Return ``True`` if encode target duration is reported as unknown."""
|
208
|
+
|
209
|
+
return (
|
210
|
+
"final encode target duration" in normalized_message
|
211
|
+
and "unknown" in normalized_message
|
212
|
+
)
|
213
|
+
|
214
|
+
|
215
|
+
def _parse_video_duration_seconds(message: str) -> tuple[bool, Optional[float]]:
|
216
|
+
"""Parse the input video duration from *message* when FFmpeg prints it."""
|
217
|
+
|
218
|
+
duration_match = re.search(r"Duration:\s*(\d{2}):(\d{2}):(\d{2}\.\d+)", message)
|
219
|
+
if not duration_match:
|
220
|
+
return False, None
|
221
|
+
|
222
|
+
try:
|
223
|
+
hours = int(duration_match.group(1))
|
224
|
+
minutes = int(duration_match.group(2))
|
225
|
+
seconds = float(duration_match.group(3))
|
226
|
+
except ValueError:
|
227
|
+
return True, None
|
228
|
+
|
229
|
+
total_seconds = hours * 3600 + minutes * 60 + seconds
|
230
|
+
return True, total_seconds
|
231
|
+
|
232
|
+
|
233
|
+
def _parse_ffmpeg_progress(message: str) -> tuple[bool, Optional[tuple[int, str]]]:
|
234
|
+
"""Parse FFmpeg progress information from *message* if available."""
|
235
|
+
|
236
|
+
time_match = re.search(r"time=(\d{2}):(\d{2}):(\d{2})\.\d+", message)
|
237
|
+
speed_match = re.search(r"speed=\s*([\d.]+)x", message)
|
238
|
+
|
239
|
+
if not (time_match and speed_match):
|
240
|
+
return False, None
|
241
|
+
|
242
|
+
try:
|
243
|
+
hours = int(time_match.group(1))
|
244
|
+
minutes = int(time_match.group(2))
|
245
|
+
seconds = int(time_match.group(3))
|
246
|
+
except ValueError:
|
247
|
+
return True, None
|
248
|
+
|
249
|
+
current_seconds = hours * 3600 + minutes * 60 + seconds
|
250
|
+
speed_str = speed_match.group(1)
|
251
|
+
return True, (current_seconds, speed_str)
|
252
|
+
|
253
|
+
|
254
|
+
class TalksReducerGUI:
|
255
|
+
"""Tkinter application mirroring the CLI options with form controls."""
|
256
|
+
|
257
|
+
PADDING = 10
|
258
|
+
AUDIO_PROCESSING_RATIO = 0.02
|
259
|
+
AUDIO_PROGRESS_STEPS = 20
|
260
|
+
MIN_AUDIO_INTERVAL_MS = 10
|
261
|
+
DEFAULT_AUDIO_INTERVAL_MS = 200
|
262
|
+
|
263
|
+
def __init__(
|
264
|
+
self,
|
265
|
+
initial_inputs: Optional[Sequence[str]] = None,
|
266
|
+
*,
|
267
|
+
auto_run: bool = False,
|
268
|
+
) -> None:
|
269
|
+
self._config_path = determine_config_path()
|
270
|
+
self.preferences = GUIPreferences(self._config_path)
|
271
|
+
|
272
|
+
# Import tkinter here to avoid loading it at module import time
|
273
|
+
import tkinter as tk
|
274
|
+
from tkinter import filedialog, messagebox, ttk
|
275
|
+
|
276
|
+
# Store references for use in methods
|
277
|
+
self.tk = tk
|
278
|
+
self.filedialog = filedialog
|
279
|
+
self.messagebox = messagebox
|
280
|
+
self.ttk = ttk
|
281
|
+
|
282
|
+
if TkinterDnD is not None:
|
283
|
+
self.root = TkinterDnD.Tk() # type: ignore[call-arg]
|
284
|
+
else:
|
285
|
+
self.root = tk.Tk()
|
286
|
+
|
287
|
+
# Set window title with version information
|
288
|
+
app_version = resolve_version()
|
289
|
+
if app_version and app_version != "unknown":
|
290
|
+
self.root.title(f"Talks Reducer v{app_version}")
|
291
|
+
else:
|
292
|
+
self.root.title("Talks Reducer")
|
293
|
+
|
294
|
+
self._apply_window_icon()
|
295
|
+
|
296
|
+
self._full_size = (1000, 800)
|
297
|
+
self._simple_size = (300, 270)
|
298
|
+
# self.root.geometry(f"{self._full_size[0]}x{self._full_size[1]}")
|
299
|
+
self.style = self.ttk.Style(self.root)
|
300
|
+
|
301
|
+
self._processing_thread: Optional[threading.Thread] = None
|
302
|
+
self._last_output: Optional[Path] = None
|
303
|
+
self._last_time_ratio: Optional[float] = None
|
304
|
+
self._last_size_ratio: Optional[float] = None
|
305
|
+
self._last_progress_seconds: Optional[int] = None
|
306
|
+
self._run_start_time: Optional[float] = None
|
307
|
+
self._status_state = "Idle"
|
308
|
+
self.status_var = tk.StringVar(value=self._status_state)
|
309
|
+
self._status_animation_job: Optional[str] = None
|
310
|
+
self._status_animation_phase = 0
|
311
|
+
self._video_duration_seconds: Optional[float] = None
|
312
|
+
self._encode_target_duration_seconds: Optional[float] = None
|
313
|
+
self._encode_total_frames: Optional[int] = None
|
314
|
+
self._encode_current_frame: Optional[int] = None
|
315
|
+
self._source_duration_seconds: Optional[float] = None
|
316
|
+
self._audio_progress_job: Optional[str] = None
|
317
|
+
self._audio_progress_interval_ms: Optional[int] = None
|
318
|
+
self._audio_progress_steps_completed = 0
|
319
|
+
self.progress_var = tk.IntVar(value=0)
|
320
|
+
self._ffmpeg_process: Optional[subprocess.Popen] = None
|
321
|
+
self._stop_requested = False
|
322
|
+
self._ping_worker_stop_requested = False
|
323
|
+
self._current_remote_mode = False
|
324
|
+
|
325
|
+
self.input_files: List[str] = []
|
326
|
+
|
327
|
+
self._dnd_available = TkinterDnD is not None and DND_FILES is not None
|
328
|
+
|
329
|
+
self.simple_mode_var = tk.BooleanVar(
|
330
|
+
value=self.preferences.get("simple_mode", True)
|
331
|
+
)
|
332
|
+
self.run_after_drop_var = tk.BooleanVar(value=True)
|
333
|
+
self.small_var = tk.BooleanVar(value=self.preferences.get("small_video", True))
|
334
|
+
self.open_after_convert_var = tk.BooleanVar(
|
335
|
+
value=self.preferences.get("open_after_convert", True)
|
336
|
+
)
|
337
|
+
stored_mode = str(self.preferences.get("processing_mode", "local"))
|
338
|
+
if stored_mode not in {"local", "remote"}:
|
339
|
+
stored_mode = "local"
|
340
|
+
self.processing_mode_var = tk.StringVar(value=stored_mode)
|
341
|
+
self.processing_mode_var.trace_add("write", self._on_processing_mode_change)
|
342
|
+
self.theme_var = tk.StringVar(value=self.preferences.get("theme", "os"))
|
343
|
+
self.theme_var.trace_add("write", self._on_theme_change)
|
344
|
+
self.small_var.trace_add("write", self._on_small_video_change)
|
345
|
+
self.open_after_convert_var.trace_add(
|
346
|
+
"write", self._on_open_after_convert_change
|
347
|
+
)
|
348
|
+
self.server_url_var = tk.StringVar(
|
349
|
+
value=str(self.preferences.get("server_url", ""))
|
350
|
+
)
|
351
|
+
self.server_url_var.trace_add("write", self._on_server_url_change)
|
352
|
+
self._discovery_thread: Optional[threading.Thread] = None
|
353
|
+
|
354
|
+
self._basic_defaults: dict[str, float] = {}
|
355
|
+
self._basic_variables: dict[str, tk.DoubleVar] = {}
|
356
|
+
self._slider_updaters: dict[str, Callable[[str], None]] = {}
|
357
|
+
self._sliders: list[tk.Scale] = []
|
358
|
+
|
359
|
+
self._build_layout()
|
360
|
+
self._apply_simple_mode(initial=True)
|
361
|
+
self._apply_status_style(self._status_state)
|
362
|
+
self._refresh_theme()
|
363
|
+
self.preferences.save()
|
364
|
+
self._hide_stop_button()
|
365
|
+
|
366
|
+
# Ping server on startup if in remote mode
|
367
|
+
if (
|
368
|
+
self.processing_mode_var.get() == "remote"
|
369
|
+
and self.server_url_var.get().strip()
|
370
|
+
):
|
371
|
+
server_url = self.server_url_var.get().strip()
|
372
|
+
|
373
|
+
def ping_worker() -> None:
|
374
|
+
try:
|
375
|
+
self._check_remote_server(
|
376
|
+
server_url,
|
377
|
+
success_status="Idle",
|
378
|
+
waiting_status="Error",
|
379
|
+
failure_status="Error",
|
380
|
+
stop_check=lambda: self._ping_worker_stop_requested,
|
381
|
+
switch_to_local_on_failure=True,
|
382
|
+
)
|
383
|
+
except Exception as exc: # pragma: no cover - defensive safeguard
|
384
|
+
host_label = self._format_server_host(server_url)
|
385
|
+
message = f"Error pinging server {host_label}: {exc}"
|
386
|
+
self._schedule_on_ui_thread(
|
387
|
+
lambda msg=message: self._append_log(msg)
|
388
|
+
)
|
389
|
+
self._schedule_on_ui_thread(
|
390
|
+
lambda msg=message: self._set_status("Idle", msg)
|
391
|
+
)
|
392
|
+
|
393
|
+
threading.Thread(target=ping_worker, daemon=True).start()
|
394
|
+
|
395
|
+
if not self._dnd_available:
|
396
|
+
self._append_log(
|
397
|
+
"Drag and drop requires the tkinterdnd2 package. Install it to enable the drop zone."
|
398
|
+
)
|
399
|
+
|
400
|
+
if initial_inputs:
|
401
|
+
self._populate_initial_inputs(initial_inputs, auto_run=auto_run)
|
402
|
+
|
403
|
+
def _start_run(self) -> None:
|
404
|
+
if self._processing_thread and self._processing_thread.is_alive():
|
405
|
+
self.messagebox.showinfo("Processing", "A job is already running.")
|
406
|
+
return
|
407
|
+
|
408
|
+
if not self.input_files:
|
409
|
+
self.messagebox.showwarning(
|
410
|
+
"Missing input", "Please add at least one file or folder."
|
411
|
+
)
|
412
|
+
return
|
413
|
+
|
414
|
+
try:
|
415
|
+
args = self._collect_arguments()
|
416
|
+
except ValueError as exc:
|
417
|
+
self.messagebox.showerror("Invalid value", str(exc))
|
418
|
+
return
|
419
|
+
|
420
|
+
self._append_log("Starting processing…")
|
421
|
+
self._stop_requested = False
|
422
|
+
self.stop_button.configure(text="Stop")
|
423
|
+
self._run_start_time = time.monotonic()
|
424
|
+
self._ping_worker_stop_requested = True
|
425
|
+
open_after_convert = bool(self.open_after_convert_var.get())
|
426
|
+
server_url = self.server_url_var.get().strip()
|
427
|
+
remote_mode = self.processing_mode_var.get() == "remote"
|
428
|
+
if remote_mode and not server_url:
|
429
|
+
self.messagebox.showerror(
|
430
|
+
"Missing server URL", "Remote mode requires a server URL."
|
431
|
+
)
|
432
|
+
remote_mode = remote_mode and bool(server_url)
|
433
|
+
|
434
|
+
# Store remote_mode for use after thread starts
|
435
|
+
self._current_remote_mode = remote_mode
|
436
|
+
|
437
|
+
def worker() -> None:
|
438
|
+
def set_process(proc: subprocess.Popen) -> None:
|
439
|
+
self._ffmpeg_process = proc
|
440
|
+
|
441
|
+
try:
|
442
|
+
files = gather_input_files(self.input_files)
|
443
|
+
if not files:
|
444
|
+
self._schedule_on_ui_thread(
|
445
|
+
lambda: self.messagebox.showwarning(
|
446
|
+
"No files", "No supported media files were found."
|
447
|
+
)
|
448
|
+
)
|
449
|
+
self._set_status("Idle")
|
450
|
+
return
|
451
|
+
|
452
|
+
if self._current_remote_mode:
|
453
|
+
success = self._process_files_via_server(
|
454
|
+
files,
|
455
|
+
args,
|
456
|
+
server_url,
|
457
|
+
open_after_convert=open_after_convert,
|
458
|
+
)
|
459
|
+
if success:
|
460
|
+
self._schedule_on_ui_thread(self._hide_stop_button)
|
461
|
+
return
|
462
|
+
# If server processing failed, fall back to local processing
|
463
|
+
# The _process_files_via_server function already switched to local mode
|
464
|
+
# Update remote_mode variable to reflect the change
|
465
|
+
self._current_remote_mode = False
|
466
|
+
|
467
|
+
reporter = _TkProgressReporter(
|
468
|
+
self._append_log,
|
469
|
+
process_callback=set_process,
|
470
|
+
stop_callback=lambda: self._stop_requested,
|
471
|
+
)
|
472
|
+
for index, file in enumerate(files, start=1):
|
473
|
+
self._append_log(f"Processing: {os.path.basename(file)}")
|
474
|
+
options = self._create_processing_options(Path(file), args)
|
475
|
+
result = speed_up_video(options, reporter=reporter)
|
476
|
+
self._last_output = result.output_file
|
477
|
+
self._last_time_ratio = result.time_ratio
|
478
|
+
self._last_size_ratio = result.size_ratio
|
479
|
+
|
480
|
+
# Create completion message with ratios if available
|
481
|
+
completion_msg = f"Completed: {result.output_file}"
|
482
|
+
if result.time_ratio is not None and result.size_ratio is not None:
|
483
|
+
completion_msg += f" (Time: {result.time_ratio:.2%}, Size: {result.size_ratio:.2%})"
|
484
|
+
|
485
|
+
self._append_log(completion_msg)
|
486
|
+
if open_after_convert:
|
487
|
+
self._schedule_on_ui_thread(
|
488
|
+
lambda path=result.output_file: self._open_in_file_manager(
|
489
|
+
path
|
490
|
+
)
|
491
|
+
)
|
492
|
+
|
493
|
+
self._append_log("All jobs finished successfully.")
|
494
|
+
self._schedule_on_ui_thread(
|
495
|
+
lambda: self.open_button.configure(state=self.tk.NORMAL)
|
496
|
+
)
|
497
|
+
self._schedule_on_ui_thread(self._clear_input_files)
|
498
|
+
except FFmpegNotFoundError as exc:
|
499
|
+
self._schedule_on_ui_thread(
|
500
|
+
lambda: self.messagebox.showerror("FFmpeg not found", str(exc))
|
501
|
+
)
|
502
|
+
self._set_status("Error")
|
503
|
+
except ProcessingAborted:
|
504
|
+
self._append_log("Processing aborted by user.")
|
505
|
+
self._set_status("Aborted")
|
506
|
+
except Exception as exc: # pragma: no cover - GUI level safeguard
|
507
|
+
# If stop was requested, don't show error (FFmpeg termination is expected)
|
508
|
+
if self._stop_requested:
|
509
|
+
self._append_log("Processing aborted by user.")
|
510
|
+
self._set_status("Aborted")
|
511
|
+
else:
|
512
|
+
error_msg = f"Processing failed: {exc}"
|
513
|
+
self._append_log(error_msg)
|
514
|
+
print(error_msg, file=sys.stderr) # Also output to console
|
515
|
+
self._schedule_on_ui_thread(
|
516
|
+
lambda: self.messagebox.showerror("Error", error_msg)
|
517
|
+
)
|
518
|
+
self._set_status("Error")
|
519
|
+
finally:
|
520
|
+
self._run_start_time = None
|
521
|
+
self._schedule_on_ui_thread(self._hide_stop_button)
|
522
|
+
|
523
|
+
self._processing_thread = threading.Thread(target=worker, daemon=True)
|
524
|
+
self._processing_thread.start()
|
525
|
+
|
526
|
+
# Show Stop button when processing starts regardless of mode
|
527
|
+
self.stop_button.grid()
|
528
|
+
|
529
|
+
# ------------------------------------------------------------------ UI --
|
530
|
+
def _apply_window_icon(self) -> None:
|
531
|
+
layout_helpers.apply_window_icon(self)
|
532
|
+
|
533
|
+
def _build_layout(self) -> None:
|
534
|
+
layout_helpers.build_layout(self)
|
535
|
+
|
536
|
+
def _update_basic_reset_state(self) -> None:
|
537
|
+
layout_helpers.update_basic_reset_state(self)
|
538
|
+
|
539
|
+
def _reset_basic_defaults(self) -> None:
|
540
|
+
layout_helpers.reset_basic_defaults(self)
|
541
|
+
|
542
|
+
def _update_processing_mode_state(self) -> None:
|
543
|
+
has_url = bool(self.server_url_var.get().strip())
|
544
|
+
if not has_url and self.processing_mode_var.get() == "remote":
|
545
|
+
self.processing_mode_var.set("local")
|
546
|
+
return
|
547
|
+
|
548
|
+
if hasattr(self, "remote_mode_button"):
|
549
|
+
state = self.tk.NORMAL if has_url else self.tk.DISABLED
|
550
|
+
self.remote_mode_button.configure(state=state)
|
551
|
+
|
552
|
+
def _normalize_server_url(self, server_url: str) -> str:
|
553
|
+
return normalize_server_url(server_url)
|
554
|
+
|
555
|
+
def _format_server_host(self, server_url: str) -> str:
|
556
|
+
return format_server_host(server_url)
|
557
|
+
|
558
|
+
def _check_remote_server(
|
559
|
+
self,
|
560
|
+
server_url: str,
|
561
|
+
*,
|
562
|
+
success_status: str,
|
563
|
+
waiting_status: str,
|
564
|
+
failure_status: str,
|
565
|
+
success_message: Optional[str] = None,
|
566
|
+
waiting_message_template: str = "Waiting server {host} (attempt {attempt}/{max_attempts})",
|
567
|
+
failure_message: Optional[str] = None,
|
568
|
+
stop_check: Optional[Callable[[], bool]] = None,
|
569
|
+
on_stop: Optional[Callable[[], None]] = None,
|
570
|
+
switch_to_local_on_failure: bool = False,
|
571
|
+
alert_on_failure: bool = False,
|
572
|
+
warning_title: str = "Server unavailable",
|
573
|
+
warning_message: Optional[str] = None,
|
574
|
+
max_attempts: int = 5,
|
575
|
+
delay: float = 1.0,
|
576
|
+
) -> bool:
|
577
|
+
return check_remote_server_for_gui(
|
578
|
+
self,
|
579
|
+
server_url,
|
580
|
+
success_status=success_status,
|
581
|
+
waiting_status=waiting_status,
|
582
|
+
failure_status=failure_status,
|
583
|
+
success_message=success_message,
|
584
|
+
waiting_message_template=waiting_message_template,
|
585
|
+
failure_message=failure_message,
|
586
|
+
stop_check=stop_check,
|
587
|
+
on_stop=on_stop,
|
588
|
+
switch_to_local_on_failure=switch_to_local_on_failure,
|
589
|
+
alert_on_failure=alert_on_failure,
|
590
|
+
warning_title=warning_title,
|
591
|
+
warning_message=warning_message,
|
592
|
+
max_attempts=max_attempts,
|
593
|
+
delay=delay,
|
594
|
+
)
|
595
|
+
|
596
|
+
def _ping_server(self, server_url: str, *, timeout: float = 5.0) -> bool:
|
597
|
+
return ping_server(server_url, timeout=timeout)
|
598
|
+
|
599
|
+
def _start_discovery(self) -> None:
|
600
|
+
discovery_helpers.start_discovery(self)
|
601
|
+
|
602
|
+
def _on_discovery_failed(self, exc: Exception) -> None:
|
603
|
+
discovery_helpers.on_discovery_failed(self, exc)
|
604
|
+
|
605
|
+
def _on_discovery_progress(self, current: int, total: int) -> None:
|
606
|
+
discovery_helpers.on_discovery_progress(self, current, total)
|
607
|
+
|
608
|
+
def _on_discovery_complete(self, urls: List[str]) -> None:
|
609
|
+
discovery_helpers.on_discovery_complete(self, urls)
|
610
|
+
|
611
|
+
def _show_discovery_results(self, urls: List[str]) -> None:
|
612
|
+
discovery_helpers.show_discovery_results(self, urls)
|
613
|
+
|
614
|
+
def _toggle_simple_mode(self) -> None:
|
615
|
+
self.preferences.update("simple_mode", self.simple_mode_var.get())
|
616
|
+
self._apply_simple_mode()
|
617
|
+
|
618
|
+
def _apply_simple_mode(self, *, initial: bool = False) -> None:
|
619
|
+
layout_helpers.apply_simple_mode(self, initial=initial)
|
620
|
+
|
621
|
+
def _apply_window_size(self, *, simple: bool) -> None:
|
622
|
+
layout_helpers.apply_window_size(self, simple=simple)
|
623
|
+
|
624
|
+
def _toggle_advanced(self, *, initial: bool = False) -> None:
|
625
|
+
if not initial:
|
626
|
+
self.advanced_visible.set(not self.advanced_visible.get())
|
627
|
+
visible = self.advanced_visible.get()
|
628
|
+
if visible:
|
629
|
+
self.advanced_frame.grid()
|
630
|
+
self.advanced_button.configure(text="Hide advanced")
|
631
|
+
else:
|
632
|
+
self.advanced_frame.grid_remove()
|
633
|
+
self.advanced_button.configure(text="Advanced")
|
634
|
+
|
635
|
+
def _on_theme_change(self, *_: object) -> None:
|
636
|
+
self.preferences.update("theme", self.theme_var.get())
|
637
|
+
self._refresh_theme()
|
638
|
+
|
639
|
+
def _on_small_video_change(self, *_: object) -> None:
|
640
|
+
self.preferences.update("small_video", bool(self.small_var.get()))
|
641
|
+
|
642
|
+
def _on_open_after_convert_change(self, *_: object) -> None:
|
643
|
+
self.preferences.update(
|
644
|
+
"open_after_convert", bool(self.open_after_convert_var.get())
|
645
|
+
)
|
646
|
+
|
647
|
+
def _on_processing_mode_change(self, *_: object) -> None:
|
648
|
+
value = self.processing_mode_var.get()
|
649
|
+
if value not in {"local", "remote"}:
|
650
|
+
self.processing_mode_var.set("local")
|
651
|
+
return
|
652
|
+
self.preferences.update("processing_mode", value)
|
653
|
+
self._update_processing_mode_state()
|
654
|
+
|
655
|
+
if self.processing_mode_var.get() == "remote":
|
656
|
+
server_url = self.server_url_var.get().strip()
|
657
|
+
if not server_url:
|
658
|
+
return
|
659
|
+
|
660
|
+
def ping_remote_mode() -> None:
|
661
|
+
self._check_remote_server(
|
662
|
+
server_url,
|
663
|
+
success_status="Idle",
|
664
|
+
waiting_status="Error",
|
665
|
+
failure_status="Error",
|
666
|
+
failure_message="Server {host} is unreachable. Switching to local mode.",
|
667
|
+
switch_to_local_on_failure=True,
|
668
|
+
alert_on_failure=True,
|
669
|
+
warning_message="Server {host} is unreachable. Switching to local mode.",
|
670
|
+
)
|
671
|
+
|
672
|
+
threading.Thread(target=ping_remote_mode, daemon=True).start()
|
673
|
+
|
674
|
+
def _on_server_url_change(self, *_: object) -> None:
|
675
|
+
value = self.server_url_var.get().strip()
|
676
|
+
self.preferences.update("server_url", value)
|
677
|
+
self._update_processing_mode_state()
|
678
|
+
|
679
|
+
def _resolve_theme_mode(self) -> str:
|
680
|
+
preference = self.theme_var.get().lower()
|
681
|
+
if preference not in {"light", "dark"}:
|
682
|
+
return detect_system_theme(
|
683
|
+
os.environ,
|
684
|
+
sys.platform,
|
685
|
+
read_windows_theme_registry,
|
686
|
+
run_defaults_command,
|
687
|
+
)
|
688
|
+
return preference
|
689
|
+
|
690
|
+
def _refresh_theme(self) -> None:
|
691
|
+
mode = self._resolve_theme_mode()
|
692
|
+
palette = LIGHT_THEME if mode == "light" else DARK_THEME
|
693
|
+
apply_theme(
|
694
|
+
self.style,
|
695
|
+
palette,
|
696
|
+
{
|
697
|
+
"root": self.root,
|
698
|
+
"drop_zone": getattr(self, "drop_zone", None),
|
699
|
+
"log_text": getattr(self, "log_text", None),
|
700
|
+
"status_label": getattr(self, "status_label", None),
|
701
|
+
"sliders": getattr(self, "_sliders", []),
|
702
|
+
"tk": self.tk,
|
703
|
+
"apply_status_style": self._apply_status_style,
|
704
|
+
"status_state": self._status_state,
|
705
|
+
},
|
706
|
+
)
|
707
|
+
|
708
|
+
def _configure_drop_targets(self, widget) -> None:
|
709
|
+
if not self._dnd_available:
|
710
|
+
return
|
711
|
+
widget.drop_target_register(DND_FILES) # type: ignore[arg-type]
|
712
|
+
widget.dnd_bind("<<Drop>>", self._on_drop) # type: ignore[attr-defined]
|
713
|
+
|
714
|
+
def _populate_initial_inputs(
|
715
|
+
self, inputs: Sequence[str], *, auto_run: bool = False
|
716
|
+
) -> None:
|
717
|
+
"""Seed the GUI with preselected inputs and optionally start processing."""
|
718
|
+
|
719
|
+
normalized: list[str] = []
|
720
|
+
for path in inputs:
|
721
|
+
if not path:
|
722
|
+
continue
|
723
|
+
resolved = os.fspath(Path(path))
|
724
|
+
if resolved not in self.input_files:
|
725
|
+
self.input_files.append(resolved)
|
726
|
+
normalized.append(resolved)
|
727
|
+
|
728
|
+
if auto_run and normalized:
|
729
|
+
# Kick off processing once the event loop becomes idle so the
|
730
|
+
# interface has a chance to render before the work starts.
|
731
|
+
self.root.after_idle(self._start_run)
|
732
|
+
|
733
|
+
# -------------------------------------------------------------- actions --
|
734
|
+
def _ask_for_input_files(self) -> tuple[str, ...]:
|
735
|
+
"""Prompt the user to select input files for processing."""
|
736
|
+
|
737
|
+
return self.filedialog.askopenfilenames(
|
738
|
+
title="Select input files",
|
739
|
+
filetypes=[
|
740
|
+
("Video files", "*.mp4 *.mkv *.mov *.avi *.m4v"),
|
741
|
+
("All", "*.*"),
|
742
|
+
],
|
743
|
+
)
|
744
|
+
|
745
|
+
def _add_files(self) -> None:
|
746
|
+
files = self._ask_for_input_files()
|
747
|
+
self._extend_inputs(files)
|
748
|
+
|
749
|
+
def _add_directory(self) -> None:
|
750
|
+
directory = self.filedialog.askdirectory(title="Select input folder")
|
751
|
+
if directory:
|
752
|
+
self._extend_inputs([directory])
|
753
|
+
|
754
|
+
def _extend_inputs(self, paths: Iterable[str], *, auto_run: bool = False) -> None:
|
755
|
+
added = False
|
756
|
+
for path in paths:
|
757
|
+
if path and path not in self.input_files:
|
758
|
+
self.input_files.append(path)
|
759
|
+
added = True
|
760
|
+
if auto_run and added and self.run_after_drop_var.get():
|
761
|
+
self._start_run()
|
762
|
+
|
763
|
+
def _clear_input_files(self) -> None:
|
764
|
+
"""Clear all queued input files."""
|
765
|
+
self.input_files.clear()
|
766
|
+
|
767
|
+
def _on_drop(self, event: object) -> None:
|
768
|
+
data = getattr(event, "data", "")
|
769
|
+
if not data:
|
770
|
+
return
|
771
|
+
paths = self.root.tk.splitlist(data)
|
772
|
+
cleaned = [path.strip("{}") for path in paths]
|
773
|
+
# Clear existing files before adding dropped files
|
774
|
+
self.input_files.clear()
|
775
|
+
self._extend_inputs(cleaned, auto_run=True)
|
776
|
+
|
777
|
+
def _on_drop_zone_click(self, event: object) -> str | None:
|
778
|
+
"""Open a file selection dialog when the drop zone is activated."""
|
779
|
+
|
780
|
+
files = self._ask_for_input_files()
|
781
|
+
if not files:
|
782
|
+
return "break"
|
783
|
+
self._clear_input_files()
|
784
|
+
self._extend_inputs(files, auto_run=True)
|
785
|
+
return "break"
|
786
|
+
|
787
|
+
def _browse_path(
|
788
|
+
self, variable, label: str
|
789
|
+
) -> None: # type: (tk.StringVar, str) -> None
|
790
|
+
if "folder" in label.lower():
|
791
|
+
result = self.filedialog.askdirectory()
|
792
|
+
else:
|
793
|
+
initial = variable.get() or os.getcwd()
|
794
|
+
result = self.filedialog.asksaveasfilename(
|
795
|
+
initialfile=os.path.basename(initial)
|
796
|
+
)
|
797
|
+
if result:
|
798
|
+
variable.set(result)
|
799
|
+
|
800
|
+
def _stop_processing(self) -> None:
|
801
|
+
"""Stop the currently running processing by terminating FFmpeg."""
|
802
|
+
import signal
|
803
|
+
|
804
|
+
self._stop_requested = True
|
805
|
+
# Update button text to indicate stopping state
|
806
|
+
self.stop_button.configure(text="Stopping...")
|
807
|
+
if self._current_remote_mode:
|
808
|
+
self._append_log("Cancelling remote job...")
|
809
|
+
elif self._ffmpeg_process and self._ffmpeg_process.poll() is None:
|
810
|
+
self._append_log("Stopping FFmpeg process...")
|
811
|
+
try:
|
812
|
+
# Send SIGTERM to FFmpeg process
|
813
|
+
if sys.platform == "win32":
|
814
|
+
# Windows doesn't have SIGTERM, use terminate()
|
815
|
+
self._ffmpeg_process.terminate()
|
816
|
+
else:
|
817
|
+
# Unix-like systems can use SIGTERM
|
818
|
+
self._ffmpeg_process.send_signal(signal.SIGTERM)
|
819
|
+
|
820
|
+
self._append_log("FFmpeg process stopped.")
|
821
|
+
except Exception as e:
|
822
|
+
self._append_log(f"Error stopping process: {e}")
|
823
|
+
else:
|
824
|
+
self._append_log("No active FFmpeg process to stop.")
|
825
|
+
|
826
|
+
self._hide_stop_button()
|
827
|
+
|
828
|
+
def _hide_stop_button(self) -> None:
|
829
|
+
"""Hide Stop button."""
|
830
|
+
self.stop_button.grid_remove()
|
831
|
+
# Show drop hint when stop button is hidden and no other buttons are visible
|
832
|
+
if (
|
833
|
+
not self.open_button.winfo_viewable()
|
834
|
+
and hasattr(self, "drop_hint_button")
|
835
|
+
and not self.drop_hint_button.winfo_viewable()
|
836
|
+
):
|
837
|
+
self.drop_hint_button.grid()
|
838
|
+
|
839
|
+
def _collect_arguments(self) -> dict[str, object]:
|
840
|
+
args: dict[str, object] = {}
|
841
|
+
|
842
|
+
if self.output_var.get():
|
843
|
+
args["output_file"] = Path(self.output_var.get())
|
844
|
+
if self.temp_var.get():
|
845
|
+
args["temp_folder"] = Path(self.temp_var.get())
|
846
|
+
silent_threshold = float(self.silent_threshold_var.get())
|
847
|
+
args["silent_threshold"] = round(silent_threshold, 2)
|
848
|
+
|
849
|
+
sounded_speed = float(self.sounded_speed_var.get())
|
850
|
+
args["sounded_speed"] = round(sounded_speed, 2)
|
851
|
+
|
852
|
+
silent_speed = float(self.silent_speed_var.get())
|
853
|
+
args["silent_speed"] = round(silent_speed, 2)
|
854
|
+
if self.frame_margin_var.get():
|
855
|
+
args["frame_spreadage"] = int(
|
856
|
+
round(self._parse_float(self.frame_margin_var.get(), "Frame margin"))
|
857
|
+
)
|
858
|
+
if self.sample_rate_var.get():
|
859
|
+
args["sample_rate"] = int(
|
860
|
+
round(self._parse_float(self.sample_rate_var.get(), "Sample rate"))
|
861
|
+
)
|
862
|
+
if self.small_var.get():
|
863
|
+
args["small"] = True
|
864
|
+
return args
|
865
|
+
|
866
|
+
def _process_files_via_server(
|
867
|
+
self,
|
868
|
+
files: List[str],
|
869
|
+
args: dict[str, object],
|
870
|
+
server_url: str,
|
871
|
+
*,
|
872
|
+
open_after_convert: bool,
|
873
|
+
) -> bool:
|
874
|
+
"""Send *files* to the configured server for processing."""
|
875
|
+
|
876
|
+
return process_files_via_server(
|
877
|
+
self,
|
878
|
+
files,
|
879
|
+
args,
|
880
|
+
server_url,
|
881
|
+
open_after_convert=open_after_convert,
|
882
|
+
default_remote_destination=_default_remote_destination,
|
883
|
+
parse_summary=_parse_ratios_from_summary,
|
884
|
+
)
|
885
|
+
|
886
|
+
def _parse_float(self, value: str, label: str) -> float:
|
887
|
+
try:
|
888
|
+
return float(value)
|
889
|
+
except ValueError as exc: # pragma: no cover - input validation
|
890
|
+
raise ValueError(f"{label} must be a number.") from exc
|
891
|
+
|
892
|
+
def _create_processing_options(
|
893
|
+
self, input_file: Path, args: dict[str, object]
|
894
|
+
) -> ProcessingOptions:
|
895
|
+
options = dict(args)
|
896
|
+
options["input_file"] = input_file
|
897
|
+
|
898
|
+
if "temp_folder" in options:
|
899
|
+
options["temp_folder"] = Path(options["temp_folder"])
|
900
|
+
|
901
|
+
return ProcessingOptions(**options)
|
902
|
+
|
903
|
+
def _open_last_output(self) -> None:
|
904
|
+
if self._last_output is not None:
|
905
|
+
self._open_in_file_manager(self._last_output)
|
906
|
+
|
907
|
+
def _open_in_file_manager(self, path: Path) -> None:
|
908
|
+
target = Path(path)
|
909
|
+
if sys.platform.startswith("win"):
|
910
|
+
command = ["explorer", f"/select,{target}"]
|
911
|
+
elif sys.platform == "darwin":
|
912
|
+
command = ["open", "-R", os.fspath(target)]
|
913
|
+
else:
|
914
|
+
command = [
|
915
|
+
"xdg-open",
|
916
|
+
os.fspath(target.parent if target.exists() else target),
|
917
|
+
]
|
918
|
+
try:
|
919
|
+
subprocess.Popen(command)
|
920
|
+
except OSError:
|
921
|
+
self._append_log(f"Could not open file manager for {target}")
|
922
|
+
|
923
|
+
def _append_log(self, message: str) -> None:
|
924
|
+
self._update_status_from_message(message)
|
925
|
+
|
926
|
+
def updater() -> None:
|
927
|
+
self.log_text.configure(state=self.tk.NORMAL)
|
928
|
+
self.log_text.insert(self.tk.END, message + "\n")
|
929
|
+
self.log_text.see(self.tk.END)
|
930
|
+
self.log_text.configure(state=self.tk.DISABLED)
|
931
|
+
|
932
|
+
self.log_text.after(0, updater)
|
933
|
+
|
934
|
+
def _update_status_from_message(self, message: str) -> None:
|
935
|
+
normalized = message.strip().lower()
|
936
|
+
|
937
|
+
metadata_found, source_duration = _parse_source_duration_seconds(message)
|
938
|
+
if metadata_found:
|
939
|
+
self._source_duration_seconds = source_duration
|
940
|
+
|
941
|
+
if self._handle_status_transitions(normalized):
|
942
|
+
return
|
943
|
+
|
944
|
+
frame_total_found, frame_total = _parse_encode_total_frames(message)
|
945
|
+
if frame_total_found:
|
946
|
+
self._encode_total_frames = frame_total
|
947
|
+
return
|
948
|
+
|
949
|
+
if _is_encode_total_frames_unknown(normalized):
|
950
|
+
self._encode_total_frames = None
|
951
|
+
return
|
952
|
+
|
953
|
+
frame_found, current_frame = _parse_current_frame(message)
|
954
|
+
if frame_found:
|
955
|
+
if current_frame is None:
|
956
|
+
return
|
957
|
+
|
958
|
+
if self._encode_current_frame == current_frame:
|
959
|
+
return
|
960
|
+
|
961
|
+
self._encode_current_frame = current_frame
|
962
|
+
if self._encode_total_frames and self._encode_total_frames > 0:
|
963
|
+
self._complete_audio_phase()
|
964
|
+
frame_ratio = min(current_frame / self._encode_total_frames, 1.0)
|
965
|
+
percentage = min(100, 5 + int(frame_ratio * 95))
|
966
|
+
self._set_progress(percentage)
|
967
|
+
else:
|
968
|
+
self._complete_audio_phase()
|
969
|
+
self._set_status("processing", f"{current_frame} frames encoded")
|
970
|
+
|
971
|
+
duration_found, encode_duration = _parse_encode_target_duration(message)
|
972
|
+
if duration_found:
|
973
|
+
self._encode_target_duration_seconds = encode_duration
|
974
|
+
|
975
|
+
if _is_encode_target_duration_unknown(normalized):
|
976
|
+
self._encode_target_duration_seconds = None
|
977
|
+
|
978
|
+
video_duration_found, video_duration = _parse_video_duration_seconds(message)
|
979
|
+
if video_duration_found and video_duration is not None:
|
980
|
+
self._video_duration_seconds = video_duration
|
981
|
+
|
982
|
+
progress_found, progress_info = _parse_ffmpeg_progress(message)
|
983
|
+
if progress_found and progress_info is not None:
|
984
|
+
current_seconds, speed_str = progress_info
|
985
|
+
time_str = self._format_progress_time(current_seconds)
|
986
|
+
|
987
|
+
self._last_progress_seconds = current_seconds
|
988
|
+
|
989
|
+
total_seconds = (
|
990
|
+
self._encode_target_duration_seconds or self._video_duration_seconds
|
991
|
+
)
|
992
|
+
if total_seconds:
|
993
|
+
total_str = self._format_progress_time(total_seconds)
|
994
|
+
time_display = f"{time_str} / {total_str}"
|
995
|
+
else:
|
996
|
+
time_display = time_str
|
997
|
+
|
998
|
+
status_msg = f"{time_display}, {speed_str}x"
|
999
|
+
|
1000
|
+
if (
|
1001
|
+
(
|
1002
|
+
not self._encode_total_frames
|
1003
|
+
or self._encode_total_frames <= 0
|
1004
|
+
or self._encode_current_frame is None
|
1005
|
+
)
|
1006
|
+
and total_seconds
|
1007
|
+
and total_seconds > 0
|
1008
|
+
):
|
1009
|
+
self._complete_audio_phase()
|
1010
|
+
time_ratio = min(current_seconds / total_seconds, 1.0)
|
1011
|
+
percentage = min(100, 5 + int(time_ratio * 95))
|
1012
|
+
self._set_progress(percentage)
|
1013
|
+
|
1014
|
+
self._set_status("processing", status_msg)
|
1015
|
+
|
1016
|
+
def _handle_status_transitions(self, normalized_message: str) -> bool:
|
1017
|
+
"""Handle high-level status transitions for *normalized_message*."""
|
1018
|
+
|
1019
|
+
if "all jobs finished successfully" in normalized_message:
|
1020
|
+
status_components: List[str] = []
|
1021
|
+
if self._run_start_time is not None:
|
1022
|
+
finish_time = time.monotonic()
|
1023
|
+
runtime_seconds = max(0.0, finish_time - self._run_start_time)
|
1024
|
+
duration_str = self._format_progress_time(runtime_seconds)
|
1025
|
+
status_components.append(f"{duration_str}")
|
1026
|
+
else:
|
1027
|
+
finished_seconds = next(
|
1028
|
+
(
|
1029
|
+
value
|
1030
|
+
for value in (
|
1031
|
+
self._last_progress_seconds,
|
1032
|
+
self._encode_target_duration_seconds,
|
1033
|
+
self._video_duration_seconds,
|
1034
|
+
)
|
1035
|
+
if value is not None
|
1036
|
+
),
|
1037
|
+
None,
|
1038
|
+
)
|
1039
|
+
|
1040
|
+
if finished_seconds is not None:
|
1041
|
+
duration_str = self._format_progress_time(finished_seconds)
|
1042
|
+
status_components.append(f"{duration_str}")
|
1043
|
+
else:
|
1044
|
+
status_components.append("Finished")
|
1045
|
+
|
1046
|
+
if self._last_time_ratio is not None and self._last_size_ratio is not None:
|
1047
|
+
status_components.append(
|
1048
|
+
f"time: {self._last_time_ratio:.0%}, size: {self._last_size_ratio:.0%}"
|
1049
|
+
)
|
1050
|
+
|
1051
|
+
status_msg = ", ".join(status_components)
|
1052
|
+
|
1053
|
+
self._reset_audio_progress_state(clear_source=True)
|
1054
|
+
self._set_status("success", status_msg)
|
1055
|
+
self._set_progress(100)
|
1056
|
+
self._run_start_time = None
|
1057
|
+
self._video_duration_seconds = None
|
1058
|
+
self._encode_target_duration_seconds = None
|
1059
|
+
self._encode_total_frames = None
|
1060
|
+
self._encode_current_frame = None
|
1061
|
+
self._last_progress_seconds = None
|
1062
|
+
return True
|
1063
|
+
|
1064
|
+
if normalized_message.startswith("extracting audio"):
|
1065
|
+
self._reset_audio_progress_state(clear_source=False)
|
1066
|
+
self._set_status("processing", "Extracting audio...")
|
1067
|
+
self._set_progress(0)
|
1068
|
+
self._video_duration_seconds = None
|
1069
|
+
self._encode_target_duration_seconds = None
|
1070
|
+
self._encode_total_frames = None
|
1071
|
+
self._encode_current_frame = None
|
1072
|
+
self._last_progress_seconds = None
|
1073
|
+
self._start_audio_progress()
|
1074
|
+
return False
|
1075
|
+
|
1076
|
+
if normalized_message.startswith("uploading"):
|
1077
|
+
self._set_status("processing", "Uploading...")
|
1078
|
+
return False
|
1079
|
+
|
1080
|
+
if normalized_message.startswith("starting processing"):
|
1081
|
+
self._reset_audio_progress_state(clear_source=True)
|
1082
|
+
self._set_status("processing", "Processing")
|
1083
|
+
self._set_progress(0)
|
1084
|
+
self._video_duration_seconds = None
|
1085
|
+
self._encode_target_duration_seconds = None
|
1086
|
+
self._encode_total_frames = None
|
1087
|
+
self._encode_current_frame = None
|
1088
|
+
self._last_progress_seconds = None
|
1089
|
+
return False
|
1090
|
+
|
1091
|
+
if normalized_message.startswith("processing"):
|
1092
|
+
is_new_job = bool(re.match(r"processing \d+/\d+:", normalized_message))
|
1093
|
+
should_reset = self._status_state.lower() != "processing" or is_new_job
|
1094
|
+
if should_reset:
|
1095
|
+
self._set_progress(0)
|
1096
|
+
self._video_duration_seconds = None
|
1097
|
+
self._encode_target_duration_seconds = None
|
1098
|
+
self._encode_total_frames = None
|
1099
|
+
self._encode_current_frame = None
|
1100
|
+
self._last_progress_seconds = None
|
1101
|
+
if is_new_job:
|
1102
|
+
self._reset_audio_progress_state(clear_source=True)
|
1103
|
+
self._set_status("processing", "Processing")
|
1104
|
+
return False
|
1105
|
+
|
1106
|
+
return False
|
1107
|
+
|
1108
|
+
def _compute_audio_progress_interval(self) -> int:
|
1109
|
+
duration = self._source_duration_seconds or self._video_duration_seconds
|
1110
|
+
if duration and duration > 0:
|
1111
|
+
audio_seconds = max(duration * self.AUDIO_PROCESSING_RATIO, 0.0)
|
1112
|
+
interval_seconds = audio_seconds / self.AUDIO_PROGRESS_STEPS
|
1113
|
+
interval_ms = int(round(interval_seconds * 1000))
|
1114
|
+
return max(self.MIN_AUDIO_INTERVAL_MS, interval_ms)
|
1115
|
+
return self.DEFAULT_AUDIO_INTERVAL_MS
|
1116
|
+
|
1117
|
+
def _start_audio_progress(self) -> None:
|
1118
|
+
interval_ms = self._compute_audio_progress_interval()
|
1119
|
+
|
1120
|
+
def _start() -> None:
|
1121
|
+
if self._audio_progress_job is not None:
|
1122
|
+
self.root.after_cancel(self._audio_progress_job)
|
1123
|
+
self._audio_progress_steps_completed = 0
|
1124
|
+
self._audio_progress_interval_ms = interval_ms
|
1125
|
+
self._audio_progress_job = self.root.after(
|
1126
|
+
interval_ms, self._advance_audio_progress
|
1127
|
+
)
|
1128
|
+
|
1129
|
+
self._schedule_on_ui_thread(_start)
|
1130
|
+
|
1131
|
+
def _advance_audio_progress(self) -> None:
|
1132
|
+
self._audio_progress_job = None
|
1133
|
+
if self._audio_progress_steps_completed >= self.AUDIO_PROGRESS_STEPS:
|
1134
|
+
self._audio_progress_interval_ms = None
|
1135
|
+
return
|
1136
|
+
|
1137
|
+
self._audio_progress_steps_completed += 1
|
1138
|
+
audio_percentage = (
|
1139
|
+
self._audio_progress_steps_completed / self.AUDIO_PROGRESS_STEPS * 100
|
1140
|
+
)
|
1141
|
+
percentage = (audio_percentage / 100) * 5
|
1142
|
+
self._set_progress(percentage)
|
1143
|
+
self._set_status("processing", "Audio processing: %d%%" % (audio_percentage))
|
1144
|
+
|
1145
|
+
if self._audio_progress_steps_completed < self.AUDIO_PROGRESS_STEPS:
|
1146
|
+
interval_ms = (
|
1147
|
+
self._audio_progress_interval_ms or self.DEFAULT_AUDIO_INTERVAL_MS
|
1148
|
+
)
|
1149
|
+
self._audio_progress_job = self.root.after(
|
1150
|
+
interval_ms, self._advance_audio_progress
|
1151
|
+
)
|
1152
|
+
else:
|
1153
|
+
self._audio_progress_interval_ms = None
|
1154
|
+
|
1155
|
+
def _cancel_audio_progress(self) -> None:
|
1156
|
+
if self._audio_progress_job is None:
|
1157
|
+
self._audio_progress_interval_ms = None
|
1158
|
+
return
|
1159
|
+
|
1160
|
+
def _cancel() -> None:
|
1161
|
+
if self._audio_progress_job is not None:
|
1162
|
+
self.root.after_cancel(self._audio_progress_job)
|
1163
|
+
self._audio_progress_job = None
|
1164
|
+
self._audio_progress_interval_ms = None
|
1165
|
+
|
1166
|
+
self._schedule_on_ui_thread(_cancel)
|
1167
|
+
|
1168
|
+
def _reset_audio_progress_state(self, *, clear_source: bool) -> None:
|
1169
|
+
if clear_source:
|
1170
|
+
self._source_duration_seconds = None
|
1171
|
+
self._audio_progress_steps_completed = 0
|
1172
|
+
self._audio_progress_interval_ms = None
|
1173
|
+
if self._audio_progress_job is not None:
|
1174
|
+
self._cancel_audio_progress()
|
1175
|
+
|
1176
|
+
def _complete_audio_phase(self) -> None:
|
1177
|
+
def _complete() -> None:
|
1178
|
+
if self._audio_progress_job is not None:
|
1179
|
+
self.root.after_cancel(self._audio_progress_job)
|
1180
|
+
self._audio_progress_job = None
|
1181
|
+
self._audio_progress_interval_ms = None
|
1182
|
+
if self._audio_progress_steps_completed < self.AUDIO_PROGRESS_STEPS:
|
1183
|
+
self._audio_progress_steps_completed = self.AUDIO_PROGRESS_STEPS
|
1184
|
+
current_value = self.progress_var.get()
|
1185
|
+
if current_value < self.AUDIO_PROGRESS_STEPS:
|
1186
|
+
self._set_progress(self.AUDIO_PROGRESS_STEPS)
|
1187
|
+
|
1188
|
+
self._schedule_on_ui_thread(_complete)
|
1189
|
+
|
1190
|
+
def _get_status_style(self, status: str) -> str | None:
|
1191
|
+
"""Return the foreground color for *status* if a match is known."""
|
1192
|
+
|
1193
|
+
color = STATUS_COLORS.get(status.lower())
|
1194
|
+
if color:
|
1195
|
+
return color
|
1196
|
+
|
1197
|
+
status_lower = status.lower()
|
1198
|
+
if "extracting audio" in status_lower:
|
1199
|
+
return STATUS_COLORS["processing"]
|
1200
|
+
|
1201
|
+
if re.search(
|
1202
|
+
r"\d+:\d{2}(?::\d{2})?(?: / \d+:\d{2}(?::\d{2})?)?.*\d+\.?\d*x",
|
1203
|
+
status,
|
1204
|
+
):
|
1205
|
+
return STATUS_COLORS["processing"]
|
1206
|
+
|
1207
|
+
if "time:" in status_lower and "size:" in status_lower:
|
1208
|
+
# This is our new success format with ratios
|
1209
|
+
return STATUS_COLORS["success"]
|
1210
|
+
|
1211
|
+
return None
|
1212
|
+
|
1213
|
+
def _apply_status_style(self, status: str) -> None:
|
1214
|
+
color = self._get_status_style(status)
|
1215
|
+
if color:
|
1216
|
+
self.status_label.configure(fg=color)
|
1217
|
+
|
1218
|
+
def _set_status(self, status: str, status_msg: str = "") -> None:
|
1219
|
+
def apply() -> None:
|
1220
|
+
self._status_state = status
|
1221
|
+
# Use status_msg if provided, otherwise use status
|
1222
|
+
display_text = status_msg if status_msg else status
|
1223
|
+
self.status_var.set(display_text)
|
1224
|
+
self._apply_status_style(
|
1225
|
+
status
|
1226
|
+
) # Colors depend on status, not display text
|
1227
|
+
self._set_progress_bar_style(status)
|
1228
|
+
lowered = status.lower()
|
1229
|
+
is_processing = lowered == "processing" or "extracting audio" in lowered
|
1230
|
+
|
1231
|
+
if is_processing:
|
1232
|
+
# Show stop button during processing
|
1233
|
+
if hasattr(self, "status_frame"):
|
1234
|
+
self.status_frame.grid()
|
1235
|
+
self.stop_button.grid()
|
1236
|
+
self.drop_hint_button.grid_remove()
|
1237
|
+
else:
|
1238
|
+
self._reset_audio_progress_state(clear_source=True)
|
1239
|
+
|
1240
|
+
if lowered == "success" or "time:" in lowered and "size:" in lowered:
|
1241
|
+
if self.simple_mode_var.get() and hasattr(self, "status_frame"):
|
1242
|
+
self.status_frame.grid()
|
1243
|
+
self.stop_button.grid_remove()
|
1244
|
+
self.drop_hint_button.grid_remove()
|
1245
|
+
self.open_button.grid()
|
1246
|
+
self.open_button.lift() # Ensure open_button is above drop_hint_button
|
1247
|
+
# print("success status")
|
1248
|
+
else:
|
1249
|
+
self.open_button.grid_remove()
|
1250
|
+
# print("not success status")
|
1251
|
+
if self.simple_mode_var.get() and not is_processing:
|
1252
|
+
self.stop_button.grid_remove()
|
1253
|
+
# Show drop hint when no other buttons are visible
|
1254
|
+
if hasattr(self, "drop_hint_button"):
|
1255
|
+
self.drop_hint_button.grid()
|
1256
|
+
|
1257
|
+
self.root.after(0, apply)
|
1258
|
+
|
1259
|
+
def _format_progress_time(self, total_seconds: float) -> str:
|
1260
|
+
"""Format a duration in seconds as h:mm:ss or m:ss for status display."""
|
1261
|
+
|
1262
|
+
try:
|
1263
|
+
rounded_seconds = max(0, int(round(total_seconds)))
|
1264
|
+
except (TypeError, ValueError):
|
1265
|
+
return "0:00"
|
1266
|
+
|
1267
|
+
hours, remainder = divmod(rounded_seconds, 3600)
|
1268
|
+
minutes, seconds = divmod(remainder, 60)
|
1269
|
+
|
1270
|
+
if hours > 0:
|
1271
|
+
return f"{hours}:{minutes:02d}:{seconds:02d}"
|
1272
|
+
|
1273
|
+
total_minutes = rounded_seconds // 60
|
1274
|
+
return f"{total_minutes}:{seconds:02d}"
|
1275
|
+
|
1276
|
+
def _calculate_gradient_color(self, percentage: int, darken: float = 1.0) -> str:
|
1277
|
+
"""Calculate color gradient from red (0%) to green (100%).
|
1278
|
+
|
1279
|
+
Args:
|
1280
|
+
percentage: The position in the gradient (0-100)
|
1281
|
+
darken: Value between 0.0 (black) and 1.0 (original brightness)
|
1282
|
+
|
1283
|
+
Returns:
|
1284
|
+
Hex color code string
|
1285
|
+
"""
|
1286
|
+
# Clamp percentage between 0 and 100
|
1287
|
+
percentage = max(0, min(100, percentage))
|
1288
|
+
# Clamp darken between 0.0 and 1.0
|
1289
|
+
darken = max(0.0, min(1.0, darken))
|
1290
|
+
|
1291
|
+
if percentage <= 50:
|
1292
|
+
# Red to Yellow (0% to 50%)
|
1293
|
+
# Red: (248, 113, 113) -> Yellow: (250, 204, 21)
|
1294
|
+
ratio = percentage / 50.0
|
1295
|
+
r = int((248 + (250 - 248) * ratio) * darken)
|
1296
|
+
g = int((113 + (204 - 113) * ratio) * darken)
|
1297
|
+
b = int((113 + (21 - 113) * ratio) * darken)
|
1298
|
+
else:
|
1299
|
+
# Yellow to Green (50% to 100%)
|
1300
|
+
# Yellow: (250, 204, 21) -> Green: (34, 197, 94)
|
1301
|
+
ratio = (percentage - 50) / 50.0
|
1302
|
+
r = int((250 + (34 - 250) * ratio) * darken)
|
1303
|
+
g = int((204 + (197 - 204) * ratio) * darken)
|
1304
|
+
b = int((21 + (94 - 21) * ratio) * darken)
|
1305
|
+
|
1306
|
+
# Ensure values are within 0-255 range after darkening
|
1307
|
+
r = max(0, min(255, r))
|
1308
|
+
g = max(0, min(255, g))
|
1309
|
+
b = max(0, min(255, b))
|
1310
|
+
|
1311
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
1312
|
+
|
1313
|
+
def _set_progress(self, percentage: int) -> None:
|
1314
|
+
"""Update the progress bar value and color (thread-safe)."""
|
1315
|
+
|
1316
|
+
def updater() -> None:
|
1317
|
+
self.progress_var.set(percentage)
|
1318
|
+
# Update color based on percentage gradient
|
1319
|
+
color = self._calculate_gradient_color(percentage, 0.5)
|
1320
|
+
palette = (
|
1321
|
+
LIGHT_THEME if self._resolve_theme_mode() == "light" else DARK_THEME
|
1322
|
+
)
|
1323
|
+
if self.theme_var.get().lower() in {"light", "dark"}:
|
1324
|
+
palette = (
|
1325
|
+
LIGHT_THEME
|
1326
|
+
if self.theme_var.get().lower() == "light"
|
1327
|
+
else DARK_THEME
|
1328
|
+
)
|
1329
|
+
|
1330
|
+
self.style.configure(
|
1331
|
+
"Dynamic.Horizontal.TProgressbar",
|
1332
|
+
background=color,
|
1333
|
+
troughcolor=palette["surface"],
|
1334
|
+
borderwidth=0,
|
1335
|
+
thickness=20,
|
1336
|
+
)
|
1337
|
+
self.progress_bar.configure(style="Dynamic.Horizontal.TProgressbar")
|
1338
|
+
|
1339
|
+
# Show stop button when progress < 100
|
1340
|
+
if percentage < 100:
|
1341
|
+
if hasattr(self, "status_frame"):
|
1342
|
+
self.status_frame.grid()
|
1343
|
+
self.stop_button.grid()
|
1344
|
+
self.drop_hint_button.grid_remove()
|
1345
|
+
|
1346
|
+
self.root.after(0, updater)
|
1347
|
+
|
1348
|
+
def _set_progress_bar_style(self, status: str) -> None:
|
1349
|
+
"""Update the progress bar color based on status."""
|
1350
|
+
|
1351
|
+
def updater() -> None:
|
1352
|
+
# Map status to progress bar style
|
1353
|
+
status_lower = status.lower()
|
1354
|
+
if status_lower == "success" or (
|
1355
|
+
"time:" in status_lower and "size:" in status_lower
|
1356
|
+
):
|
1357
|
+
style = "Success.Horizontal.TProgressbar"
|
1358
|
+
elif status_lower == "error":
|
1359
|
+
style = "Error.Horizontal.TProgressbar"
|
1360
|
+
elif status_lower == "aborted":
|
1361
|
+
style = "Aborted.Horizontal.TProgressbar"
|
1362
|
+
elif status_lower == "idle":
|
1363
|
+
style = "Idle.Horizontal.TProgressbar"
|
1364
|
+
else:
|
1365
|
+
# For processing states, use dynamic gradient (will be set by _set_progress)
|
1366
|
+
return
|
1367
|
+
|
1368
|
+
self.progress_bar.configure(style=style)
|
1369
|
+
|
1370
|
+
self.root.after(0, updater)
|
1371
|
+
|
1372
|
+
def _schedule_on_ui_thread(self, callback: Callable[[], None]) -> None:
|
1373
|
+
self.root.after(0, callback)
|
1374
|
+
|
1375
|
+
def run(self) -> None:
|
1376
|
+
"""Start the Tkinter event loop."""
|
1377
|
+
|
1378
|
+
self.root.mainloop()
|
1379
|
+
|
1380
|
+
|
1381
|
+
__all__ = [
|
1382
|
+
"TalksReducerGUI",
|
1383
|
+
"_default_remote_destination",
|
1384
|
+
"_parse_ratios_from_summary",
|
1385
|
+
]
|