talks-reducer 0.3.0__tar.gz → 0.3.2__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 (24) hide show
  1. {talks_reducer-0.3.0/talks_reducer.egg-info → talks_reducer-0.3.2}/PKG-INFO +12 -7
  2. {talks_reducer-0.3.0 → talks_reducer-0.3.2}/README.md +10 -5
  3. {talks_reducer-0.3.0 → talks_reducer-0.3.2}/pyproject.toml +2 -2
  4. {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer/audio.py +18 -16
  5. {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer/cli.py +21 -17
  6. {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer/ffmpeg.py +7 -6
  7. {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer/gui.py +76 -36
  8. {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer/pipeline.py +1 -1
  9. {talks_reducer-0.3.0 → talks_reducer-0.3.2/talks_reducer.egg-info}/PKG-INFO +12 -7
  10. {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer.egg-info/SOURCES.txt +2 -0
  11. {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer.egg-info/requires.txt +1 -1
  12. talks_reducer-0.3.2/tests/test_audio.py +47 -0
  13. talks_reducer-0.3.2/tests/test_cli.py +78 -0
  14. {talks_reducer-0.3.0 → talks_reducer-0.3.2}/LICENSE +0 -0
  15. {talks_reducer-0.3.0 → talks_reducer-0.3.2}/setup.cfg +0 -0
  16. {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer/__init__.py +0 -0
  17. {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer/__main__.py +0 -0
  18. {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer/chunks.py +0 -0
  19. {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer/models.py +0 -0
  20. {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer/progress.py +0 -0
  21. {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer.egg-info/dependency_links.txt +0 -0
  22. {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer.egg-info/entry_points.txt +0 -0
  23. {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer.egg-info/top_level.txt +0 -0
  24. {talks_reducer-0.3.0 → talks_reducer-0.3.2}/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.0
3
+ Version: 0.3.2
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
@@ -32,6 +32,10 @@ project was renamed from **jumpcutter** to emphasize its focus on conference tal
32
32
  - 1h 19m, 751 MB — Talks Reducer
33
33
  - 1h 19m, 171 MB — Talks Reducer `--small`
34
34
 
35
+ ## Changelog
36
+
37
+ See [CHANGELOG.md](CHANGELOG.md).
38
+
35
39
  ## Install GUI (Windows, macOS)
36
40
  Go to the [releases page](https://github.com/popstas/talks-reducer/releases) and download the appropriate artifact:
37
41
 
@@ -50,9 +54,9 @@ pip install talks-reducer
50
54
  The `--small` preset applies a 720p video scale and 128 kbps audio bitrate, making it useful for sharing talks over constrained
51
55
  connections. Without `--small`, the script aims to preserve original quality while removing silence.
52
56
 
53
- > **Tip:** Running `talks-reducer-gui` without arguments opens the Tkinter interface. Passing regular CLI options (for example,
54
- > `talks-reducer-gui --small input.mp4`) now executes the command-line pipeline, so you can keep a single shortcut for both
55
- > workflows.
57
+ > **Tip:** The `talks-reducer` and `talks-reducer-gui` commands now behave the same way: launching them without arguments opens
58
+ > the Tkinter interface, while passing regular CLI options (for example, `talks-reducer --small input.mp4`) executes the
59
+ > command-line pipeline. You can keep a single shortcut for both workflows.
56
60
 
57
61
  When CUDA-capable hardware is available the pipeline leans on GPU encoders to keep export times low, but it still runs great on
58
62
  CPUs.
@@ -82,8 +86,9 @@ continues to work unchanged for local development.
82
86
  zone, hides the manual run controls and log, and automatically processes new
83
87
  files as soon as you drop them. Uncheck the box to return to the full layout
84
88
  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.
89
+ - **Input drop zone** — drag files or folders from your desktop, click to open
90
+ the system file picker, or add them via the Explorer/Finder dialog; duplicates
91
+ are ignored.
87
92
  - **Small video** — toggles the `--small` preset used by the CLI.
88
93
  - **Open after convert** — controls whether the exported file is revealed in
89
94
  your system file manager as soon as each job finishes.
@@ -7,6 +7,10 @@ project was renamed from **jumpcutter** to emphasize its focus on conference tal
7
7
  - 1h 19m, 751 MB — Talks Reducer
8
8
  - 1h 19m, 171 MB — Talks Reducer `--small`
9
9
 
10
+ ## Changelog
11
+
12
+ See [CHANGELOG.md](CHANGELOG.md).
13
+
10
14
  ## Install GUI (Windows, macOS)
11
15
  Go to the [releases page](https://github.com/popstas/talks-reducer/releases) and download the appropriate artifact:
12
16
 
@@ -25,9 +29,9 @@ pip install talks-reducer
25
29
  The `--small` preset applies a 720p video scale and 128 kbps audio bitrate, making it useful for sharing talks over constrained
26
30
  connections. Without `--small`, the script aims to preserve original quality while removing silence.
27
31
 
28
- > **Tip:** Running `talks-reducer-gui` without arguments opens the Tkinter interface. Passing regular CLI options (for example,
29
- > `talks-reducer-gui --small input.mp4`) now executes the command-line pipeline, so you can keep a single shortcut for both
30
- > workflows.
32
+ > **Tip:** The `talks-reducer` and `talks-reducer-gui` commands now behave the same way: launching them without arguments opens
33
+ > the Tkinter interface, while passing regular CLI options (for example, `talks-reducer --small input.mp4`) executes the
34
+ > command-line pipeline. You can keep a single shortcut for both workflows.
31
35
 
32
36
  When CUDA-capable hardware is available the pipeline leans on GPU encoders to keep export times low, but it still runs great on
33
37
  CPUs.
@@ -57,8 +61,9 @@ continues to work unchanged for local development.
57
61
  zone, hides the manual run controls and log, and automatically processes new
58
62
  files as soon as you drop them. Uncheck the box to return to the full layout
59
63
  with file pickers, the Run button, and detailed logging.
60
- - **Input drop zone** — drag files or folders from your desktop or add them via
61
- the Explorer/Finder dialog; duplicates are ignored.
64
+ - **Input drop zone** — drag files or folders from your desktop, click to open
65
+ the system file picker, or add them via the Explorer/Finder dialog; duplicates
66
+ are ignored.
62
67
  - **Small video** — toggles the `--small` preset used by the CLI.
63
68
  - **Open after convert** — controls whether the exported file is revealed in
64
69
  your system file manager as soon as each job finishes.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "talks-reducer"
7
- version = "0.3.0"
7
+ version = "0.3.2"
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,<2.0.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
- 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(
@@ -6,6 +6,7 @@ import argparse
6
6
  import os
7
7
  import sys
8
8
  import time
9
+ from importlib import import_module
9
10
  from importlib.metadata import version
10
11
  from pathlib import Path
11
12
  from typing import Dict, List, Optional, Sequence
@@ -23,19 +24,19 @@ def _build_parser() -> argparse.ArgumentParser:
23
24
  parser = argparse.ArgumentParser(
24
25
  description="Modifies a video file to play at different speeds when there is sound vs. silence.",
25
26
  )
26
-
27
+
27
28
  # Add version argument
28
29
  try:
29
30
  pkg_version = version("talks-reducer")
30
31
  except Exception:
31
32
  pkg_version = "unknown"
32
-
33
+
33
34
  parser.add_argument(
34
35
  "--version",
35
36
  action="version",
36
37
  version=f"talks-reducer {pkg_version}",
37
38
  )
38
-
39
+
39
40
  parser.add_argument(
40
41
  "input_file",
41
42
  type=str,
@@ -113,31 +114,34 @@ def gather_input_files(paths: List[str]) -> List[str]:
113
114
  return files
114
115
 
115
116
 
117
+ def _launch_gui(argv: Sequence[str]) -> bool:
118
+ """Attempt to launch the GUI with the provided arguments."""
119
+
120
+ try:
121
+ gui_module = import_module(".gui", __package__)
122
+ except ImportError:
123
+ return False
124
+
125
+ gui_main = getattr(gui_module, "main", None)
126
+ if gui_main is None:
127
+ return False
128
+
129
+ return bool(gui_main(list(argv)))
130
+
131
+
116
132
  def main(argv: Optional[Sequence[str]] = None) -> None:
117
133
  """Entry point for the command line interface.
118
134
 
119
135
  Launch the GUI when run without arguments, otherwise defer to the CLI.
120
136
  """
121
137
 
122
- # Check if running without arguments
123
138
  if argv is None:
124
139
  argv_list = sys.argv[1:]
125
140
  else:
126
141
  argv_list = list(argv)
127
142
 
128
- # Launch GUI if no arguments provided
129
143
  if not argv_list:
130
- gui_launched = False
131
-
132
- try:
133
- from .gui import main as gui_main
134
-
135
- gui_launched = gui_main([])
136
- except ImportError:
137
- # GUI dependencies not available, show help instead
138
- gui_launched = False
139
-
140
- if gui_launched:
144
+ if _launch_gui(argv_list):
141
145
  return
142
146
 
143
147
  parser = _build_parser()
@@ -145,7 +149,7 @@ def main(argv: Optional[Sequence[str]] = None) -> None:
145
149
  return
146
150
 
147
151
  parser = _build_parser()
148
- parsed_args = parser.parse_args(argv)
152
+ parsed_args = parser.parse_args(argv_list)
149
153
  start_time = time.time()
150
154
 
151
155
  files = gather_input_files(parsed_args.input_file)
@@ -38,6 +38,7 @@ def find_ffmpeg() -> Optional[str]:
38
38
  # Try bundled ffmpeg from imageio-ffmpeg first
39
39
  try:
40
40
  import imageio_ffmpeg
41
+
41
42
  bundled_path = imageio_ffmpeg.get_ffmpeg_exe()
42
43
  if bundled_path and os.path.isfile(bundled_path):
43
44
  return bundled_path
@@ -161,11 +162,11 @@ def check_cuda_available(ffmpeg_path: Optional[str] = None) -> bool:
161
162
  try:
162
163
  ffmpeg_path = ffmpeg_path or get_ffmpeg_path()
163
164
  result = subprocess.run(
164
- [ffmpeg_path, "-encoders"],
165
- capture_output=True,
166
- text=True,
165
+ [ffmpeg_path, "-encoders"],
166
+ capture_output=True,
167
+ text=True,
167
168
  timeout=5,
168
- creationflags=creationflags
169
+ creationflags=creationflags,
169
170
  )
170
171
  except (
171
172
  subprocess.TimeoutExpired,
@@ -193,7 +194,7 @@ def run_timed_ffmpeg_command(
193
194
  process_callback: Optional[callable] = None,
194
195
  ) -> None:
195
196
  """Execute an FFmpeg command while streaming progress information.
196
-
197
+
197
198
  Args:
198
199
  process_callback: Optional callback that receives the subprocess.Popen object
199
200
  """
@@ -243,7 +244,7 @@ def run_timed_ffmpeg_command(
243
244
 
244
245
  sys.stderr.write(line)
245
246
  sys.stderr.flush()
246
-
247
+
247
248
  # Send FFmpeg output to reporter for GUI display
248
249
  progress_reporter.log(line.strip())
249
250
 
@@ -395,6 +395,10 @@ class TalksReducerGUI:
395
395
  input_frame.rowconfigure(1, weight=1)
396
396
  self._configure_drop_targets(self.drop_zone)
397
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)
398
402
 
399
403
  self.add_files_button = self.ttk.Button(
400
404
  input_frame, text="Add files", command=self._add_files
@@ -420,23 +424,28 @@ class TalksReducerGUI:
420
424
  options.grid(row=2, column=0, pady=(16, 0), sticky="ew")
421
425
  options.columnconfigure(0, weight=1)
422
426
 
423
- self.simple_mode_check = self.ttk.Checkbutton(
424
- options,
425
- text="Simple mode",
426
- variable=self.simple_mode_var,
427
- command=self._toggle_simple_mode,
428
- )
429
- self.simple_mode_check.grid(row=0, column=0, sticky="w")
427
+ checkbox_frame = self.ttk.Frame(options)
428
+ checkbox_frame.grid(row=0, column=0, columnspan=2, sticky="w")
430
429
 
431
- self.ttk.Checkbutton(options, text="Small video", variable=self.small_var).grid(
432
- row=1, column=0, sticky="w", pady=(8, 0)
433
- )
430
+ self.ttk.Checkbutton(
431
+ checkbox_frame,
432
+ text="Small video",
433
+ variable=self.small_var,
434
+ ).grid(row=0, column=0, sticky="w")
434
435
 
435
436
  self.ttk.Checkbutton(
436
- options,
437
+ checkbox_frame,
437
438
  text="Open after convert",
438
439
  variable=self.open_after_convert_var,
439
- ).grid(row=2, column=0, sticky="w", pady=(8, 0))
440
+ ).grid(row=0, column=1, sticky="w", padx=(12, 0))
441
+
442
+ self.simple_mode_check = self.ttk.Checkbutton(
443
+ checkbox_frame,
444
+ text="Simple mode",
445
+ variable=self.simple_mode_var,
446
+ command=self._toggle_simple_mode,
447
+ )
448
+ self.simple_mode_check.grid(row=0, column=2, sticky="w", padx=(12, 0))
440
449
 
441
450
  self.advanced_visible = self.tk.BooleanVar(value=False)
442
451
  self.advanced_button = self.ttk.Button(
@@ -444,10 +453,10 @@ class TalksReducerGUI:
444
453
  text="Advanced",
445
454
  command=self._toggle_advanced,
446
455
  )
447
- self.advanced_button.grid(row=0, column=1, sticky="e")
456
+ self.advanced_button.grid(row=1, column=1, sticky="e")
448
457
 
449
458
  self.advanced_frame = self.ttk.Frame(options, padding=self.PADDING)
450
- self.advanced_frame.grid(row=3, column=0, columnspan=2, sticky="nsew")
459
+ self.advanced_frame.grid(row=2, column=0, columnspan=2, sticky="nsew")
451
460
  self.advanced_frame.columnconfigure(1, weight=1)
452
461
 
453
462
  self.output_var = self.tk.StringVar()
@@ -509,9 +518,10 @@ class TalksReducerGUI:
509
518
  status_frame.columnconfigure(1, weight=1)
510
519
  status_frame.columnconfigure(2, weight=0)
511
520
 
512
-
513
521
  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")
522
+ self.status_label = self.tk.Label(
523
+ status_frame, textvariable=self.status_var, anchor="e"
524
+ )
515
525
  self.status_label.grid(row=0, column=1, sticky="e")
516
526
 
517
527
  # Progress bar
@@ -527,7 +537,9 @@ class TalksReducerGUI:
527
537
  self.stop_button = self.ttk.Button(
528
538
  status_frame, text="Stop", command=self._stop_processing
529
539
  )
530
- self.stop_button.grid(row=2, column=0, columnspan=3, sticky="ew", pady=self.PADDING)
540
+ self.stop_button.grid(
541
+ row=2, column=0, columnspan=3, sticky="ew", pady=self.PADDING
542
+ )
531
543
  self.stop_button.grid_remove() # Hidden by default
532
544
 
533
545
  self.open_button = self.ttk.Button(
@@ -536,7 +548,9 @@ class TalksReducerGUI:
536
548
  command=self._open_last_output,
537
549
  state=self.tk.DISABLED,
538
550
  )
539
- self.open_button.grid(row=2, column=0, columnspan=3, sticky="ew", pady=self.PADDING)
551
+ self.open_button.grid(
552
+ row=2, column=0, columnspan=3, sticky="ew", pady=self.PADDING
553
+ )
540
554
  self.open_button.grid_remove()
541
555
 
542
556
  # Button shown when no other action buttons are visible
@@ -545,7 +559,9 @@ class TalksReducerGUI:
545
559
  text="Drop video to convert",
546
560
  state=self.tk.DISABLED,
547
561
  )
548
- self.drop_hint_button.grid(row=2, column=0, columnspan=3, sticky="ew", pady=self.PADDING)
562
+ self.drop_hint_button.grid(
563
+ row=2, column=0, columnspan=3, sticky="ew", pady=self.PADDING
564
+ )
549
565
  self.drop_hint_button.grid_remove() # Hidden by default
550
566
  self._configure_drop_targets(self.drop_hint_button)
551
567
 
@@ -607,11 +623,13 @@ class TalksReducerGUI:
607
623
  self.stop_button.grid_remove()
608
624
  self.advanced_button.grid_remove()
609
625
  self.advanced_frame.grid_remove()
610
- if hasattr(self, 'status_frame'):
626
+ if hasattr(self, "status_frame"):
611
627
  self.status_frame.grid_remove()
612
628
  self.run_after_drop_var.set(True)
613
629
  self._apply_window_size(simple=True)
614
- if self.status_var.get().lower() == "success" and hasattr(self, 'status_frame'):
630
+ if self.status_var.get().lower() == "success" and hasattr(
631
+ self, "status_frame"
632
+ ):
615
633
  self.status_frame.grid()
616
634
  self.open_button.grid()
617
635
  self.drop_hint_button.grid_remove()
@@ -619,7 +637,7 @@ class TalksReducerGUI:
619
637
  for widget in widgets:
620
638
  widget.grid()
621
639
  self.log_frame.grid()
622
- if hasattr(self, 'status_frame'):
640
+ if hasattr(self, "status_frame"):
623
641
  self.status_frame.grid()
624
642
  self.advanced_button.grid()
625
643
  if self.advanced_visible.get():
@@ -841,14 +859,19 @@ class TalksReducerGUI:
841
859
  widget.dnd_bind("<<Drop>>", self._on_drop) # type: ignore[attr-defined]
842
860
 
843
861
  # -------------------------------------------------------------- actions --
844
- def _add_files(self) -> None:
845
- 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(
846
866
  title="Select input files",
847
867
  filetypes=[
848
868
  ("Video files", "*.mp4 *.mkv *.mov *.avi *.m4v"),
849
869
  ("All", "*.*"),
850
870
  ],
851
871
  )
872
+
873
+ def _add_files(self) -> None:
874
+ files = self._ask_for_input_files()
852
875
  self._extend_inputs(files)
853
876
 
854
877
  def _add_directory(self) -> None:
@@ -888,6 +911,16 @@ class TalksReducerGUI:
888
911
  self.input_list.delete(0, self.tk.END)
889
912
  self._extend_inputs(cleaned, auto_run=True)
890
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
+
891
924
  def _browse_path(
892
925
  self, variable, label: str
893
926
  ) -> None: # type: (tk.StringVar, str) -> None
@@ -1012,9 +1045,11 @@ class TalksReducerGUI:
1012
1045
  """Hide Stop button."""
1013
1046
  self.stop_button.grid_remove()
1014
1047
  # 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()):
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
+ ):
1018
1053
  self.drop_hint_button.grid()
1019
1054
 
1020
1055
  def _collect_arguments(self) -> dict[str, object]:
@@ -1168,27 +1203,31 @@ class TalksReducerGUI:
1168
1203
  if is_processing:
1169
1204
  self._start_status_animation()
1170
1205
  # Show stop button during processing
1171
- if hasattr(self, 'status_frame'):
1206
+ if hasattr(self, "status_frame"):
1172
1207
  self.status_frame.grid()
1173
1208
  self.stop_button.grid()
1174
1209
  self.drop_hint_button.grid_remove()
1175
1210
 
1176
1211
  if lowered == "success":
1177
- if self.simple_mode_var.get() and hasattr(self, 'status_frame'):
1212
+ if self.simple_mode_var.get() and hasattr(self, "status_frame"):
1178
1213
  self.status_frame.grid()
1179
1214
  self.stop_button.grid_remove()
1180
1215
  self.drop_hint_button.grid_remove()
1181
1216
  self.open_button.grid()
1182
1217
  self.open_button.lift() # Ensure open_button is above drop_hint_button
1183
- print("success status")
1218
+ # print("success status")
1184
1219
  else:
1185
1220
  self.open_button.grid_remove()
1186
- print("not success status")
1187
- if self.simple_mode_var.get() and not is_processing and hasattr(self, 'status_frame'):
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
+ ):
1188
1227
  self.status_frame.grid_remove()
1189
1228
  self.stop_button.grid_remove()
1190
1229
  # Show drop hint when no other buttons are visible
1191
- if hasattr(self, 'drop_hint_button'):
1230
+ if hasattr(self, "drop_hint_button"):
1192
1231
  self.drop_hint_button.grid()
1193
1232
 
1194
1233
  self.root.after(0, apply)
@@ -1219,11 +1258,11 @@ class TalksReducerGUI:
1219
1258
 
1220
1259
  def _calculate_gradient_color(self, percentage: int, darken: float = 1.0) -> str:
1221
1260
  """Calculate color gradient from red (0%) to green (100%).
1222
-
1261
+
1223
1262
  Args:
1224
1263
  percentage: The position in the gradient (0-100)
1225
1264
  darken: Value between 0.0 (black) and 1.0 (original brightness)
1226
-
1265
+
1227
1266
  Returns:
1228
1267
  Hex color code string
1229
1268
  """
@@ -1282,10 +1321,11 @@ class TalksReducerGUI:
1282
1321
 
1283
1322
  # Show stop button when progress < 100
1284
1323
  if percentage < 100:
1285
- if hasattr(self, 'status_frame'):
1324
+ if hasattr(self, "status_frame"):
1286
1325
  self.status_frame.grid()
1287
1326
  self.stop_button.grid()
1288
1327
  self.drop_hint_button.grid_remove()
1328
+
1289
1329
  self.root.after(0, updater)
1290
1330
 
1291
1331
  def _set_progress_bar_style(self, status: str) -> None:
@@ -158,7 +158,7 @@ def speed_up_video(
158
158
  )
159
159
 
160
160
  reporter.log("Extracting audio...")
161
- process_callback = getattr(reporter, 'process_callback', None)
161
+ process_callback = getattr(reporter, "process_callback", None)
162
162
  run_timed_ffmpeg_command(
163
163
  extract_command,
164
164
  reporter=reporter,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: talks-reducer
3
- Version: 0.3.0
3
+ Version: 0.3.2
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
@@ -32,6 +32,10 @@ project was renamed from **jumpcutter** to emphasize its focus on conference tal
32
32
  - 1h 19m, 751 MB — Talks Reducer
33
33
  - 1h 19m, 171 MB — Talks Reducer `--small`
34
34
 
35
+ ## Changelog
36
+
37
+ See [CHANGELOG.md](CHANGELOG.md).
38
+
35
39
  ## Install GUI (Windows, macOS)
36
40
  Go to the [releases page](https://github.com/popstas/talks-reducer/releases) and download the appropriate artifact:
37
41
 
@@ -50,9 +54,9 @@ pip install talks-reducer
50
54
  The `--small` preset applies a 720p video scale and 128 kbps audio bitrate, making it useful for sharing talks over constrained
51
55
  connections. Without `--small`, the script aims to preserve original quality while removing silence.
52
56
 
53
- > **Tip:** Running `talks-reducer-gui` without arguments opens the Tkinter interface. Passing regular CLI options (for example,
54
- > `talks-reducer-gui --small input.mp4`) now executes the command-line pipeline, so you can keep a single shortcut for both
55
- > workflows.
57
+ > **Tip:** The `talks-reducer` and `talks-reducer-gui` commands now behave the same way: launching them without arguments opens
58
+ > the Tkinter interface, while passing regular CLI options (for example, `talks-reducer --small input.mp4`) executes the
59
+ > command-line pipeline. You can keep a single shortcut for both workflows.
56
60
 
57
61
  When CUDA-capable hardware is available the pipeline leans on GPU encoders to keep export times low, but it still runs great on
58
62
  CPUs.
@@ -82,8 +86,9 @@ continues to work unchanged for local development.
82
86
  zone, hides the manual run controls and log, and automatically processes new
83
87
  files as soon as you drop them. Uncheck the box to return to the full layout
84
88
  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.
89
+ - **Input drop zone** — drag files or folders from your desktop, click to open
90
+ the system file picker, or add them via the Explorer/Finder dialog; duplicates
91
+ are ignored.
87
92
  - **Small video** — toggles the `--small` preset used by the CLI.
88
93
  - **Open after convert** — controls whether the exported file is revealed in
89
94
  your system file manager as soon as each job finishes.
@@ -17,4 +17,6 @@ talks_reducer.egg-info/dependency_links.txt
17
17
  talks_reducer.egg-info/entry_points.txt
18
18
  talks_reducer.egg-info/requires.txt
19
19
  talks_reducer.egg-info/top_level.txt
20
+ tests/test_audio.py
21
+ tests/test_cli.py
20
22
  tests/test_pipeline_service.py
@@ -1,6 +1,6 @@
1
1
  audiotsm>=0.1.2
2
2
  scipy>=1.10.0
3
- numpy<2.0.0,>=1.22.0
3
+ numpy>=1.22.0
4
4
  tqdm>=4.65.0
5
5
  tkinterdnd2>=0.3.0
6
6
  Pillow>=9.0.0
@@ -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
@@ -0,0 +1,78 @@
1
+ """Tests for the CLI entry point behaviour."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from types import SimpleNamespace
7
+ from unittest import mock
8
+
9
+ import pytest
10
+
11
+ from talks_reducer import cli
12
+
13
+
14
+ def test_main_launches_gui_when_no_args(monkeypatch: pytest.MonkeyPatch) -> None:
15
+ """The GUI should be launched when no CLI arguments are provided."""
16
+
17
+ launch_calls: list[list[str]] = []
18
+
19
+ def fake_launch(argv: list[str]) -> bool:
20
+ launch_calls.append(list(argv))
21
+ return True
22
+
23
+ def fail_build_parser() -> None:
24
+ raise AssertionError("Parser should not be built when GUI launches")
25
+
26
+ monkeypatch.setattr(cli, "_launch_gui", fake_launch)
27
+ monkeypatch.setattr(cli, "_build_parser", fail_build_parser)
28
+
29
+ cli.main([])
30
+
31
+ assert launch_calls == [[]]
32
+
33
+
34
+ def test_main_runs_cli_with_arguments(monkeypatch: pytest.MonkeyPatch) -> None:
35
+ """Providing CLI arguments should bypass the GUI and run the pipeline."""
36
+
37
+ parsed_args = SimpleNamespace(
38
+ input_file=["input.mp4"],
39
+ output_file=None,
40
+ temp_folder=None,
41
+ silent_threshold=None,
42
+ silent_speed=None,
43
+ sounded_speed=None,
44
+ frame_spreadage=None,
45
+ sample_rate=None,
46
+ small=False,
47
+ )
48
+
49
+ parser_mock = mock.Mock()
50
+ parser_mock.parse_args.return_value = parsed_args
51
+
52
+ outputs: list[cli.ProcessingOptions] = []
53
+
54
+ class DummyReporter:
55
+ def log(self, _message: str) -> None: # pragma: no cover - simple stub
56
+ pass
57
+
58
+ def fake_speed_up_video(options: cli.ProcessingOptions, reporter: object):
59
+ outputs.append(options)
60
+ return SimpleNamespace(output_file=Path("/tmp/output.mp4"))
61
+
62
+ def fake_gather_input_files(_paths: list[str]) -> list[str]:
63
+ return ["/tmp/input.mp4"]
64
+
65
+ def fail_launch(_argv: list[str]) -> bool:
66
+ raise AssertionError("GUI should not be launched when arguments exist")
67
+
68
+ monkeypatch.setattr(cli, "_build_parser", lambda: parser_mock)
69
+ monkeypatch.setattr(cli, "gather_input_files", fake_gather_input_files)
70
+ monkeypatch.setattr(cli, "speed_up_video", fake_speed_up_video)
71
+ monkeypatch.setattr(cli, "TqdmProgressReporter", lambda: DummyReporter())
72
+ monkeypatch.setattr(cli, "_launch_gui", fail_launch)
73
+
74
+ cli.main(["input.mp4"])
75
+
76
+ parser_mock.parse_args.assert_called_once_with(["input.mp4"])
77
+ assert len(outputs) == 1
78
+ assert outputs[0].input_file == Path("/tmp/input.mp4")
File without changes
File without changes