talks-reducer 0.2.24__tar.gz → 0.3.1__tar.gz
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-0.2.24/talks_reducer.egg-info → talks_reducer-0.3.1}/PKG-INFO +11 -4
- {talks_reducer-0.2.24 → talks_reducer-0.3.1}/README.md +9 -2
- {talks_reducer-0.2.24 → talks_reducer-0.3.1}/pyproject.toml +2 -2
- {talks_reducer-0.2.24 → talks_reducer-0.3.1}/talks_reducer/audio.py +18 -16
- {talks_reducer-0.2.24 → talks_reducer-0.3.1}/talks_reducer/ffmpeg.py +13 -1
- {talks_reducer-0.2.24 → talks_reducer-0.3.1}/talks_reducer/gui.py +499 -80
- {talks_reducer-0.2.24 → talks_reducer-0.3.1}/talks_reducer/pipeline.py +5 -0
- {talks_reducer-0.2.24 → talks_reducer-0.3.1/talks_reducer.egg-info}/PKG-INFO +11 -4
- {talks_reducer-0.2.24 → talks_reducer-0.3.1}/talks_reducer.egg-info/SOURCES.txt +1 -0
- {talks_reducer-0.2.24 → talks_reducer-0.3.1}/talks_reducer.egg-info/requires.txt +1 -1
- talks_reducer-0.3.1/tests/test_audio.py +47 -0
- {talks_reducer-0.2.24 → talks_reducer-0.3.1}/LICENSE +0 -0
- {talks_reducer-0.2.24 → talks_reducer-0.3.1}/setup.cfg +0 -0
- {talks_reducer-0.2.24 → talks_reducer-0.3.1}/talks_reducer/__init__.py +0 -0
- {talks_reducer-0.2.24 → talks_reducer-0.3.1}/talks_reducer/__main__.py +0 -0
- {talks_reducer-0.2.24 → talks_reducer-0.3.1}/talks_reducer/chunks.py +0 -0
- {talks_reducer-0.2.24 → talks_reducer-0.3.1}/talks_reducer/cli.py +0 -0
- {talks_reducer-0.2.24 → talks_reducer-0.3.1}/talks_reducer/models.py +0 -0
- {talks_reducer-0.2.24 → talks_reducer-0.3.1}/talks_reducer/progress.py +0 -0
- {talks_reducer-0.2.24 → talks_reducer-0.3.1}/talks_reducer.egg-info/dependency_links.txt +0 -0
- {talks_reducer-0.2.24 → talks_reducer-0.3.1}/talks_reducer.egg-info/entry_points.txt +0 -0
- {talks_reducer-0.2.24 → talks_reducer-0.3.1}/talks_reducer.egg-info/top_level.txt +0 -0
- {talks_reducer-0.2.24 → talks_reducer-0.3.1}/tests/test_pipeline_service.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: talks-reducer
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.3.1
|
4
4
|
Summary: CLI for speeding up long-form talks by removing silence
|
5
5
|
Author: Talks Reducer Maintainers
|
6
6
|
License-Expression: MIT
|
@@ -9,7 +9,7 @@ Description-Content-Type: text/markdown
|
|
9
9
|
License-File: LICENSE
|
10
10
|
Requires-Dist: audiotsm>=0.1.2
|
11
11
|
Requires-Dist: scipy>=1.10.0
|
12
|
-
Requires-Dist: numpy
|
12
|
+
Requires-Dist: numpy>=1.22.0
|
13
13
|
Requires-Dist: tqdm>=4.65.0
|
14
14
|
Requires-Dist: tkinterdnd2>=0.3.0
|
15
15
|
Requires-Dist: Pillow>=9.0.0
|
@@ -82,9 +82,12 @@ continues to work unchanged for local development.
|
|
82
82
|
zone, hides the manual run controls and log, and automatically processes new
|
83
83
|
files as soon as you drop them. Uncheck the box to return to the full layout
|
84
84
|
with file pickers, the Run button, and detailed logging.
|
85
|
-
- **Input drop zone** — drag files or folders from your desktop
|
86
|
-
the Explorer/Finder dialog; duplicates
|
85
|
+
- **Input drop zone** — drag files or folders from your desktop, click to open
|
86
|
+
the system file picker, or add them via the Explorer/Finder dialog; duplicates
|
87
|
+
are ignored.
|
87
88
|
- **Small video** — toggles the `--small` preset used by the CLI.
|
89
|
+
- **Open after convert** — controls whether the exported file is revealed in
|
90
|
+
your system file manager as soon as each job finishes.
|
88
91
|
- **Advanced** — reveals optional controls for the output path, temp folder,
|
89
92
|
timing/audio knobs mirrored from the command line, and an appearance picker
|
90
93
|
that can force dark or light mode or follow your operating system.
|
@@ -94,6 +97,10 @@ a background thread. Once every queued job succeeds an **Open last output**
|
|
94
97
|
button appears so you can jump straight to the exported file in your system
|
95
98
|
file manager.
|
96
99
|
|
100
|
+
The GUI stores your last-used Simple mode, Small video, Open after convert, and
|
101
|
+
theme preferences in a cross-platform configuration file so they persist across
|
102
|
+
launches.
|
103
|
+
|
97
104
|
## Repository Structure
|
98
105
|
- `talks_reducer/` — Python package that exposes the CLI and reusable pipeline:
|
99
106
|
- `cli.py` parses arguments and dispatches to the pipeline.
|
@@ -57,9 +57,12 @@ continues to work unchanged for local development.
|
|
57
57
|
zone, hides the manual run controls and log, and automatically processes new
|
58
58
|
files as soon as you drop them. Uncheck the box to return to the full layout
|
59
59
|
with file pickers, the Run button, and detailed logging.
|
60
|
-
- **Input drop zone** — drag files or folders from your desktop
|
61
|
-
the Explorer/Finder dialog; duplicates
|
60
|
+
- **Input drop zone** — drag files or folders from your desktop, click to open
|
61
|
+
the system file picker, or add them via the Explorer/Finder dialog; duplicates
|
62
|
+
are ignored.
|
62
63
|
- **Small video** — toggles the `--small` preset used by the CLI.
|
64
|
+
- **Open after convert** — controls whether the exported file is revealed in
|
65
|
+
your system file manager as soon as each job finishes.
|
63
66
|
- **Advanced** — reveals optional controls for the output path, temp folder,
|
64
67
|
timing/audio knobs mirrored from the command line, and an appearance picker
|
65
68
|
that can force dark or light mode or follow your operating system.
|
@@ -69,6 +72,10 @@ a background thread. Once every queued job succeeds an **Open last output**
|
|
69
72
|
button appears so you can jump straight to the exported file in your system
|
70
73
|
file manager.
|
71
74
|
|
75
|
+
The GUI stores your last-used Simple mode, Small video, Open after convert, and
|
76
|
+
theme preferences in a cross-platform configuration file so they persist across
|
77
|
+
launches.
|
78
|
+
|
72
79
|
## Repository Structure
|
73
80
|
- `talks_reducer/` — Python package that exposes the CLI and reusable pipeline:
|
74
81
|
- `cli.py` parses arguments and dispatches to the pipeline.
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
4
4
|
|
5
5
|
[project]
|
6
6
|
name = "talks-reducer"
|
7
|
-
version = "0.
|
7
|
+
version = "0.3.1"
|
8
8
|
description = "CLI for speeding up long-form talks by removing silence"
|
9
9
|
readme = "README.md"
|
10
10
|
requires-python = ">=3.9"
|
@@ -15,7 +15,7 @@ authors = [
|
|
15
15
|
dependencies = [
|
16
16
|
"audiotsm>=0.1.2",
|
17
17
|
"scipy>=1.10.0",
|
18
|
-
"numpy>=1.22.0
|
18
|
+
"numpy>=1.22.0",
|
19
19
|
"tqdm>=4.65.0",
|
20
20
|
"tkinterdnd2>=0.3.0",
|
21
21
|
"Pillow>=9.0.0",
|
@@ -11,6 +11,8 @@ import numpy as np
|
|
11
11
|
from audiotsm import phasevocoder
|
12
12
|
from audiotsm.io.array import ArrayReader, ArrayWriter
|
13
13
|
|
14
|
+
from .ffmpeg import get_ffprobe_path
|
15
|
+
|
14
16
|
|
15
17
|
def get_max_volume(samples: np.ndarray) -> float:
|
16
18
|
"""Return the maximum absolute volume in the provided sample array."""
|
@@ -21,8 +23,6 @@ def get_max_volume(samples: np.ndarray) -> float:
|
|
21
23
|
def is_valid_input_file(filename: str) -> bool:
|
22
24
|
"""Check whether ``ffprobe`` recognises the input file and finds an audio stream."""
|
23
25
|
|
24
|
-
from .ffmpeg import get_ffprobe_path
|
25
|
-
|
26
26
|
ffprobe_path = get_ffprobe_path()
|
27
27
|
command = [
|
28
28
|
ffprobe_path,
|
@@ -36,29 +36,31 @@ def is_valid_input_file(filename: str) -> bool:
|
|
36
36
|
"-show_entries",
|
37
37
|
"stream=codec_type",
|
38
38
|
]
|
39
|
-
|
39
|
+
|
40
40
|
# Hide console window on Windows
|
41
41
|
creationflags = 0
|
42
42
|
if sys.platform == "win32":
|
43
43
|
# CREATE_NO_WINDOW = 0x08000000
|
44
44
|
creationflags = 0x08000000
|
45
|
-
|
46
|
-
process = subprocess.Popen(
|
47
|
-
command,
|
48
|
-
stdout=subprocess.PIPE,
|
49
|
-
stderr=subprocess.PIPE,
|
50
|
-
creationflags=creationflags
|
51
|
-
)
|
52
|
-
outs, errs = None, None
|
45
|
+
|
53
46
|
try:
|
54
|
-
|
47
|
+
result = subprocess.run(
|
48
|
+
command,
|
49
|
+
capture_output=True,
|
50
|
+
text=True,
|
51
|
+
timeout=5,
|
52
|
+
creationflags=creationflags,
|
53
|
+
)
|
55
54
|
except subprocess.TimeoutExpired:
|
56
55
|
print("Timeout while checking the input file. Aborting. Command:")
|
57
56
|
print(" ".join(command))
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
return
|
57
|
+
return False
|
58
|
+
|
59
|
+
if result.returncode != 0:
|
60
|
+
return False
|
61
|
+
|
62
|
+
stdout = result.stdout or ""
|
63
|
+
return "codec_type=audio" in stdout
|
62
64
|
|
63
65
|
|
64
66
|
def process_audio_chunks(
|
@@ -190,8 +190,13 @@ def run_timed_ffmpeg_command(
|
|
190
190
|
desc: str = "",
|
191
191
|
total: Optional[int] = None,
|
192
192
|
unit: str = "frames",
|
193
|
+
process_callback: Optional[callable] = None,
|
193
194
|
) -> None:
|
194
|
-
"""Execute an FFmpeg command while streaming progress information.
|
195
|
+
"""Execute an FFmpeg command while streaming progress information.
|
196
|
+
|
197
|
+
Args:
|
198
|
+
process_callback: Optional callback that receives the subprocess.Popen object
|
199
|
+
"""
|
195
200
|
|
196
201
|
import shlex
|
197
202
|
|
@@ -221,6 +226,10 @@ def run_timed_ffmpeg_command(
|
|
221
226
|
print(f"Error starting FFmpeg: {exc}", file=sys.stderr)
|
222
227
|
raise
|
223
228
|
|
229
|
+
# Notify callback with process object
|
230
|
+
if process_callback:
|
231
|
+
process_callback(process)
|
232
|
+
|
224
233
|
progress_reporter = reporter or TqdmProgressReporter()
|
225
234
|
task_manager = progress_reporter.task(desc=desc, total=total, unit=unit)
|
226
235
|
with task_manager as progress:
|
@@ -234,6 +243,9 @@ def run_timed_ffmpeg_command(
|
|
234
243
|
|
235
244
|
sys.stderr.write(line)
|
236
245
|
sys.stderr.flush()
|
246
|
+
|
247
|
+
# Send FFmpeg output to reporter for GUI display
|
248
|
+
progress_reporter.log(line.strip())
|
237
249
|
|
238
250
|
match = re.search(r"frame=\s*(\d+)", line)
|
239
251
|
if match:
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
4
4
|
|
5
5
|
import json
|
6
6
|
import os
|
7
|
+
import re
|
7
8
|
import subprocess
|
8
9
|
import sys
|
9
10
|
import threading
|
@@ -117,9 +118,10 @@ except ModuleNotFoundError: # pragma: no cover - runtime dependency
|
|
117
118
|
|
118
119
|
STATUS_COLORS = {
|
119
120
|
"idle": "#9ca3af",
|
120
|
-
"processing": "#
|
121
|
-
"success": "#
|
122
|
-
"error": "#
|
121
|
+
"processing": "#af8e0e",
|
122
|
+
"success": "#178941",
|
123
|
+
"error": "#ad4f4f",
|
124
|
+
"aborted": "#6d727a",
|
123
125
|
}
|
124
126
|
|
125
127
|
LIGHT_THEME = {
|
@@ -128,7 +130,8 @@ LIGHT_THEME = {
|
|
128
130
|
"accent": "#2563eb",
|
129
131
|
"surface": "#ffffff",
|
130
132
|
"border": "#cbd5e1",
|
131
|
-
"hover": "#
|
133
|
+
"hover": "#efefef",
|
134
|
+
"hover_text": "#000000",
|
132
135
|
"selection_background": "#2563eb",
|
133
136
|
"selection_foreground": "#ffffff",
|
134
137
|
}
|
@@ -140,6 +143,7 @@ DARK_THEME = {
|
|
140
143
|
"surface": "#2b2b3c",
|
141
144
|
"border": "#4b5563",
|
142
145
|
"hover": "#333333",
|
146
|
+
"hover_text": "#ffffff",
|
143
147
|
"selection_background": "#333333",
|
144
148
|
"selection_foreground": "#f3f4f6",
|
145
149
|
}
|
@@ -186,8 +190,13 @@ class _GuiProgressHandle(ProgressHandle):
|
|
186
190
|
class _TkProgressReporter(SignalProgressReporter):
|
187
191
|
"""Progress reporter that forwards updates to the GUI thread."""
|
188
192
|
|
189
|
-
def __init__(
|
193
|
+
def __init__(
|
194
|
+
self,
|
195
|
+
log_callback: Callable[[str], None],
|
196
|
+
process_callback: Optional[Callable] = None,
|
197
|
+
) -> None:
|
190
198
|
self._log_callback = log_callback
|
199
|
+
self.process_callback = process_callback
|
191
200
|
|
192
201
|
def log(self, message: str) -> None:
|
193
202
|
self._log_callback(message)
|
@@ -202,7 +211,55 @@ class _TkProgressReporter(SignalProgressReporter):
|
|
202
211
|
class TalksReducerGUI:
|
203
212
|
"""Tkinter application mirroring the CLI options with form controls."""
|
204
213
|
|
214
|
+
PADDING = 10
|
215
|
+
|
216
|
+
def _determine_config_path(self) -> Path:
|
217
|
+
if sys.platform == "win32":
|
218
|
+
appdata = os.environ.get("APPDATA")
|
219
|
+
base = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming"
|
220
|
+
elif sys.platform == "darwin":
|
221
|
+
base = Path.home() / "Library" / "Application Support"
|
222
|
+
else:
|
223
|
+
xdg_config = os.environ.get("XDG_CONFIG_HOME")
|
224
|
+
base = Path(xdg_config) if xdg_config else Path.home() / ".config"
|
225
|
+
return base / "talks-reducer" / "settings.json"
|
226
|
+
|
227
|
+
def _load_settings(self) -> dict[str, object]:
|
228
|
+
try:
|
229
|
+
with self._config_path.open("r", encoding="utf-8") as handle:
|
230
|
+
data = json.load(handle)
|
231
|
+
if isinstance(data, dict):
|
232
|
+
return data
|
233
|
+
except FileNotFoundError:
|
234
|
+
return {}
|
235
|
+
except (OSError, json.JSONDecodeError):
|
236
|
+
return {}
|
237
|
+
return {}
|
238
|
+
|
239
|
+
def _save_settings(self) -> None:
|
240
|
+
try:
|
241
|
+
self._config_path.parent.mkdir(parents=True, exist_ok=True)
|
242
|
+
with self._config_path.open("w", encoding="utf-8") as handle:
|
243
|
+
json.dump(self._settings, handle, indent=2, sort_keys=True)
|
244
|
+
except OSError:
|
245
|
+
pass
|
246
|
+
|
247
|
+
def _get_setting(self, key: str, default: object) -> object:
|
248
|
+
value = self._settings.get(key, default)
|
249
|
+
if key not in self._settings:
|
250
|
+
self._settings[key] = value
|
251
|
+
return value
|
252
|
+
|
253
|
+
def _update_setting(self, key: str, value: object) -> None:
|
254
|
+
if self._settings.get(key) == value:
|
255
|
+
return
|
256
|
+
self._settings[key] = value
|
257
|
+
self._save_settings()
|
258
|
+
|
205
259
|
def __init__(self) -> None:
|
260
|
+
self._config_path = self._determine_config_path()
|
261
|
+
self._settings = self._load_settings()
|
262
|
+
|
206
263
|
# Import tkinter here to avoid loading it at module import time
|
207
264
|
import tkinter as tk
|
208
265
|
from tkinter import filedialog, messagebox, ttk
|
@@ -217,18 +274,18 @@ class TalksReducerGUI:
|
|
217
274
|
self.root = TkinterDnD.Tk() # type: ignore[call-arg]
|
218
275
|
else:
|
219
276
|
self.root = tk.Tk()
|
220
|
-
|
277
|
+
|
221
278
|
# Set window title with version
|
222
279
|
try:
|
223
280
|
app_version = version("talks-reducer")
|
224
281
|
self.root.title(f"Talks Reducer v{app_version}")
|
225
282
|
except Exception:
|
226
283
|
self.root.title("Talks Reducer")
|
227
|
-
|
284
|
+
|
228
285
|
self._apply_window_icon()
|
229
286
|
|
230
287
|
self._full_size = (760, 680)
|
231
|
-
self._simple_size = (255,
|
288
|
+
self._simple_size = (255, 330)
|
232
289
|
self.root.geometry(f"{self._full_size[0]}x{self._full_size[1]}")
|
233
290
|
self.style = self.ttk.Style(self.root)
|
234
291
|
|
@@ -238,21 +295,36 @@ class TalksReducerGUI:
|
|
238
295
|
self.status_var = tk.StringVar(value=self._status_state)
|
239
296
|
self._status_animation_job: Optional[str] = None
|
240
297
|
self._status_animation_phase = 0
|
298
|
+
self._video_duration_seconds: Optional[float] = None
|
299
|
+
self.progress_var = tk.IntVar(value=0)
|
300
|
+
self._ffmpeg_process: Optional[subprocess.Popen] = None
|
301
|
+
self._stop_requested = False
|
241
302
|
|
242
303
|
self.input_files: List[str] = []
|
243
304
|
|
244
305
|
self._dnd_available = TkinterDnD is not None and DND_FILES is not None
|
245
306
|
|
246
|
-
self.simple_mode_var = tk.BooleanVar(
|
307
|
+
self.simple_mode_var = tk.BooleanVar(
|
308
|
+
value=self._get_setting("simple_mode", True)
|
309
|
+
)
|
247
310
|
self.run_after_drop_var = tk.BooleanVar(value=True)
|
248
|
-
self.small_var = tk.BooleanVar(value=True)
|
249
|
-
self.
|
311
|
+
self.small_var = tk.BooleanVar(value=self._get_setting("small_video", True))
|
312
|
+
self.open_after_convert_var = tk.BooleanVar(
|
313
|
+
value=self._get_setting("open_after_convert", True)
|
314
|
+
)
|
315
|
+
self.theme_var = tk.StringVar(value=self._get_setting("theme", "os"))
|
250
316
|
self.theme_var.trace_add("write", self._on_theme_change)
|
317
|
+
self.small_var.trace_add("write", self._on_small_video_change)
|
318
|
+
self.open_after_convert_var.trace_add(
|
319
|
+
"write", self._on_open_after_convert_change
|
320
|
+
)
|
251
321
|
|
252
322
|
self._build_layout()
|
253
323
|
self._apply_simple_mode(initial=True)
|
254
324
|
self._apply_status_style(self._status_state)
|
255
325
|
self._apply_theme()
|
326
|
+
self._save_settings()
|
327
|
+
self._hide_stop_button()
|
256
328
|
|
257
329
|
if not self._dnd_available:
|
258
330
|
self._append_log(
|
@@ -289,16 +361,17 @@ class TalksReducerGUI:
|
|
289
361
|
continue
|
290
362
|
|
291
363
|
def _build_layout(self) -> None:
|
292
|
-
main = self.ttk.Frame(self.root, padding=
|
364
|
+
main = self.ttk.Frame(self.root, padding=self.PADDING)
|
293
365
|
main.grid(row=0, column=0, sticky="nsew")
|
294
366
|
self.root.columnconfigure(0, weight=1)
|
295
367
|
self.root.rowconfigure(0, weight=1)
|
296
368
|
|
297
369
|
# Input selection frame
|
298
|
-
input_frame = self.ttk.
|
370
|
+
input_frame = self.ttk.Frame(main, padding=self.PADDING)
|
299
371
|
input_frame.grid(row=0, column=0, sticky="nsew")
|
300
372
|
main.rowconfigure(0, weight=1)
|
301
|
-
|
373
|
+
main.columnconfigure(0, weight=1)
|
374
|
+
for column in range(5):
|
302
375
|
input_frame.columnconfigure(column, weight=1)
|
303
376
|
|
304
377
|
self.input_list = self.tk.Listbox(input_frame, height=5)
|
@@ -312,16 +385,20 @@ class TalksReducerGUI:
|
|
312
385
|
self.drop_zone = self.tk.Label(
|
313
386
|
input_frame,
|
314
387
|
text="Drop files or folders here",
|
315
|
-
relief=self.tk.
|
316
|
-
borderwidth=
|
317
|
-
padx=
|
318
|
-
pady=
|
319
|
-
highlightthickness=
|
388
|
+
relief=self.tk.FLAT,
|
389
|
+
borderwidth=0,
|
390
|
+
padx=self.PADDING,
|
391
|
+
pady=self.PADDING,
|
392
|
+
highlightthickness=0,
|
320
393
|
)
|
321
|
-
self.drop_zone.grid(row=1, column=0, columnspan=
|
394
|
+
self.drop_zone.grid(row=1, column=0, columnspan=5, sticky="nsew")
|
322
395
|
input_frame.rowconfigure(1, weight=1)
|
323
396
|
self._configure_drop_targets(self.drop_zone)
|
324
397
|
self._configure_drop_targets(self.input_list)
|
398
|
+
self.drop_zone.configure(cursor="hand2", takefocus=1)
|
399
|
+
self.drop_zone.bind("<Button-1>", self._on_drop_zone_click)
|
400
|
+
self.drop_zone.bind("<Return>", self._on_drop_zone_click)
|
401
|
+
self.drop_zone.bind("<space>", self._on_drop_zone_click)
|
325
402
|
|
326
403
|
self.add_files_button = self.ttk.Button(
|
327
404
|
input_frame, text="Add files", command=self._add_files
|
@@ -343,21 +420,32 @@ class TalksReducerGUI:
|
|
343
420
|
self.run_after_drop_check.grid(row=2, column=3, pady=8, sticky="e")
|
344
421
|
|
345
422
|
# Options frame
|
346
|
-
options = self.ttk.
|
347
|
-
options.grid(row=
|
423
|
+
options = self.ttk.Frame(main, padding=self.PADDING)
|
424
|
+
options.grid(row=2, column=0, pady=(16, 0), sticky="ew")
|
348
425
|
options.columnconfigure(0, weight=1)
|
349
426
|
|
427
|
+
checkbox_frame = self.ttk.Frame(options)
|
428
|
+
checkbox_frame.grid(row=0, column=0, columnspan=2, sticky="w")
|
429
|
+
|
430
|
+
self.ttk.Checkbutton(
|
431
|
+
checkbox_frame,
|
432
|
+
text="Small video",
|
433
|
+
variable=self.small_var,
|
434
|
+
).grid(row=0, column=0, sticky="w")
|
435
|
+
|
436
|
+
self.ttk.Checkbutton(
|
437
|
+
checkbox_frame,
|
438
|
+
text="Open after convert",
|
439
|
+
variable=self.open_after_convert_var,
|
440
|
+
).grid(row=0, column=1, sticky="w", padx=(12, 0))
|
441
|
+
|
350
442
|
self.simple_mode_check = self.ttk.Checkbutton(
|
351
|
-
|
443
|
+
checkbox_frame,
|
352
444
|
text="Simple mode",
|
353
445
|
variable=self.simple_mode_var,
|
354
446
|
command=self._toggle_simple_mode,
|
355
447
|
)
|
356
|
-
self.simple_mode_check.grid(row=0, column=
|
357
|
-
|
358
|
-
self.ttk.Checkbutton(options, text="Small video", variable=self.small_var).grid(
|
359
|
-
row=1, column=0, sticky="w", pady=(8, 0)
|
360
|
-
)
|
448
|
+
self.simple_mode_check.grid(row=0, column=2, sticky="w", padx=(12, 0))
|
361
449
|
|
362
450
|
self.advanced_visible = self.tk.BooleanVar(value=False)
|
363
451
|
self.advanced_button = self.ttk.Button(
|
@@ -365,9 +453,9 @@ class TalksReducerGUI:
|
|
365
453
|
text="Advanced",
|
366
454
|
command=self._toggle_advanced,
|
367
455
|
)
|
368
|
-
self.advanced_button.grid(row=
|
456
|
+
self.advanced_button.grid(row=1, column=1, sticky="e")
|
369
457
|
|
370
|
-
self.advanced_frame = self.ttk.Frame(options, padding=
|
458
|
+
self.advanced_frame = self.ttk.Frame(options, padding=self.PADDING)
|
371
459
|
self.advanced_frame.grid(row=2, column=0, columnspan=2, sticky="nsew")
|
372
460
|
self.advanced_frame.columnconfigure(1, weight=1)
|
373
461
|
|
@@ -424,33 +512,61 @@ class TalksReducerGUI:
|
|
424
512
|
self._toggle_advanced(initial=True)
|
425
513
|
|
426
514
|
# Action buttons and log output
|
427
|
-
|
428
|
-
|
429
|
-
|
515
|
+
status_frame = self.ttk.Frame(main, padding=self.PADDING)
|
516
|
+
status_frame.grid(row=1, column=0, sticky="ew")
|
517
|
+
status_frame.columnconfigure(0, weight=0)
|
518
|
+
status_frame.columnconfigure(1, weight=1)
|
519
|
+
status_frame.columnconfigure(2, weight=0)
|
430
520
|
|
431
|
-
self.
|
432
|
-
|
521
|
+
self.ttk.Label(status_frame, text="Status:").grid(row=0, column=0, sticky="w")
|
522
|
+
self.status_label = self.tk.Label(
|
523
|
+
status_frame, textvariable=self.status_var, anchor="e"
|
524
|
+
)
|
525
|
+
self.status_label.grid(row=0, column=1, sticky="e")
|
526
|
+
|
527
|
+
# Progress bar
|
528
|
+
self.progress_bar = self.ttk.Progressbar(
|
529
|
+
status_frame,
|
530
|
+
variable=self.progress_var,
|
531
|
+
maximum=100,
|
532
|
+
mode="determinate",
|
533
|
+
style="Idle.Horizontal.TProgressbar",
|
433
534
|
)
|
434
|
-
self.
|
535
|
+
self.progress_bar.grid(row=1, column=0, columnspan=3, sticky="ew", pady=(0, 0))
|
536
|
+
|
537
|
+
self.stop_button = self.ttk.Button(
|
538
|
+
status_frame, text="Stop", command=self._stop_processing
|
539
|
+
)
|
540
|
+
self.stop_button.grid(
|
541
|
+
row=2, column=0, columnspan=3, sticky="ew", pady=self.PADDING
|
542
|
+
)
|
543
|
+
self.stop_button.grid_remove() # Hidden by default
|
435
544
|
|
436
545
|
self.open_button = self.ttk.Button(
|
437
|
-
|
438
|
-
text="Open last
|
546
|
+
status_frame,
|
547
|
+
text="Open last",
|
439
548
|
command=self._open_last_output,
|
440
549
|
state=self.tk.DISABLED,
|
441
550
|
)
|
442
|
-
self.open_button.grid(
|
551
|
+
self.open_button.grid(
|
552
|
+
row=2, column=0, columnspan=3, sticky="ew", pady=self.PADDING
|
553
|
+
)
|
443
554
|
self.open_button.grid_remove()
|
444
555
|
|
445
|
-
|
446
|
-
|
447
|
-
|
448
|
-
|
449
|
-
|
450
|
-
|
556
|
+
# Button shown when no other action buttons are visible
|
557
|
+
self.drop_hint_button = self.ttk.Button(
|
558
|
+
status_frame,
|
559
|
+
text="Drop video to convert",
|
560
|
+
state=self.tk.DISABLED,
|
561
|
+
)
|
562
|
+
self.drop_hint_button.grid(
|
563
|
+
row=2, column=0, columnspan=3, sticky="ew", pady=self.PADDING
|
564
|
+
)
|
565
|
+
self.drop_hint_button.grid_remove() # Hidden by default
|
566
|
+
self._configure_drop_targets(self.drop_hint_button)
|
451
567
|
|
452
|
-
self.log_frame = self.ttk.
|
453
|
-
self.log_frame.grid(row=
|
568
|
+
self.log_frame = self.ttk.Frame(main, padding=self.PADDING)
|
569
|
+
self.log_frame.grid(row=3, column=0, pady=(16, 0), sticky="nsew")
|
454
570
|
main.rowconfigure(4, weight=1)
|
455
571
|
self.log_frame.columnconfigure(0, weight=1)
|
456
572
|
self.log_frame.rowconfigure(0, weight=1)
|
@@ -486,6 +602,7 @@ class TalksReducerGUI:
|
|
486
602
|
button.grid(row=row, column=2, padx=(8, 0))
|
487
603
|
|
488
604
|
def _toggle_simple_mode(self) -> None:
|
605
|
+
self._update_setting("simple_mode", self.simple_mode_var.get())
|
489
606
|
self._apply_simple_mode()
|
490
607
|
|
491
608
|
def _apply_simple_mode(self, *, initial: bool = False) -> None:
|
@@ -503,21 +620,25 @@ class TalksReducerGUI:
|
|
503
620
|
for widget in widgets:
|
504
621
|
widget.grid_remove()
|
505
622
|
self.log_frame.grid_remove()
|
506
|
-
self.
|
623
|
+
self.stop_button.grid_remove()
|
507
624
|
self.advanced_button.grid_remove()
|
508
625
|
self.advanced_frame.grid_remove()
|
509
|
-
self
|
626
|
+
if hasattr(self, "status_frame"):
|
627
|
+
self.status_frame.grid_remove()
|
510
628
|
self.run_after_drop_var.set(True)
|
511
629
|
self._apply_window_size(simple=True)
|
512
|
-
if self.status_var.get().lower() == "success"
|
513
|
-
self
|
630
|
+
if self.status_var.get().lower() == "success" and hasattr(
|
631
|
+
self, "status_frame"
|
632
|
+
):
|
633
|
+
self.status_frame.grid()
|
514
634
|
self.open_button.grid()
|
635
|
+
self.drop_hint_button.grid_remove()
|
515
636
|
else:
|
516
637
|
for widget in widgets:
|
517
638
|
widget.grid()
|
518
639
|
self.log_frame.grid()
|
519
|
-
self
|
520
|
-
|
640
|
+
if hasattr(self, "status_frame"):
|
641
|
+
self.status_frame.grid()
|
521
642
|
self.advanced_button.grid()
|
522
643
|
if self.advanced_visible.get():
|
523
644
|
self.advanced_frame.grid()
|
@@ -551,8 +672,17 @@ class TalksReducerGUI:
|
|
551
672
|
self.advanced_button.configure(text="Advanced")
|
552
673
|
|
553
674
|
def _on_theme_change(self, *_: object) -> None:
|
675
|
+
self._update_setting("theme", self.theme_var.get())
|
554
676
|
self._apply_theme()
|
555
677
|
|
678
|
+
def _on_small_video_change(self, *_: object) -> None:
|
679
|
+
self._update_setting("small_video", bool(self.small_var.get()))
|
680
|
+
|
681
|
+
def _on_open_after_convert_change(self, *_: object) -> None:
|
682
|
+
self._update_setting(
|
683
|
+
"open_after_convert", bool(self.open_after_convert_var.get())
|
684
|
+
)
|
685
|
+
|
556
686
|
def _apply_theme(self) -> None:
|
557
687
|
preference = self.theme_var.get().lower()
|
558
688
|
if preference not in {"light", "dark"}:
|
@@ -572,6 +702,8 @@ class TalksReducerGUI:
|
|
572
702
|
"TLabelframe",
|
573
703
|
background=palette["background"],
|
574
704
|
foreground=palette["foreground"],
|
705
|
+
borderwidth=0,
|
706
|
+
relief="flat",
|
575
707
|
)
|
576
708
|
self.style.configure(
|
577
709
|
"TLabelframe.Label",
|
@@ -586,11 +718,19 @@ class TalksReducerGUI:
|
|
586
718
|
background=palette["background"],
|
587
719
|
foreground=palette["foreground"],
|
588
720
|
)
|
721
|
+
self.style.map(
|
722
|
+
"TCheckbutton",
|
723
|
+
background=[("active", palette.get("hover", palette["background"]))],
|
724
|
+
)
|
589
725
|
self.style.configure(
|
590
726
|
"TRadiobutton",
|
591
727
|
background=palette["background"],
|
592
728
|
foreground=palette["foreground"],
|
593
729
|
)
|
730
|
+
self.style.map(
|
731
|
+
"TRadiobutton",
|
732
|
+
background=[("active", palette.get("hover", palette["background"]))],
|
733
|
+
)
|
594
734
|
self.style.configure(
|
595
735
|
"TButton",
|
596
736
|
background=palette["surface"],
|
@@ -604,7 +744,7 @@ class TalksReducerGUI:
|
|
604
744
|
("disabled", palette["surface"]),
|
605
745
|
],
|
606
746
|
foreground=[
|
607
|
-
("active", palette
|
747
|
+
("active", palette.get("hover_text", "#000000")),
|
608
748
|
("disabled", palette["foreground"]),
|
609
749
|
],
|
610
750
|
)
|
@@ -619,11 +759,47 @@ class TalksReducerGUI:
|
|
619
759
|
foreground=palette["foreground"],
|
620
760
|
)
|
621
761
|
|
762
|
+
# Configure progress bar styles for different states
|
763
|
+
self.style.configure(
|
764
|
+
"Idle.Horizontal.TProgressbar",
|
765
|
+
background=STATUS_COLORS["idle"],
|
766
|
+
troughcolor=palette["surface"],
|
767
|
+
borderwidth=0,
|
768
|
+
thickness=20,
|
769
|
+
)
|
770
|
+
self.style.configure(
|
771
|
+
"Processing.Horizontal.TProgressbar",
|
772
|
+
background=STATUS_COLORS["processing"],
|
773
|
+
troughcolor=palette["surface"],
|
774
|
+
borderwidth=0,
|
775
|
+
thickness=20,
|
776
|
+
)
|
777
|
+
self.style.configure(
|
778
|
+
"Success.Horizontal.TProgressbar",
|
779
|
+
background=STATUS_COLORS["success"],
|
780
|
+
troughcolor=palette["surface"],
|
781
|
+
borderwidth=0,
|
782
|
+
thickness=20,
|
783
|
+
)
|
784
|
+
self.style.configure(
|
785
|
+
"Error.Horizontal.TProgressbar",
|
786
|
+
background=STATUS_COLORS["error"],
|
787
|
+
troughcolor=palette["surface"],
|
788
|
+
borderwidth=0,
|
789
|
+
thickness=20,
|
790
|
+
)
|
791
|
+
self.style.configure(
|
792
|
+
"Aborted.Horizontal.TProgressbar",
|
793
|
+
background=STATUS_COLORS["aborted"],
|
794
|
+
troughcolor=palette["surface"],
|
795
|
+
borderwidth=0,
|
796
|
+
thickness=20,
|
797
|
+
)
|
798
|
+
|
622
799
|
self.drop_zone.configure(
|
623
800
|
bg=palette["surface"],
|
624
801
|
fg=palette["foreground"],
|
625
|
-
|
626
|
-
highlightcolor=palette["border"],
|
802
|
+
highlightthickness=0,
|
627
803
|
)
|
628
804
|
self.input_list.configure(
|
629
805
|
bg=palette["surface"],
|
@@ -683,14 +859,19 @@ class TalksReducerGUI:
|
|
683
859
|
widget.dnd_bind("<<Drop>>", self._on_drop) # type: ignore[attr-defined]
|
684
860
|
|
685
861
|
# -------------------------------------------------------------- actions --
|
686
|
-
def
|
687
|
-
files
|
862
|
+
def _ask_for_input_files(self) -> tuple[str, ...]:
|
863
|
+
"""Prompt the user to select input files for processing."""
|
864
|
+
|
865
|
+
return self.filedialog.askopenfilenames(
|
688
866
|
title="Select input files",
|
689
867
|
filetypes=[
|
690
868
|
("Video files", "*.mp4 *.mkv *.mov *.avi *.m4v"),
|
691
869
|
("All", "*.*"),
|
692
870
|
],
|
693
871
|
)
|
872
|
+
|
873
|
+
def _add_files(self) -> None:
|
874
|
+
files = self._ask_for_input_files()
|
694
875
|
self._extend_inputs(files)
|
695
876
|
|
696
877
|
def _add_directory(self) -> None:
|
@@ -714,14 +895,32 @@ class TalksReducerGUI:
|
|
714
895
|
self.input_list.delete(index)
|
715
896
|
del self.input_files[index]
|
716
897
|
|
898
|
+
def _clear_input_files(self) -> None:
|
899
|
+
"""Clear all input files from the list."""
|
900
|
+
self.input_files.clear()
|
901
|
+
self.input_list.delete(0, self.tk.END)
|
902
|
+
|
717
903
|
def _on_drop(self, event: object) -> None:
|
718
904
|
data = getattr(event, "data", "")
|
719
905
|
if not data:
|
720
906
|
return
|
721
907
|
paths = self.root.tk.splitlist(data)
|
722
908
|
cleaned = [path.strip("{}") for path in paths]
|
909
|
+
# Clear existing files before adding dropped files
|
910
|
+
self.input_files.clear()
|
911
|
+
self.input_list.delete(0, self.tk.END)
|
723
912
|
self._extend_inputs(cleaned, auto_run=True)
|
724
913
|
|
914
|
+
def _on_drop_zone_click(self, event: object) -> str | None:
|
915
|
+
"""Open a file selection dialog when the drop zone is activated."""
|
916
|
+
|
917
|
+
files = self._ask_for_input_files()
|
918
|
+
if not files:
|
919
|
+
return "break"
|
920
|
+
self._clear_input_files()
|
921
|
+
self._extend_inputs(files, auto_run=True)
|
922
|
+
return "break"
|
923
|
+
|
725
924
|
def _browse_path(
|
726
925
|
self, variable, label: str
|
727
926
|
) -> None: # type: (tk.StringVar, str) -> None
|
@@ -753,10 +952,16 @@ class TalksReducerGUI:
|
|
753
952
|
return
|
754
953
|
|
755
954
|
self._append_log("Starting processing…")
|
756
|
-
self.
|
955
|
+
self._stop_requested = False
|
956
|
+
open_after_convert = bool(self.open_after_convert_var.get())
|
757
957
|
|
758
958
|
def worker() -> None:
|
759
|
-
|
959
|
+
def set_process(proc: subprocess.Popen) -> None:
|
960
|
+
self._ffmpeg_process = proc
|
961
|
+
|
962
|
+
reporter = _TkProgressReporter(
|
963
|
+
self._append_log, process_callback=set_process
|
964
|
+
)
|
760
965
|
try:
|
761
966
|
files = gather_input_files(self.input_files)
|
762
967
|
if not files:
|
@@ -776,30 +981,77 @@ class TalksReducerGUI:
|
|
776
981
|
result = speed_up_video(options, reporter=reporter)
|
777
982
|
self._last_output = result.output_file
|
778
983
|
self._append_log(f"Completed: {result.output_file}")
|
779
|
-
|
780
|
-
|
781
|
-
|
984
|
+
if open_after_convert:
|
985
|
+
self._notify(
|
986
|
+
lambda path=result.output_file: self._open_in_file_manager(
|
987
|
+
path
|
988
|
+
)
|
989
|
+
)
|
782
990
|
|
783
991
|
self._append_log("All jobs finished successfully.")
|
784
992
|
self._notify(lambda: self.open_button.configure(state=self.tk.NORMAL))
|
993
|
+
self._notify(self._clear_input_files)
|
785
994
|
except FFmpegNotFoundError as exc:
|
786
995
|
self._notify(
|
787
996
|
lambda: self.messagebox.showerror("FFmpeg not found", str(exc))
|
788
997
|
)
|
789
998
|
self._set_status("Error")
|
790
999
|
except Exception as exc: # pragma: no cover - GUI level safeguard
|
791
|
-
|
792
|
-
|
793
|
-
|
1000
|
+
# If stop was requested, don't show error (FFmpeg termination is expected)
|
1001
|
+
if self._stop_requested:
|
1002
|
+
self._append_log("Processing aborted by user.")
|
1003
|
+
self._set_status("Aborted")
|
1004
|
+
else:
|
1005
|
+
self._notify(
|
1006
|
+
lambda: self.messagebox.showerror(
|
1007
|
+
"Error", f"Processing failed: {exc}"
|
1008
|
+
)
|
794
1009
|
)
|
795
|
-
|
796
|
-
self._set_status("Error")
|
1010
|
+
self._set_status("Error")
|
797
1011
|
finally:
|
798
|
-
self._notify(
|
1012
|
+
self._notify(self._hide_stop_button)
|
799
1013
|
|
800
1014
|
self._processing_thread = threading.Thread(target=worker, daemon=True)
|
801
1015
|
self._processing_thread.start()
|
802
1016
|
|
1017
|
+
# Show Stop button when processing starts
|
1018
|
+
self.stop_button.grid()
|
1019
|
+
|
1020
|
+
def _stop_processing(self) -> None:
|
1021
|
+
"""Stop the currently running processing by terminating FFmpeg."""
|
1022
|
+
import signal
|
1023
|
+
|
1024
|
+
self._stop_requested = True
|
1025
|
+
if self._ffmpeg_process and self._ffmpeg_process.poll() is None:
|
1026
|
+
self._append_log("Stopping FFmpeg process...")
|
1027
|
+
try:
|
1028
|
+
# Send SIGTERM to FFmpeg process
|
1029
|
+
if sys.platform == "win32":
|
1030
|
+
# Windows doesn't have SIGTERM, use terminate()
|
1031
|
+
self._ffmpeg_process.terminate()
|
1032
|
+
else:
|
1033
|
+
# Unix-like systems can use SIGTERM
|
1034
|
+
self._ffmpeg_process.send_signal(signal.SIGTERM)
|
1035
|
+
|
1036
|
+
self._append_log("FFmpeg process stopped.")
|
1037
|
+
except Exception as e:
|
1038
|
+
self._append_log(f"Error stopping process: {e}")
|
1039
|
+
else:
|
1040
|
+
self._append_log("No active FFmpeg process to stop.")
|
1041
|
+
|
1042
|
+
self._hide_stop_button()
|
1043
|
+
|
1044
|
+
def _hide_stop_button(self) -> None:
|
1045
|
+
"""Hide Stop button."""
|
1046
|
+
self.stop_button.grid_remove()
|
1047
|
+
# Show drop hint when stop button is hidden and no other buttons are visible
|
1048
|
+
if (
|
1049
|
+
not self.open_button.winfo_viewable()
|
1050
|
+
and hasattr(self, "drop_hint_button")
|
1051
|
+
and not self.drop_hint_button.winfo_viewable()
|
1052
|
+
):
|
1053
|
+
self.drop_hint_button.grid()
|
1054
|
+
|
803
1055
|
def _collect_arguments(self) -> dict[str, object]:
|
804
1056
|
args: dict[str, object] = {}
|
805
1057
|
|
@@ -884,17 +1136,59 @@ class TalksReducerGUI:
|
|
884
1136
|
normalized = message.strip().lower()
|
885
1137
|
if "all jobs finished successfully" in normalized:
|
886
1138
|
self._set_status("Success")
|
1139
|
+
self._set_progress(100) # 100% on success
|
1140
|
+
self._video_duration_seconds = None # Reset for next video
|
1141
|
+
elif normalized.startswith("extracting audio"):
|
1142
|
+
self._set_status("Extracting audio...")
|
1143
|
+
self._set_progress(0) # 0% on start
|
1144
|
+
self._video_duration_seconds = None # Reset for new processing
|
887
1145
|
elif normalized.startswith("starting processing") or normalized.startswith(
|
888
1146
|
"processing"
|
889
1147
|
):
|
890
1148
|
self._set_status("Processing")
|
1149
|
+
self._set_progress(0) # 0% on start
|
1150
|
+
self._video_duration_seconds = None # Reset for new processing
|
1151
|
+
|
1152
|
+
# Parse video duration from FFmpeg output
|
1153
|
+
duration_match = re.search(r"Duration:\s*(\d{2}):(\d{2}):(\d{2}\.\d+)", message)
|
1154
|
+
if duration_match:
|
1155
|
+
hours = int(duration_match.group(1))
|
1156
|
+
minutes = int(duration_match.group(2))
|
1157
|
+
seconds = float(duration_match.group(3))
|
1158
|
+
self._video_duration_seconds = hours * 3600 + minutes * 60 + seconds
|
1159
|
+
|
1160
|
+
# Parse FFmpeg progress information (time and speed)
|
1161
|
+
time_match = re.search(r"time=(\d{2}):(\d{2}):(\d{2})\.\d+", message)
|
1162
|
+
speed_match = re.search(r"speed=\s*([\d.]+)x", message)
|
1163
|
+
|
1164
|
+
if time_match and speed_match:
|
1165
|
+
hours = int(time_match.group(1))
|
1166
|
+
minutes = int(time_match.group(2))
|
1167
|
+
seconds = int(time_match.group(3))
|
1168
|
+
time_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
1169
|
+
speed_str = speed_match.group(1)
|
1170
|
+
|
1171
|
+
# Calculate percentage if we have duration
|
1172
|
+
if self._video_duration_seconds and self._video_duration_seconds > 0:
|
1173
|
+
current_seconds = hours * 3600 + minutes * 60 + seconds
|
1174
|
+
percentage = min(
|
1175
|
+
100, int((current_seconds / self._video_duration_seconds) * 100)
|
1176
|
+
)
|
1177
|
+
self._set_status(f"{time_str}, {speed_str}x ({percentage}%)")
|
1178
|
+
self._set_progress(percentage) # Update progress bar
|
1179
|
+
else:
|
1180
|
+
self._set_status(f"{time_str}, {speed_str}x")
|
891
1181
|
|
892
1182
|
def _apply_status_style(self, status: str) -> None:
|
893
1183
|
color = STATUS_COLORS.get(status.lower())
|
894
1184
|
if color:
|
895
1185
|
self.status_label.configure(fg=color)
|
896
1186
|
else:
|
897
|
-
|
1187
|
+
# For extracting audio or FFmpeg progress messages, use processing color
|
1188
|
+
if "extracting audio" in status.lower() or re.search(
|
1189
|
+
r"\d{2}:\d{2}:\d{2}.*\d+\.?\d*x", status
|
1190
|
+
):
|
1191
|
+
self.status_label.configure(fg=STATUS_COLORS["processing"])
|
898
1192
|
|
899
1193
|
def _set_status(self, status: str) -> None:
|
900
1194
|
def apply() -> None:
|
@@ -902,22 +1196,39 @@ class TalksReducerGUI:
|
|
902
1196
|
self._status_state = status
|
903
1197
|
self.status_var.set(status)
|
904
1198
|
self._apply_status_style(status)
|
1199
|
+
self._set_progress_bar_style(status)
|
905
1200
|
lowered = status.lower()
|
906
|
-
|
907
|
-
|
1201
|
+
is_processing = lowered == "processing" or "extracting audio" in lowered
|
1202
|
+
|
1203
|
+
if is_processing:
|
908
1204
|
self._start_status_animation()
|
909
|
-
|
910
|
-
if
|
911
|
-
self.
|
1205
|
+
# Show stop button during processing
|
1206
|
+
if hasattr(self, "status_frame"):
|
1207
|
+
self.status_frame.grid()
|
1208
|
+
self.stop_button.grid()
|
1209
|
+
self.drop_hint_button.grid_remove()
|
912
1210
|
|
913
1211
|
if lowered == "success":
|
914
|
-
if self.simple_mode_var.get():
|
915
|
-
self.
|
1212
|
+
if self.simple_mode_var.get() and hasattr(self, "status_frame"):
|
1213
|
+
self.status_frame.grid()
|
1214
|
+
self.stop_button.grid_remove()
|
1215
|
+
self.drop_hint_button.grid_remove()
|
916
1216
|
self.open_button.grid()
|
1217
|
+
self.open_button.lift() # Ensure open_button is above drop_hint_button
|
1218
|
+
print("success status")
|
917
1219
|
else:
|
918
1220
|
self.open_button.grid_remove()
|
919
|
-
|
920
|
-
|
1221
|
+
print("not success status")
|
1222
|
+
if (
|
1223
|
+
self.simple_mode_var.get()
|
1224
|
+
and not is_processing
|
1225
|
+
and hasattr(self, "status_frame")
|
1226
|
+
):
|
1227
|
+
self.status_frame.grid_remove()
|
1228
|
+
self.stop_button.grid_remove()
|
1229
|
+
# Show drop hint when no other buttons are visible
|
1230
|
+
if hasattr(self, "drop_hint_button"):
|
1231
|
+
self.drop_hint_button.grid()
|
921
1232
|
|
922
1233
|
self.root.after(0, apply)
|
923
1234
|
|
@@ -945,6 +1256,100 @@ class TalksReducerGUI:
|
|
945
1256
|
if self._status_state.lower() != "processing":
|
946
1257
|
self.status_var.set(self._status_state)
|
947
1258
|
|
1259
|
+
def _calculate_gradient_color(self, percentage: int, darken: float = 1.0) -> str:
|
1260
|
+
"""Calculate color gradient from red (0%) to green (100%).
|
1261
|
+
|
1262
|
+
Args:
|
1263
|
+
percentage: The position in the gradient (0-100)
|
1264
|
+
darken: Value between 0.0 (black) and 1.0 (original brightness)
|
1265
|
+
|
1266
|
+
Returns:
|
1267
|
+
Hex color code string
|
1268
|
+
"""
|
1269
|
+
# Clamp percentage between 0 and 100
|
1270
|
+
percentage = max(0, min(100, percentage))
|
1271
|
+
# Clamp darken between 0.0 and 1.0
|
1272
|
+
darken = max(0.0, min(1.0, darken))
|
1273
|
+
|
1274
|
+
if percentage <= 50:
|
1275
|
+
# Red to Yellow (0% to 50%)
|
1276
|
+
# Red: (248, 113, 113) -> Yellow: (250, 204, 21)
|
1277
|
+
ratio = percentage / 50.0
|
1278
|
+
r = int((248 + (250 - 248) * ratio) * darken)
|
1279
|
+
g = int((113 + (204 - 113) * ratio) * darken)
|
1280
|
+
b = int((113 + (21 - 113) * ratio) * darken)
|
1281
|
+
else:
|
1282
|
+
# Yellow to Green (50% to 100%)
|
1283
|
+
# Yellow: (250, 204, 21) -> Green: (34, 197, 94)
|
1284
|
+
ratio = (percentage - 50) / 50.0
|
1285
|
+
r = int((250 + (34 - 250) * ratio) * darken)
|
1286
|
+
g = int((204 + (197 - 204) * ratio) * darken)
|
1287
|
+
b = int((21 + (94 - 21) * ratio) * darken)
|
1288
|
+
|
1289
|
+
# Ensure values are within 0-255 range after darkening
|
1290
|
+
r = max(0, min(255, r))
|
1291
|
+
g = max(0, min(255, g))
|
1292
|
+
b = max(0, min(255, b))
|
1293
|
+
|
1294
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
1295
|
+
|
1296
|
+
def _set_progress(self, percentage: int) -> None:
|
1297
|
+
"""Update the progress bar value and color (thread-safe)."""
|
1298
|
+
|
1299
|
+
def updater() -> None:
|
1300
|
+
self.progress_var.set(percentage)
|
1301
|
+
# Update color based on percentage gradient
|
1302
|
+
color = self._calculate_gradient_color(percentage, 0.5)
|
1303
|
+
palette = (
|
1304
|
+
LIGHT_THEME if self._detect_system_theme() == "light" else DARK_THEME
|
1305
|
+
)
|
1306
|
+
if self.theme_var.get().lower() in {"light", "dark"}:
|
1307
|
+
palette = (
|
1308
|
+
LIGHT_THEME
|
1309
|
+
if self.theme_var.get().lower() == "light"
|
1310
|
+
else DARK_THEME
|
1311
|
+
)
|
1312
|
+
|
1313
|
+
self.style.configure(
|
1314
|
+
"Dynamic.Horizontal.TProgressbar",
|
1315
|
+
background=color,
|
1316
|
+
troughcolor=palette["surface"],
|
1317
|
+
borderwidth=0,
|
1318
|
+
thickness=20,
|
1319
|
+
)
|
1320
|
+
self.progress_bar.configure(style="Dynamic.Horizontal.TProgressbar")
|
1321
|
+
|
1322
|
+
# Show stop button when progress < 100
|
1323
|
+
if percentage < 100:
|
1324
|
+
if hasattr(self, "status_frame"):
|
1325
|
+
self.status_frame.grid()
|
1326
|
+
self.stop_button.grid()
|
1327
|
+
self.drop_hint_button.grid_remove()
|
1328
|
+
|
1329
|
+
self.root.after(0, updater)
|
1330
|
+
|
1331
|
+
def _set_progress_bar_style(self, status: str) -> None:
|
1332
|
+
"""Update the progress bar color based on status."""
|
1333
|
+
|
1334
|
+
def updater() -> None:
|
1335
|
+
# Map status to progress bar style
|
1336
|
+
status_lower = status.lower()
|
1337
|
+
if status_lower == "success":
|
1338
|
+
style = "Success.Horizontal.TProgressbar"
|
1339
|
+
elif status_lower == "error":
|
1340
|
+
style = "Error.Horizontal.TProgressbar"
|
1341
|
+
elif status_lower == "aborted":
|
1342
|
+
style = "Aborted.Horizontal.TProgressbar"
|
1343
|
+
elif status_lower == "idle":
|
1344
|
+
style = "Idle.Horizontal.TProgressbar"
|
1345
|
+
else:
|
1346
|
+
# For processing states, use dynamic gradient (will be set by _set_progress)
|
1347
|
+
return
|
1348
|
+
|
1349
|
+
self.progress_bar.configure(style=style)
|
1350
|
+
|
1351
|
+
self.root.after(0, updater)
|
1352
|
+
|
948
1353
|
def _notify(self, callback: Callable[[], None]) -> None:
|
949
1354
|
self.root.after(0, callback)
|
950
1355
|
|
@@ -996,7 +1401,21 @@ def main(argv: Optional[Sequence[str]] = None) -> bool:
|
|
996
1401
|
print()
|
997
1402
|
print("Run 'python3 -m talks_reducer --help' for all options.")
|
998
1403
|
print()
|
999
|
-
print("
|
1404
|
+
print("Troubleshooting tips:")
|
1405
|
+
if sys.platform == "darwin":
|
1406
|
+
print(
|
1407
|
+
" - On macOS, install Python from python.org or ensure "
|
1408
|
+
"Homebrew's python-tk package is present."
|
1409
|
+
)
|
1410
|
+
elif sys.platform.startswith("linux"):
|
1411
|
+
print(
|
1412
|
+
" - On Linux, install the Tk bindings for Python (for example, "
|
1413
|
+
"python3-tk)."
|
1414
|
+
)
|
1415
|
+
else:
|
1416
|
+
print(" - Ensure your Python installation includes Tk support.")
|
1417
|
+
print(" - You can always fall back to the CLI workflow below.")
|
1418
|
+
print()
|
1000
1419
|
print("The CLI interface works perfectly and is recommended.")
|
1001
1420
|
except UnicodeEncodeError:
|
1002
1421
|
# Fallback for extreme encoding issues
|
@@ -157,12 +157,15 @@ def speed_up_video(
|
|
157
157
|
ffmpeg_path=ffmpeg_path,
|
158
158
|
)
|
159
159
|
|
160
|
+
reporter.log("Extracting audio...")
|
161
|
+
process_callback = getattr(reporter, 'process_callback', None)
|
160
162
|
run_timed_ffmpeg_command(
|
161
163
|
extract_command,
|
162
164
|
reporter=reporter,
|
163
165
|
total=int(original_duration * frame_rate),
|
164
166
|
unit="frames",
|
165
167
|
desc="Extracting audio:",
|
168
|
+
process_callback=process_callback,
|
166
169
|
)
|
167
170
|
|
168
171
|
wav_sample_rate, audio_data = wavfile.read(os.fspath(audio_wav))
|
@@ -249,6 +252,7 @@ def speed_up_video(
|
|
249
252
|
total=updated_chunks[-1][3],
|
250
253
|
unit="frames",
|
251
254
|
desc="Generating final:",
|
255
|
+
process_callback=process_callback,
|
252
256
|
)
|
253
257
|
except subprocess.CalledProcessError as exc:
|
254
258
|
if fallback_command_str and use_cuda_encoder:
|
@@ -259,6 +263,7 @@ def speed_up_video(
|
|
259
263
|
total=updated_chunks[-1][3],
|
260
264
|
unit="frames",
|
261
265
|
desc="Generating final (fallback):",
|
266
|
+
process_callback=process_callback,
|
262
267
|
)
|
263
268
|
else:
|
264
269
|
raise
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: talks-reducer
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.3.1
|
4
4
|
Summary: CLI for speeding up long-form talks by removing silence
|
5
5
|
Author: Talks Reducer Maintainers
|
6
6
|
License-Expression: MIT
|
@@ -9,7 +9,7 @@ Description-Content-Type: text/markdown
|
|
9
9
|
License-File: LICENSE
|
10
10
|
Requires-Dist: audiotsm>=0.1.2
|
11
11
|
Requires-Dist: scipy>=1.10.0
|
12
|
-
Requires-Dist: numpy
|
12
|
+
Requires-Dist: numpy>=1.22.0
|
13
13
|
Requires-Dist: tqdm>=4.65.0
|
14
14
|
Requires-Dist: tkinterdnd2>=0.3.0
|
15
15
|
Requires-Dist: Pillow>=9.0.0
|
@@ -82,9 +82,12 @@ continues to work unchanged for local development.
|
|
82
82
|
zone, hides the manual run controls and log, and automatically processes new
|
83
83
|
files as soon as you drop them. Uncheck the box to return to the full layout
|
84
84
|
with file pickers, the Run button, and detailed logging.
|
85
|
-
- **Input drop zone** — drag files or folders from your desktop
|
86
|
-
the Explorer/Finder dialog; duplicates
|
85
|
+
- **Input drop zone** — drag files or folders from your desktop, click to open
|
86
|
+
the system file picker, or add them via the Explorer/Finder dialog; duplicates
|
87
|
+
are ignored.
|
87
88
|
- **Small video** — toggles the `--small` preset used by the CLI.
|
89
|
+
- **Open after convert** — controls whether the exported file is revealed in
|
90
|
+
your system file manager as soon as each job finishes.
|
88
91
|
- **Advanced** — reveals optional controls for the output path, temp folder,
|
89
92
|
timing/audio knobs mirrored from the command line, and an appearance picker
|
90
93
|
that can force dark or light mode or follow your operating system.
|
@@ -94,6 +97,10 @@ a background thread. Once every queued job succeeds an **Open last output**
|
|
94
97
|
button appears so you can jump straight to the exported file in your system
|
95
98
|
file manager.
|
96
99
|
|
100
|
+
The GUI stores your last-used Simple mode, Small video, Open after convert, and
|
101
|
+
theme preferences in a cross-platform configuration file so they persist across
|
102
|
+
launches.
|
103
|
+
|
97
104
|
## Repository Structure
|
98
105
|
- `talks_reducer/` — Python package that exposes the CLI and reusable pipeline:
|
99
106
|
- `cli.py` parses arguments and dispatches to the pipeline.
|
@@ -0,0 +1,47 @@
|
|
1
|
+
"""Tests for the audio helper utilities."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import types
|
6
|
+
|
7
|
+
from talks_reducer import audio
|
8
|
+
|
9
|
+
|
10
|
+
def _make_completed_process(stdout: str = "", stderr: str = "", returncode: int = 0):
|
11
|
+
"""Create a minimal object emulating :class:`subprocess.CompletedProcess`."""
|
12
|
+
|
13
|
+
completed = types.SimpleNamespace()
|
14
|
+
completed.stdout = stdout
|
15
|
+
completed.stderr = stderr
|
16
|
+
completed.returncode = returncode
|
17
|
+
return completed
|
18
|
+
|
19
|
+
|
20
|
+
def test_is_valid_input_file_accepts_warnings(monkeypatch):
|
21
|
+
"""A warning written to stderr should not invalidate a valid audio file."""
|
22
|
+
|
23
|
+
monkeypatch.setattr(audio, "get_ffprobe_path", lambda: "ffprobe")
|
24
|
+
|
25
|
+
def fake_run(*args, **kwargs):
|
26
|
+
return _make_completed_process(
|
27
|
+
stdout="[STREAM]\ncodec_type=audio\n[/STREAM]\n",
|
28
|
+
stderr="Configuration warning",
|
29
|
+
returncode=0,
|
30
|
+
)
|
31
|
+
|
32
|
+
monkeypatch.setattr(audio.subprocess, "run", fake_run)
|
33
|
+
|
34
|
+
assert audio.is_valid_input_file("example.mp4") is True
|
35
|
+
|
36
|
+
|
37
|
+
def test_is_valid_input_file_requires_audio_stream(monkeypatch):
|
38
|
+
"""Return ``False`` when ffprobe completes but finds no audio stream."""
|
39
|
+
|
40
|
+
monkeypatch.setattr(audio, "get_ffprobe_path", lambda: "ffprobe")
|
41
|
+
|
42
|
+
def fake_run(*args, **kwargs):
|
43
|
+
return _make_completed_process(stdout="", stderr="", returncode=0)
|
44
|
+
|
45
|
+
monkeypatch.setattr(audio.subprocess, "run", fake_run)
|
46
|
+
|
47
|
+
assert audio.is_valid_input_file("silent.mp4") is False
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|