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