talks-reducer 0.2.23__tar.gz → 0.3.0__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.
Files changed (22) hide show
  1. {talks_reducer-0.2.23/talks_reducer.egg-info → talks_reducer-0.3.0}/PKG-INFO +7 -1
  2. {talks_reducer-0.2.23 → talks_reducer-0.3.0}/README.md +6 -0
  3. {talks_reducer-0.2.23 → talks_reducer-0.3.0}/pyproject.toml +1 -1
  4. {talks_reducer-0.2.23 → talks_reducer-0.3.0}/talks_reducer/cli.py +14 -0
  5. {talks_reducer-0.2.23 → talks_reducer-0.3.0}/talks_reducer/ffmpeg.py +13 -1
  6. {talks_reducer-0.2.23 → talks_reducer-0.3.0}/talks_reducer/gui.py +458 -71
  7. {talks_reducer-0.2.23 → talks_reducer-0.3.0}/talks_reducer/pipeline.py +5 -0
  8. {talks_reducer-0.2.23 → talks_reducer-0.3.0/talks_reducer.egg-info}/PKG-INFO +7 -1
  9. {talks_reducer-0.2.23 → talks_reducer-0.3.0}/LICENSE +0 -0
  10. {talks_reducer-0.2.23 → talks_reducer-0.3.0}/setup.cfg +0 -0
  11. {talks_reducer-0.2.23 → talks_reducer-0.3.0}/talks_reducer/__init__.py +0 -0
  12. {talks_reducer-0.2.23 → talks_reducer-0.3.0}/talks_reducer/__main__.py +0 -0
  13. {talks_reducer-0.2.23 → talks_reducer-0.3.0}/talks_reducer/audio.py +0 -0
  14. {talks_reducer-0.2.23 → talks_reducer-0.3.0}/talks_reducer/chunks.py +0 -0
  15. {talks_reducer-0.2.23 → talks_reducer-0.3.0}/talks_reducer/models.py +0 -0
  16. {talks_reducer-0.2.23 → talks_reducer-0.3.0}/talks_reducer/progress.py +0 -0
  17. {talks_reducer-0.2.23 → talks_reducer-0.3.0}/talks_reducer.egg-info/SOURCES.txt +0 -0
  18. {talks_reducer-0.2.23 → talks_reducer-0.3.0}/talks_reducer.egg-info/dependency_links.txt +0 -0
  19. {talks_reducer-0.2.23 → talks_reducer-0.3.0}/talks_reducer.egg-info/entry_points.txt +0 -0
  20. {talks_reducer-0.2.23 → talks_reducer-0.3.0}/talks_reducer.egg-info/requires.txt +0 -0
  21. {talks_reducer-0.2.23 → talks_reducer-0.3.0}/talks_reducer.egg-info/top_level.txt +0 -0
  22. {talks_reducer-0.2.23 → talks_reducer-0.3.0}/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.2.23
3
+ Version: 0.3.0
4
4
  Summary: CLI for speeding up long-form talks by removing silence
5
5
  Author: Talks Reducer Maintainers
6
6
  License-Expression: MIT
@@ -85,6 +85,8 @@ continues to work unchanged for local development.
85
85
  - **Input drop zone** — drag files or folders from your desktop or add them via
86
86
  the Explorer/Finder dialog; duplicates are ignored.
87
87
  - **Small video** — toggles the `--small` preset used by the CLI.
88
+ - **Open after convert** — controls whether the exported file is revealed in
89
+ your system file manager as soon as each job finishes.
88
90
  - **Advanced** — reveals optional controls for the output path, temp folder,
89
91
  timing/audio knobs mirrored from the command line, and an appearance picker
90
92
  that can force dark or light mode or follow your operating system.
@@ -94,6 +96,10 @@ a background thread. Once every queued job succeeds an **Open last output**
94
96
  button appears so you can jump straight to the exported file in your system
95
97
  file manager.
96
98
 
99
+ The GUI stores your last-used Simple mode, Small video, Open after convert, and
100
+ theme preferences in a cross-platform configuration file so they persist across
101
+ launches.
102
+
97
103
  ## Repository Structure
98
104
  - `talks_reducer/` — Python package that exposes the CLI and reusable pipeline:
99
105
  - `cli.py` parses arguments and dispatches to the pipeline.
@@ -60,6 +60,8 @@ continues to work unchanged for local development.
60
60
  - **Input drop zone** — drag files or folders from your desktop or add them via
61
61
  the Explorer/Finder dialog; duplicates are ignored.
62
62
  - **Small video** — toggles the `--small` preset used by the CLI.
63
+ - **Open after convert** — controls whether the exported file is revealed in
64
+ your system file manager as soon as each job finishes.
63
65
  - **Advanced** — reveals optional controls for the output path, temp folder,
64
66
  timing/audio knobs mirrored from the command line, and an appearance picker
65
67
  that can force dark or light mode or follow your operating system.
@@ -69,6 +71,10 @@ a background thread. Once every queued job succeeds an **Open last output**
69
71
  button appears so you can jump straight to the exported file in your system
70
72
  file manager.
71
73
 
74
+ The GUI stores your last-used Simple mode, Small video, Open after convert, and
75
+ theme preferences in a cross-platform configuration file so they persist across
76
+ launches.
77
+
72
78
  ## Repository Structure
73
79
  - `talks_reducer/` — Python package that exposes the CLI and reusable pipeline:
74
80
  - `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.2.23"
7
+ version = "0.3.0"
8
8
  description = "CLI for speeding up long-form talks by removing silence"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -6,6 +6,7 @@ import argparse
6
6
  import os
7
7
  import sys
8
8
  import time
9
+ from importlib.metadata import version
9
10
  from pathlib import Path
10
11
  from typing import Dict, List, Optional, Sequence
11
12
 
@@ -22,6 +23,19 @@ def _build_parser() -> argparse.ArgumentParser:
22
23
  parser = argparse.ArgumentParser(
23
24
  description="Modifies a video file to play at different speeds when there is sound vs. silence.",
24
25
  )
26
+
27
+ # Add version argument
28
+ try:
29
+ pkg_version = version("talks-reducer")
30
+ except Exception:
31
+ pkg_version = "unknown"
32
+
33
+ parser.add_argument(
34
+ "--version",
35
+ action="version",
36
+ version=f"talks-reducer {pkg_version}",
37
+ )
38
+
25
39
  parser.add_argument(
26
40
  "input_file",
27
41
  type=str,
@@ -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,9 +4,11 @@ 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
11
+ from importlib.metadata import version
10
12
  from pathlib import Path
11
13
  from typing import TYPE_CHECKING, Callable, Iterable, List, Optional, Sequence
12
14
 
@@ -116,9 +118,10 @@ except ModuleNotFoundError: # pragma: no cover - runtime dependency
116
118
 
117
119
  STATUS_COLORS = {
118
120
  "idle": "#9ca3af",
119
- "processing": "#facc15",
120
- "success": "#22c55e",
121
- "error": "#f87171",
121
+ "processing": "#af8e0e",
122
+ "success": "#178941",
123
+ "error": "#ad4f4f",
124
+ "aborted": "#6d727a",
122
125
  }
123
126
 
124
127
  LIGHT_THEME = {
@@ -127,7 +130,8 @@ LIGHT_THEME = {
127
130
  "accent": "#2563eb",
128
131
  "surface": "#ffffff",
129
132
  "border": "#cbd5e1",
130
- "hover": "#1d4ed8",
133
+ "hover": "#efefef",
134
+ "hover_text": "#000000",
131
135
  "selection_background": "#2563eb",
132
136
  "selection_foreground": "#ffffff",
133
137
  }
@@ -139,6 +143,7 @@ DARK_THEME = {
139
143
  "surface": "#2b2b3c",
140
144
  "border": "#4b5563",
141
145
  "hover": "#333333",
146
+ "hover_text": "#ffffff",
142
147
  "selection_background": "#333333",
143
148
  "selection_foreground": "#f3f4f6",
144
149
  }
@@ -185,8 +190,13 @@ class _GuiProgressHandle(ProgressHandle):
185
190
  class _TkProgressReporter(SignalProgressReporter):
186
191
  """Progress reporter that forwards updates to the GUI thread."""
187
192
 
188
- def __init__(self, log_callback: Callable[[str], None]) -> None:
193
+ def __init__(
194
+ self,
195
+ log_callback: Callable[[str], None],
196
+ process_callback: Optional[Callable] = None,
197
+ ) -> None:
189
198
  self._log_callback = log_callback
199
+ self.process_callback = process_callback
190
200
 
191
201
  def log(self, message: str) -> None:
192
202
  self._log_callback(message)
@@ -201,7 +211,55 @@ class _TkProgressReporter(SignalProgressReporter):
201
211
  class TalksReducerGUI:
202
212
  """Tkinter application mirroring the CLI options with form controls."""
203
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
+
204
259
  def __init__(self) -> None:
260
+ self._config_path = self._determine_config_path()
261
+ self._settings = self._load_settings()
262
+
205
263
  # Import tkinter here to avoid loading it at module import time
206
264
  import tkinter as tk
207
265
  from tkinter import filedialog, messagebox, ttk
@@ -216,11 +274,18 @@ class TalksReducerGUI:
216
274
  self.root = TkinterDnD.Tk() # type: ignore[call-arg]
217
275
  else:
218
276
  self.root = tk.Tk()
219
- self.root.title("Talks Reducer")
277
+
278
+ # Set window title with version
279
+ try:
280
+ app_version = version("talks-reducer")
281
+ self.root.title(f"Talks Reducer v{app_version}")
282
+ except Exception:
283
+ self.root.title("Talks Reducer")
284
+
220
285
  self._apply_window_icon()
221
286
 
222
287
  self._full_size = (760, 680)
223
- self._simple_size = (245, 300)
288
+ self._simple_size = (255, 330)
224
289
  self.root.geometry(f"{self._full_size[0]}x{self._full_size[1]}")
225
290
  self.style = self.ttk.Style(self.root)
226
291
 
@@ -230,21 +295,36 @@ class TalksReducerGUI:
230
295
  self.status_var = tk.StringVar(value=self._status_state)
231
296
  self._status_animation_job: Optional[str] = None
232
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
233
302
 
234
303
  self.input_files: List[str] = []
235
304
 
236
305
  self._dnd_available = TkinterDnD is not None and DND_FILES is not None
237
306
 
238
- self.simple_mode_var = tk.BooleanVar(value=True)
307
+ self.simple_mode_var = tk.BooleanVar(
308
+ value=self._get_setting("simple_mode", True)
309
+ )
239
310
  self.run_after_drop_var = tk.BooleanVar(value=True)
240
- self.small_var = tk.BooleanVar(value=True)
241
- self.theme_var = tk.StringVar(value="os")
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"))
242
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
+ )
243
321
 
244
322
  self._build_layout()
245
323
  self._apply_simple_mode(initial=True)
246
324
  self._apply_status_style(self._status_state)
247
325
  self._apply_theme()
326
+ self._save_settings()
327
+ self._hide_stop_button()
248
328
 
249
329
  if not self._dnd_available:
250
330
  self._append_log(
@@ -281,16 +361,17 @@ class TalksReducerGUI:
281
361
  continue
282
362
 
283
363
  def _build_layout(self) -> None:
284
- main = self.ttk.Frame(self.root, padding=16)
364
+ main = self.ttk.Frame(self.root, padding=self.PADDING)
285
365
  main.grid(row=0, column=0, sticky="nsew")
286
366
  self.root.columnconfigure(0, weight=1)
287
367
  self.root.rowconfigure(0, weight=1)
288
368
 
289
369
  # Input selection frame
290
- input_frame = self.ttk.LabelFrame(main, text="Input files", padding=12)
370
+ input_frame = self.ttk.Frame(main, padding=self.PADDING)
291
371
  input_frame.grid(row=0, column=0, sticky="nsew")
292
372
  main.rowconfigure(0, weight=1)
293
- for column in range(4):
373
+ main.columnconfigure(0, weight=1)
374
+ for column in range(5):
294
375
  input_frame.columnconfigure(column, weight=1)
295
376
 
296
377
  self.input_list = self.tk.Listbox(input_frame, height=5)
@@ -304,13 +385,13 @@ class TalksReducerGUI:
304
385
  self.drop_zone = self.tk.Label(
305
386
  input_frame,
306
387
  text="Drop files or folders here",
307
- relief=self.tk.RIDGE,
308
- borderwidth=2,
309
- padx=16,
310
- pady=16,
311
- highlightthickness=1,
388
+ relief=self.tk.FLAT,
389
+ borderwidth=0,
390
+ padx=self.PADDING,
391
+ pady=self.PADDING,
392
+ highlightthickness=0,
312
393
  )
313
- self.drop_zone.grid(row=1, column=0, columnspan=4, sticky="nsew")
394
+ self.drop_zone.grid(row=1, column=0, columnspan=5, sticky="nsew")
314
395
  input_frame.rowconfigure(1, weight=1)
315
396
  self._configure_drop_targets(self.drop_zone)
316
397
  self._configure_drop_targets(self.input_list)
@@ -335,8 +416,8 @@ class TalksReducerGUI:
335
416
  self.run_after_drop_check.grid(row=2, column=3, pady=8, sticky="e")
336
417
 
337
418
  # Options frame
338
- options = self.ttk.LabelFrame(main, text="Options", padding=12)
339
- options.grid(row=1, column=0, pady=(16, 0), sticky="nsew")
419
+ options = self.ttk.Frame(main, padding=self.PADDING)
420
+ options.grid(row=2, column=0, pady=(16, 0), sticky="ew")
340
421
  options.columnconfigure(0, weight=1)
341
422
 
342
423
  self.simple_mode_check = self.ttk.Checkbutton(
@@ -351,6 +432,12 @@ class TalksReducerGUI:
351
432
  row=1, column=0, sticky="w", pady=(8, 0)
352
433
  )
353
434
 
435
+ self.ttk.Checkbutton(
436
+ options,
437
+ text="Open after convert",
438
+ variable=self.open_after_convert_var,
439
+ ).grid(row=2, column=0, sticky="w", pady=(8, 0))
440
+
354
441
  self.advanced_visible = self.tk.BooleanVar(value=False)
355
442
  self.advanced_button = self.ttk.Button(
356
443
  options,
@@ -359,8 +446,8 @@ class TalksReducerGUI:
359
446
  )
360
447
  self.advanced_button.grid(row=0, column=1, sticky="e")
361
448
 
362
- self.advanced_frame = self.ttk.Frame(options, padding=(0, 12, 0, 0))
363
- self.advanced_frame.grid(row=2, column=0, columnspan=2, sticky="nsew")
449
+ self.advanced_frame = self.ttk.Frame(options, padding=self.PADDING)
450
+ self.advanced_frame.grid(row=3, column=0, columnspan=2, sticky="nsew")
364
451
  self.advanced_frame.columnconfigure(1, weight=1)
365
452
 
366
453
  self.output_var = self.tk.StringVar()
@@ -416,33 +503,54 @@ class TalksReducerGUI:
416
503
  self._toggle_advanced(initial=True)
417
504
 
418
505
  # Action buttons and log output
419
- self.actions_frame = self.ttk.Frame(main)
420
- self.actions_frame.grid(row=2, column=0, pady=(16, 0), sticky="ew")
421
- self.actions_frame.columnconfigure(1, weight=1)
506
+ status_frame = self.ttk.Frame(main, padding=self.PADDING)
507
+ status_frame.grid(row=1, column=0, sticky="ew")
508
+ status_frame.columnconfigure(0, weight=0)
509
+ status_frame.columnconfigure(1, weight=1)
510
+ status_frame.columnconfigure(2, weight=0)
511
+
512
+
513
+ self.ttk.Label(status_frame, text="Status:").grid(row=0, column=0, sticky="w")
514
+ self.status_label = self.tk.Label(status_frame, textvariable=self.status_var, anchor="e")
515
+ self.status_label.grid(row=0, column=1, sticky="e")
516
+
517
+ # Progress bar
518
+ self.progress_bar = self.ttk.Progressbar(
519
+ status_frame,
520
+ variable=self.progress_var,
521
+ maximum=100,
522
+ mode="determinate",
523
+ style="Idle.Horizontal.TProgressbar",
524
+ )
525
+ self.progress_bar.grid(row=1, column=0, columnspan=3, sticky="ew", pady=(0, 0))
422
526
 
423
- self.run_button = self.ttk.Button(
424
- self.actions_frame, text="Run", command=self._start_run
527
+ self.stop_button = self.ttk.Button(
528
+ status_frame, text="Stop", command=self._stop_processing
425
529
  )
426
- self.run_button.grid(row=0, column=0, sticky="w")
530
+ self.stop_button.grid(row=2, column=0, columnspan=3, sticky="ew", pady=self.PADDING)
531
+ self.stop_button.grid_remove() # Hidden by default
427
532
 
428
533
  self.open_button = self.ttk.Button(
429
- self.actions_frame,
430
- text="Open last output",
534
+ status_frame,
535
+ text="Open last",
431
536
  command=self._open_last_output,
432
537
  state=self.tk.DISABLED,
433
538
  )
434
- self.open_button.grid(row=0, column=1, sticky="e")
539
+ self.open_button.grid(row=2, column=0, columnspan=3, sticky="ew", pady=self.PADDING)
435
540
  self.open_button.grid_remove()
436
541
 
437
- status_frame = self.ttk.Frame(main, padding=(0, 8, 0, 0))
438
- status_frame.grid(row=3, column=0, sticky="ew")
439
- status_frame.columnconfigure(1, weight=1)
440
- self.ttk.Label(status_frame, text="Status:").grid(row=0, column=0, sticky="w")
441
- self.status_label = self.tk.Label(status_frame, textvariable=self.status_var)
442
- self.status_label.grid(row=0, column=1, sticky="w")
542
+ # Button shown when no other action buttons are visible
543
+ self.drop_hint_button = self.ttk.Button(
544
+ status_frame,
545
+ text="Drop video to convert",
546
+ state=self.tk.DISABLED,
547
+ )
548
+ self.drop_hint_button.grid(row=2, column=0, columnspan=3, sticky="ew", pady=self.PADDING)
549
+ self.drop_hint_button.grid_remove() # Hidden by default
550
+ self._configure_drop_targets(self.drop_hint_button)
443
551
 
444
- self.log_frame = self.ttk.LabelFrame(main, text="Log", padding=12)
445
- self.log_frame.grid(row=4, column=0, pady=(16, 0), sticky="nsew")
552
+ self.log_frame = self.ttk.Frame(main, padding=self.PADDING)
553
+ self.log_frame.grid(row=3, column=0, pady=(16, 0), sticky="nsew")
446
554
  main.rowconfigure(4, weight=1)
447
555
  self.log_frame.columnconfigure(0, weight=1)
448
556
  self.log_frame.rowconfigure(0, weight=1)
@@ -478,6 +586,7 @@ class TalksReducerGUI:
478
586
  button.grid(row=row, column=2, padx=(8, 0))
479
587
 
480
588
  def _toggle_simple_mode(self) -> None:
589
+ self._update_setting("simple_mode", self.simple_mode_var.get())
481
590
  self._apply_simple_mode()
482
591
 
483
592
  def _apply_simple_mode(self, *, initial: bool = False) -> None:
@@ -495,21 +604,23 @@ class TalksReducerGUI:
495
604
  for widget in widgets:
496
605
  widget.grid_remove()
497
606
  self.log_frame.grid_remove()
498
- self.run_button.grid_remove()
607
+ self.stop_button.grid_remove()
499
608
  self.advanced_button.grid_remove()
500
609
  self.advanced_frame.grid_remove()
501
- self.actions_frame.grid_remove()
610
+ if hasattr(self, 'status_frame'):
611
+ self.status_frame.grid_remove()
502
612
  self.run_after_drop_var.set(True)
503
613
  self._apply_window_size(simple=True)
504
- if self.status_var.get().lower() == "success":
505
- self.actions_frame.grid()
614
+ if self.status_var.get().lower() == "success" and hasattr(self, 'status_frame'):
615
+ self.status_frame.grid()
506
616
  self.open_button.grid()
617
+ self.drop_hint_button.grid_remove()
507
618
  else:
508
619
  for widget in widgets:
509
620
  widget.grid()
510
621
  self.log_frame.grid()
511
- self.actions_frame.grid()
512
- self.run_button.grid()
622
+ if hasattr(self, 'status_frame'):
623
+ self.status_frame.grid()
513
624
  self.advanced_button.grid()
514
625
  if self.advanced_visible.get():
515
626
  self.advanced_frame.grid()
@@ -543,8 +654,17 @@ class TalksReducerGUI:
543
654
  self.advanced_button.configure(text="Advanced")
544
655
 
545
656
  def _on_theme_change(self, *_: object) -> None:
657
+ self._update_setting("theme", self.theme_var.get())
546
658
  self._apply_theme()
547
659
 
660
+ def _on_small_video_change(self, *_: object) -> None:
661
+ self._update_setting("small_video", bool(self.small_var.get()))
662
+
663
+ def _on_open_after_convert_change(self, *_: object) -> None:
664
+ self._update_setting(
665
+ "open_after_convert", bool(self.open_after_convert_var.get())
666
+ )
667
+
548
668
  def _apply_theme(self) -> None:
549
669
  preference = self.theme_var.get().lower()
550
670
  if preference not in {"light", "dark"}:
@@ -564,6 +684,8 @@ class TalksReducerGUI:
564
684
  "TLabelframe",
565
685
  background=palette["background"],
566
686
  foreground=palette["foreground"],
687
+ borderwidth=0,
688
+ relief="flat",
567
689
  )
568
690
  self.style.configure(
569
691
  "TLabelframe.Label",
@@ -578,11 +700,19 @@ class TalksReducerGUI:
578
700
  background=palette["background"],
579
701
  foreground=palette["foreground"],
580
702
  )
703
+ self.style.map(
704
+ "TCheckbutton",
705
+ background=[("active", palette.get("hover", palette["background"]))],
706
+ )
581
707
  self.style.configure(
582
708
  "TRadiobutton",
583
709
  background=palette["background"],
584
710
  foreground=palette["foreground"],
585
711
  )
712
+ self.style.map(
713
+ "TRadiobutton",
714
+ background=[("active", palette.get("hover", palette["background"]))],
715
+ )
586
716
  self.style.configure(
587
717
  "TButton",
588
718
  background=palette["surface"],
@@ -596,7 +726,7 @@ class TalksReducerGUI:
596
726
  ("disabled", palette["surface"]),
597
727
  ],
598
728
  foreground=[
599
- ("active", palette["surface"]),
729
+ ("active", palette.get("hover_text", "#000000")),
600
730
  ("disabled", palette["foreground"]),
601
731
  ],
602
732
  )
@@ -611,11 +741,47 @@ class TalksReducerGUI:
611
741
  foreground=palette["foreground"],
612
742
  )
613
743
 
744
+ # Configure progress bar styles for different states
745
+ self.style.configure(
746
+ "Idle.Horizontal.TProgressbar",
747
+ background=STATUS_COLORS["idle"],
748
+ troughcolor=palette["surface"],
749
+ borderwidth=0,
750
+ thickness=20,
751
+ )
752
+ self.style.configure(
753
+ "Processing.Horizontal.TProgressbar",
754
+ background=STATUS_COLORS["processing"],
755
+ troughcolor=palette["surface"],
756
+ borderwidth=0,
757
+ thickness=20,
758
+ )
759
+ self.style.configure(
760
+ "Success.Horizontal.TProgressbar",
761
+ background=STATUS_COLORS["success"],
762
+ troughcolor=palette["surface"],
763
+ borderwidth=0,
764
+ thickness=20,
765
+ )
766
+ self.style.configure(
767
+ "Error.Horizontal.TProgressbar",
768
+ background=STATUS_COLORS["error"],
769
+ troughcolor=palette["surface"],
770
+ borderwidth=0,
771
+ thickness=20,
772
+ )
773
+ self.style.configure(
774
+ "Aborted.Horizontal.TProgressbar",
775
+ background=STATUS_COLORS["aborted"],
776
+ troughcolor=palette["surface"],
777
+ borderwidth=0,
778
+ thickness=20,
779
+ )
780
+
614
781
  self.drop_zone.configure(
615
782
  bg=palette["surface"],
616
783
  fg=palette["foreground"],
617
- highlightbackground=palette["border"],
618
- highlightcolor=palette["border"],
784
+ highlightthickness=0,
619
785
  )
620
786
  self.input_list.configure(
621
787
  bg=palette["surface"],
@@ -706,12 +872,20 @@ class TalksReducerGUI:
706
872
  self.input_list.delete(index)
707
873
  del self.input_files[index]
708
874
 
875
+ def _clear_input_files(self) -> None:
876
+ """Clear all input files from the list."""
877
+ self.input_files.clear()
878
+ self.input_list.delete(0, self.tk.END)
879
+
709
880
  def _on_drop(self, event: object) -> None:
710
881
  data = getattr(event, "data", "")
711
882
  if not data:
712
883
  return
713
884
  paths = self.root.tk.splitlist(data)
714
885
  cleaned = [path.strip("{}") for path in paths]
886
+ # Clear existing files before adding dropped files
887
+ self.input_files.clear()
888
+ self.input_list.delete(0, self.tk.END)
715
889
  self._extend_inputs(cleaned, auto_run=True)
716
890
 
717
891
  def _browse_path(
@@ -745,10 +919,16 @@ class TalksReducerGUI:
745
919
  return
746
920
 
747
921
  self._append_log("Starting processing…")
748
- self.run_button.configure(state=self.tk.DISABLED)
922
+ self._stop_requested = False
923
+ open_after_convert = bool(self.open_after_convert_var.get())
749
924
 
750
925
  def worker() -> None:
751
- reporter = _TkProgressReporter(self._append_log)
926
+ def set_process(proc: subprocess.Popen) -> None:
927
+ self._ffmpeg_process = proc
928
+
929
+ reporter = _TkProgressReporter(
930
+ self._append_log, process_callback=set_process
931
+ )
752
932
  try:
753
933
  files = gather_input_files(self.input_files)
754
934
  if not files:
@@ -768,30 +948,75 @@ class TalksReducerGUI:
768
948
  result = speed_up_video(options, reporter=reporter)
769
949
  self._last_output = result.output_file
770
950
  self._append_log(f"Completed: {result.output_file}")
771
- self._notify(
772
- lambda path=result.output_file: self._open_in_file_manager(path)
773
- )
951
+ if open_after_convert:
952
+ self._notify(
953
+ lambda path=result.output_file: self._open_in_file_manager(
954
+ path
955
+ )
956
+ )
774
957
 
775
958
  self._append_log("All jobs finished successfully.")
776
959
  self._notify(lambda: self.open_button.configure(state=self.tk.NORMAL))
960
+ self._notify(self._clear_input_files)
777
961
  except FFmpegNotFoundError as exc:
778
962
  self._notify(
779
963
  lambda: self.messagebox.showerror("FFmpeg not found", str(exc))
780
964
  )
781
965
  self._set_status("Error")
782
966
  except Exception as exc: # pragma: no cover - GUI level safeguard
783
- self._notify(
784
- lambda: self.messagebox.showerror(
785
- "Error", f"Processing failed: {exc}"
967
+ # If stop was requested, don't show error (FFmpeg termination is expected)
968
+ if self._stop_requested:
969
+ self._append_log("Processing aborted by user.")
970
+ self._set_status("Aborted")
971
+ else:
972
+ self._notify(
973
+ lambda: self.messagebox.showerror(
974
+ "Error", f"Processing failed: {exc}"
975
+ )
786
976
  )
787
- )
788
- self._set_status("Error")
977
+ self._set_status("Error")
789
978
  finally:
790
- self._notify(lambda: self.run_button.configure(state=self.tk.NORMAL))
979
+ self._notify(self._hide_stop_button)
791
980
 
792
981
  self._processing_thread = threading.Thread(target=worker, daemon=True)
793
982
  self._processing_thread.start()
794
983
 
984
+ # Show Stop button when processing starts
985
+ self.stop_button.grid()
986
+
987
+ def _stop_processing(self) -> None:
988
+ """Stop the currently running processing by terminating FFmpeg."""
989
+ import signal
990
+
991
+ self._stop_requested = True
992
+ if self._ffmpeg_process and self._ffmpeg_process.poll() is None:
993
+ self._append_log("Stopping FFmpeg process...")
994
+ try:
995
+ # Send SIGTERM to FFmpeg process
996
+ if sys.platform == "win32":
997
+ # Windows doesn't have SIGTERM, use terminate()
998
+ self._ffmpeg_process.terminate()
999
+ else:
1000
+ # Unix-like systems can use SIGTERM
1001
+ self._ffmpeg_process.send_signal(signal.SIGTERM)
1002
+
1003
+ self._append_log("FFmpeg process stopped.")
1004
+ except Exception as e:
1005
+ self._append_log(f"Error stopping process: {e}")
1006
+ else:
1007
+ self._append_log("No active FFmpeg process to stop.")
1008
+
1009
+ self._hide_stop_button()
1010
+
1011
+ def _hide_stop_button(self) -> None:
1012
+ """Hide Stop button."""
1013
+ self.stop_button.grid_remove()
1014
+ # Show drop hint when stop button is hidden and no other buttons are visible
1015
+ if (not self.open_button.winfo_viewable() and
1016
+ hasattr(self, 'drop_hint_button') and
1017
+ not self.drop_hint_button.winfo_viewable()):
1018
+ self.drop_hint_button.grid()
1019
+
795
1020
  def _collect_arguments(self) -> dict[str, object]:
796
1021
  args: dict[str, object] = {}
797
1022
 
@@ -876,17 +1101,59 @@ class TalksReducerGUI:
876
1101
  normalized = message.strip().lower()
877
1102
  if "all jobs finished successfully" in normalized:
878
1103
  self._set_status("Success")
1104
+ self._set_progress(100) # 100% on success
1105
+ self._video_duration_seconds = None # Reset for next video
1106
+ elif normalized.startswith("extracting audio"):
1107
+ self._set_status("Extracting audio...")
1108
+ self._set_progress(0) # 0% on start
1109
+ self._video_duration_seconds = None # Reset for new processing
879
1110
  elif normalized.startswith("starting processing") or normalized.startswith(
880
1111
  "processing"
881
1112
  ):
882
1113
  self._set_status("Processing")
1114
+ self._set_progress(0) # 0% on start
1115
+ self._video_duration_seconds = None # Reset for new processing
1116
+
1117
+ # Parse video duration from FFmpeg output
1118
+ duration_match = re.search(r"Duration:\s*(\d{2}):(\d{2}):(\d{2}\.\d+)", message)
1119
+ if duration_match:
1120
+ hours = int(duration_match.group(1))
1121
+ minutes = int(duration_match.group(2))
1122
+ seconds = float(duration_match.group(3))
1123
+ self._video_duration_seconds = hours * 3600 + minutes * 60 + seconds
1124
+
1125
+ # Parse FFmpeg progress information (time and speed)
1126
+ time_match = re.search(r"time=(\d{2}):(\d{2}):(\d{2})\.\d+", message)
1127
+ speed_match = re.search(r"speed=\s*([\d.]+)x", message)
1128
+
1129
+ if time_match and speed_match:
1130
+ hours = int(time_match.group(1))
1131
+ minutes = int(time_match.group(2))
1132
+ seconds = int(time_match.group(3))
1133
+ time_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
1134
+ speed_str = speed_match.group(1)
1135
+
1136
+ # Calculate percentage if we have duration
1137
+ if self._video_duration_seconds and self._video_duration_seconds > 0:
1138
+ current_seconds = hours * 3600 + minutes * 60 + seconds
1139
+ percentage = min(
1140
+ 100, int((current_seconds / self._video_duration_seconds) * 100)
1141
+ )
1142
+ self._set_status(f"{time_str}, {speed_str}x ({percentage}%)")
1143
+ self._set_progress(percentage) # Update progress bar
1144
+ else:
1145
+ self._set_status(f"{time_str}, {speed_str}x")
883
1146
 
884
1147
  def _apply_status_style(self, status: str) -> None:
885
1148
  color = STATUS_COLORS.get(status.lower())
886
1149
  if color:
887
1150
  self.status_label.configure(fg=color)
888
1151
  else:
889
- self.status_label.configure(fg="")
1152
+ # For extracting audio or FFmpeg progress messages, use processing color
1153
+ if "extracting audio" in status.lower() or re.search(
1154
+ r"\d{2}:\d{2}:\d{2}.*\d+\.?\d*x", status
1155
+ ):
1156
+ self.status_label.configure(fg=STATUS_COLORS["processing"])
890
1157
 
891
1158
  def _set_status(self, status: str) -> None:
892
1159
  def apply() -> None:
@@ -894,22 +1161,35 @@ class TalksReducerGUI:
894
1161
  self._status_state = status
895
1162
  self.status_var.set(status)
896
1163
  self._apply_status_style(status)
1164
+ self._set_progress_bar_style(status)
897
1165
  lowered = status.lower()
898
- if lowered == "processing":
899
- self.run_button.configure(state=self.tk.DISABLED)
1166
+ is_processing = lowered == "processing" or "extracting audio" in lowered
1167
+
1168
+ if is_processing:
900
1169
  self._start_status_animation()
901
- else:
902
- if not self.simple_mode_var.get():
903
- self.run_button.configure(state=self.tk.NORMAL)
1170
+ # Show stop button during processing
1171
+ if hasattr(self, 'status_frame'):
1172
+ self.status_frame.grid()
1173
+ self.stop_button.grid()
1174
+ self.drop_hint_button.grid_remove()
904
1175
 
905
1176
  if lowered == "success":
906
- if self.simple_mode_var.get():
907
- self.actions_frame.grid()
1177
+ if self.simple_mode_var.get() and hasattr(self, 'status_frame'):
1178
+ self.status_frame.grid()
1179
+ self.stop_button.grid_remove()
1180
+ self.drop_hint_button.grid_remove()
908
1181
  self.open_button.grid()
1182
+ self.open_button.lift() # Ensure open_button is above drop_hint_button
1183
+ print("success status")
909
1184
  else:
910
1185
  self.open_button.grid_remove()
911
- if self.simple_mode_var.get():
912
- self.actions_frame.grid_remove()
1186
+ print("not success status")
1187
+ if self.simple_mode_var.get() and not is_processing and hasattr(self, 'status_frame'):
1188
+ self.status_frame.grid_remove()
1189
+ self.stop_button.grid_remove()
1190
+ # Show drop hint when no other buttons are visible
1191
+ if hasattr(self, 'drop_hint_button'):
1192
+ self.drop_hint_button.grid()
913
1193
 
914
1194
  self.root.after(0, apply)
915
1195
 
@@ -937,6 +1217,99 @@ class TalksReducerGUI:
937
1217
  if self._status_state.lower() != "processing":
938
1218
  self.status_var.set(self._status_state)
939
1219
 
1220
+ def _calculate_gradient_color(self, percentage: int, darken: float = 1.0) -> str:
1221
+ """Calculate color gradient from red (0%) to green (100%).
1222
+
1223
+ Args:
1224
+ percentage: The position in the gradient (0-100)
1225
+ darken: Value between 0.0 (black) and 1.0 (original brightness)
1226
+
1227
+ Returns:
1228
+ Hex color code string
1229
+ """
1230
+ # Clamp percentage between 0 and 100
1231
+ percentage = max(0, min(100, percentage))
1232
+ # Clamp darken between 0.0 and 1.0
1233
+ darken = max(0.0, min(1.0, darken))
1234
+
1235
+ if percentage <= 50:
1236
+ # Red to Yellow (0% to 50%)
1237
+ # Red: (248, 113, 113) -> Yellow: (250, 204, 21)
1238
+ ratio = percentage / 50.0
1239
+ r = int((248 + (250 - 248) * ratio) * darken)
1240
+ g = int((113 + (204 - 113) * ratio) * darken)
1241
+ b = int((113 + (21 - 113) * ratio) * darken)
1242
+ else:
1243
+ # Yellow to Green (50% to 100%)
1244
+ # Yellow: (250, 204, 21) -> Green: (34, 197, 94)
1245
+ ratio = (percentage - 50) / 50.0
1246
+ r = int((250 + (34 - 250) * ratio) * darken)
1247
+ g = int((204 + (197 - 204) * ratio) * darken)
1248
+ b = int((21 + (94 - 21) * ratio) * darken)
1249
+
1250
+ # Ensure values are within 0-255 range after darkening
1251
+ r = max(0, min(255, r))
1252
+ g = max(0, min(255, g))
1253
+ b = max(0, min(255, b))
1254
+
1255
+ return f"#{r:02x}{g:02x}{b:02x}"
1256
+
1257
+ def _set_progress(self, percentage: int) -> None:
1258
+ """Update the progress bar value and color (thread-safe)."""
1259
+
1260
+ def updater() -> None:
1261
+ self.progress_var.set(percentage)
1262
+ # Update color based on percentage gradient
1263
+ color = self._calculate_gradient_color(percentage, 0.5)
1264
+ palette = (
1265
+ LIGHT_THEME if self._detect_system_theme() == "light" else DARK_THEME
1266
+ )
1267
+ if self.theme_var.get().lower() in {"light", "dark"}:
1268
+ palette = (
1269
+ LIGHT_THEME
1270
+ if self.theme_var.get().lower() == "light"
1271
+ else DARK_THEME
1272
+ )
1273
+
1274
+ self.style.configure(
1275
+ "Dynamic.Horizontal.TProgressbar",
1276
+ background=color,
1277
+ troughcolor=palette["surface"],
1278
+ borderwidth=0,
1279
+ thickness=20,
1280
+ )
1281
+ self.progress_bar.configure(style="Dynamic.Horizontal.TProgressbar")
1282
+
1283
+ # Show stop button when progress < 100
1284
+ if percentage < 100:
1285
+ if hasattr(self, 'status_frame'):
1286
+ self.status_frame.grid()
1287
+ self.stop_button.grid()
1288
+ self.drop_hint_button.grid_remove()
1289
+ self.root.after(0, updater)
1290
+
1291
+ def _set_progress_bar_style(self, status: str) -> None:
1292
+ """Update the progress bar color based on status."""
1293
+
1294
+ def updater() -> None:
1295
+ # Map status to progress bar style
1296
+ status_lower = status.lower()
1297
+ if status_lower == "success":
1298
+ style = "Success.Horizontal.TProgressbar"
1299
+ elif status_lower == "error":
1300
+ style = "Error.Horizontal.TProgressbar"
1301
+ elif status_lower == "aborted":
1302
+ style = "Aborted.Horizontal.TProgressbar"
1303
+ elif status_lower == "idle":
1304
+ style = "Idle.Horizontal.TProgressbar"
1305
+ else:
1306
+ # For processing states, use dynamic gradient (will be set by _set_progress)
1307
+ return
1308
+
1309
+ self.progress_bar.configure(style=style)
1310
+
1311
+ self.root.after(0, updater)
1312
+
940
1313
  def _notify(self, callback: Callable[[], None]) -> None:
941
1314
  self.root.after(0, callback)
942
1315
 
@@ -988,7 +1361,21 @@ def main(argv: Optional[Sequence[str]] = None) -> bool:
988
1361
  print()
989
1362
  print("Run 'python3 -m talks_reducer --help' for all options.")
990
1363
  print()
991
- print("This is likely a macOS/Tkinter compatibility issue.")
1364
+ print("Troubleshooting tips:")
1365
+ if sys.platform == "darwin":
1366
+ print(
1367
+ " - On macOS, install Python from python.org or ensure "
1368
+ "Homebrew's python-tk package is present."
1369
+ )
1370
+ elif sys.platform.startswith("linux"):
1371
+ print(
1372
+ " - On Linux, install the Tk bindings for Python (for example, "
1373
+ "python3-tk)."
1374
+ )
1375
+ else:
1376
+ print(" - Ensure your Python installation includes Tk support.")
1377
+ print(" - You can always fall back to the CLI workflow below.")
1378
+ print()
992
1379
  print("The CLI interface works perfectly and is recommended.")
993
1380
  except UnicodeEncodeError:
994
1381
  # 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.2.23
3
+ Version: 0.3.0
4
4
  Summary: CLI for speeding up long-form talks by removing silence
5
5
  Author: Talks Reducer Maintainers
6
6
  License-Expression: MIT
@@ -85,6 +85,8 @@ continues to work unchanged for local development.
85
85
  - **Input drop zone** — drag files or folders from your desktop or add them via
86
86
  the Explorer/Finder dialog; duplicates are ignored.
87
87
  - **Small video** — toggles the `--small` preset used by the CLI.
88
+ - **Open after convert** — controls whether the exported file is revealed in
89
+ your system file manager as soon as each job finishes.
88
90
  - **Advanced** — reveals optional controls for the output path, temp folder,
89
91
  timing/audio knobs mirrored from the command line, and an appearance picker
90
92
  that can force dark or light mode or follow your operating system.
@@ -94,6 +96,10 @@ a background thread. Once every queued job succeeds an **Open last output**
94
96
  button appears so you can jump straight to the exported file in your system
95
97
  file manager.
96
98
 
99
+ The GUI stores your last-used Simple mode, Small video, Open after convert, and
100
+ theme preferences in a cross-platform configuration file so they persist across
101
+ launches.
102
+
97
103
  ## Repository Structure
98
104
  - `talks_reducer/` — Python package that exposes the CLI and reusable pipeline:
99
105
  - `cli.py` parses arguments and dispatches to the pipeline.
File without changes
File without changes