talks-reducer 0.2.24__py3-none-any.whl → 0.3.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/audio.py CHANGED
@@ -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
- outs, errs = process.communicate(timeout=1)
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
- process.kill()
59
- outs, errs = process.communicate()
60
- finally:
61
- return len(errs) == 0 and len(outs) > 0
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(
talks_reducer/ffmpeg.py CHANGED
@@ -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:
talks_reducer/gui.py CHANGED
@@ -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": "#facc15",
121
- "success": "#22c55e",
122
- "error": "#f87171",
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": "#1d4ed8",
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__(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:
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, 300)
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(value=True)
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.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"))
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=16)
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.LabelFrame(main, text="Input files", padding=12)
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
- for column in range(4):
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.RIDGE,
316
- borderwidth=2,
317
- padx=16,
318
- pady=16,
319
- highlightthickness=1,
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=4, sticky="nsew")
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.LabelFrame(main, text="Options", padding=12)
347
- options.grid(row=1, column=0, pady=(16, 0), sticky="nsew")
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
- options,
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=0, sticky="w")
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=0, column=1, sticky="e")
456
+ self.advanced_button.grid(row=1, column=1, sticky="e")
369
457
 
370
- self.advanced_frame = self.ttk.Frame(options, padding=(0, 12, 0, 0))
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
- self.actions_frame = self.ttk.Frame(main)
428
- self.actions_frame.grid(row=2, column=0, pady=(16, 0), sticky="ew")
429
- self.actions_frame.columnconfigure(1, weight=1)
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.run_button = self.ttk.Button(
432
- self.actions_frame, text="Run", command=self._start_run
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.run_button.grid(row=0, column=0, sticky="w")
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
- self.actions_frame,
438
- text="Open last output",
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(row=0, column=1, sticky="e")
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
- status_frame = self.ttk.Frame(main, padding=(0, 8, 0, 0))
446
- status_frame.grid(row=3, column=0, sticky="ew")
447
- status_frame.columnconfigure(1, weight=1)
448
- self.ttk.Label(status_frame, text="Status:").grid(row=0, column=0, sticky="w")
449
- self.status_label = self.tk.Label(status_frame, textvariable=self.status_var)
450
- self.status_label.grid(row=0, column=1, sticky="w")
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.LabelFrame(main, text="Log", padding=12)
453
- self.log_frame.grid(row=4, column=0, pady=(16, 0), sticky="nsew")
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.run_button.grid_remove()
623
+ self.stop_button.grid_remove()
507
624
  self.advanced_button.grid_remove()
508
625
  self.advanced_frame.grid_remove()
509
- self.actions_frame.grid_remove()
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.actions_frame.grid()
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.actions_frame.grid()
520
- self.run_button.grid()
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["surface"]),
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
- highlightbackground=palette["border"],
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 _add_files(self) -> None:
687
- files = self.filedialog.askopenfilenames(
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.run_button.configure(state=self.tk.DISABLED)
955
+ self._stop_requested = False
956
+ open_after_convert = bool(self.open_after_convert_var.get())
757
957
 
758
958
  def worker() -> None:
759
- reporter = _TkProgressReporter(self._append_log)
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
- self._notify(
780
- lambda path=result.output_file: self._open_in_file_manager(path)
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
- self._notify(
792
- lambda: self.messagebox.showerror(
793
- "Error", f"Processing failed: {exc}"
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(lambda: self.run_button.configure(state=self.tk.NORMAL))
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
- self.status_label.configure(fg="")
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
- if lowered == "processing":
907
- self.run_button.configure(state=self.tk.DISABLED)
1201
+ is_processing = lowered == "processing" or "extracting audio" in lowered
1202
+
1203
+ if is_processing:
908
1204
  self._start_status_animation()
909
- else:
910
- if not self.simple_mode_var.get():
911
- self.run_button.configure(state=self.tk.NORMAL)
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.actions_frame.grid()
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
- if self.simple_mode_var.get():
920
- self.actions_frame.grid_remove()
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("This is likely a macOS/Tkinter compatibility issue.")
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
talks_reducer/pipeline.py CHANGED
@@ -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.24
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<2.0.0,>=1.22.0
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 or add them via
86
- the Explorer/Finder dialog; duplicates are ignored.
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,16 @@
1
+ talks_reducer/__init__.py,sha256=lb50C4_o_SLERyMyVpQfgHnXf49FJOIF9j05MZ8KAvM,158
2
+ talks_reducer/__main__.py,sha256=azR_vh8HFPLaOnh-L6gUFWsL67I6iHtbeH5rQhsipGY,299
3
+ talks_reducer/audio.py,sha256=sjHMeY0H9ESG-Gn5BX0wFRBX7sXjWwsgS8u9Vb0bJ88,4396
4
+ talks_reducer/chunks.py,sha256=IpdZxRFPURSG5wP-OQ_p09CVP8wcKwIFysV29zOTSWI,2959
5
+ talks_reducer/cli.py,sha256=ZQcay6NF32g0PF7OGLKWPY1TbIR1Hx2xaRlzOXK1lto,6508
6
+ talks_reducer/ffmpeg.py,sha256=tM_T2mV_Y7U91QzWmTBEid9cjEobLpPnxsiOUfz_yDA,11697
7
+ talks_reducer/gui.py,sha256=42gWHLs1LlNvNYp-mEnUmpJd-XpempOF5tWytA5fTRs,53679
8
+ talks_reducer/models.py,sha256=6cZRcJf0EBZIzNd-PWrh4Wdsoa4EBj5nSdB6BnFiOXM,1106
9
+ talks_reducer/pipeline.py,sha256=deGvGMF3CSVd7lcpA7dke8dlLQw3mi_FEhSkFNte7Ro,8871
10
+ talks_reducer/progress.py,sha256=Mh43M6VWhjjUv9CI22xfD2EJ_7Aq3PCueqefQ9Bd5-o,4565
11
+ talks_reducer-0.3.1.dist-info/licenses/LICENSE,sha256=jN17mHNR3e84awmH3AbpWBcBDBzPxEH0rcOFoj1s7sQ,1124
12
+ talks_reducer-0.3.1.dist-info/METADATA,sha256=NU0eHj8kK0bC4Lz-AMjqxlAUhxKuaSgODYVr5iRkT9Y,7660
13
+ talks_reducer-0.3.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
+ talks_reducer-0.3.1.dist-info/entry_points.txt,sha256=LCzfSnh_7VXhvl9twoFSAj0C3sG7bayWs2LkxpH7hoI,100
15
+ talks_reducer-0.3.1.dist-info/top_level.txt,sha256=pJWGcy__LR9JIEKH3QJyFmk9XrIsiFtqvuMNxFdIzDU,14
16
+ talks_reducer-0.3.1.dist-info/RECORD,,
@@ -1,16 +0,0 @@
1
- talks_reducer/__init__.py,sha256=lb50C4_o_SLERyMyVpQfgHnXf49FJOIF9j05MZ8KAvM,158
2
- talks_reducer/__main__.py,sha256=azR_vh8HFPLaOnh-L6gUFWsL67I6iHtbeH5rQhsipGY,299
3
- talks_reducer/audio.py,sha256=we6-lyVjCJSFDEFUTPhtpiUmT2MZdPMpBrYN2ED_T9E,4440
4
- talks_reducer/chunks.py,sha256=IpdZxRFPURSG5wP-OQ_p09CVP8wcKwIFysV29zOTSWI,2959
5
- talks_reducer/cli.py,sha256=ZQcay6NF32g0PF7OGLKWPY1TbIR1Hx2xaRlzOXK1lto,6508
6
- talks_reducer/ffmpeg.py,sha256=GzHD1gnC7D3mgQJYZhz0waudDs2x-biI4x7ThrFMP50,11318
7
- talks_reducer/gui.py,sha256=EVGpQBBu4qz5KgkxIlq7RZqdiBIN_XFCs6-jssK9FGs,37011
8
- talks_reducer/models.py,sha256=6cZRcJf0EBZIzNd-PWrh4Wdsoa4EBj5nSdB6BnFiOXM,1106
9
- talks_reducer/pipeline.py,sha256=zcrN8VaWXPc1InY_mXX4yDrQ9goST3G5uBYKkEfJQu4,8623
10
- talks_reducer/progress.py,sha256=Mh43M6VWhjjUv9CI22xfD2EJ_7Aq3PCueqefQ9Bd5-o,4565
11
- talks_reducer-0.2.24.dist-info/licenses/LICENSE,sha256=jN17mHNR3e84awmH3AbpWBcBDBzPxEH0rcOFoj1s7sQ,1124
12
- talks_reducer-0.2.24.dist-info/METADATA,sha256=nmQZPt5hx6jEWH44zxgG_qMMekyfiUYwJoOdXSOw-us,7313
13
- talks_reducer-0.2.24.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
- talks_reducer-0.2.24.dist-info/entry_points.txt,sha256=LCzfSnh_7VXhvl9twoFSAj0C3sG7bayWs2LkxpH7hoI,100
15
- talks_reducer-0.2.24.dist-info/top_level.txt,sha256=pJWGcy__LR9JIEKH3QJyFmk9XrIsiFtqvuMNxFdIzDU,14
16
- talks_reducer-0.2.24.dist-info/RECORD,,