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.
- {talks_reducer-0.3.0/talks_reducer.egg-info → talks_reducer-0.3.2}/PKG-INFO +12 -7
- {talks_reducer-0.3.0 → talks_reducer-0.3.2}/README.md +10 -5
- {talks_reducer-0.3.0 → talks_reducer-0.3.2}/pyproject.toml +2 -2
- {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer/audio.py +18 -16
- {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer/cli.py +21 -17
- {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer/ffmpeg.py +7 -6
- {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer/gui.py +76 -36
- {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer/pipeline.py +1 -1
- {talks_reducer-0.3.0 → talks_reducer-0.3.2/talks_reducer.egg-info}/PKG-INFO +12 -7
- {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer.egg-info/SOURCES.txt +2 -0
- {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer.egg-info/requires.txt +1 -1
- talks_reducer-0.3.2/tests/test_audio.py +47 -0
- talks_reducer-0.3.2/tests/test_cli.py +78 -0
- {talks_reducer-0.3.0 → talks_reducer-0.3.2}/LICENSE +0 -0
- {talks_reducer-0.3.0 → talks_reducer-0.3.2}/setup.cfg +0 -0
- {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer/__init__.py +0 -0
- {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer/__main__.py +0 -0
- {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer/chunks.py +0 -0
- {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer/models.py +0 -0
- {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer/progress.py +0 -0
- {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer.egg-info/dependency_links.txt +0 -0
- {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer.egg-info/entry_points.txt +0 -0
- {talks_reducer-0.3.0 → talks_reducer-0.3.2}/talks_reducer.egg-info/top_level.txt +0 -0
- {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.
|
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
|
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:**
|
54
|
-
> `talks-reducer
|
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
|
86
|
-
the Explorer/Finder dialog; duplicates
|
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:**
|
29
|
-
> `talks-reducer
|
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
|
61
|
-
the Explorer/Finder dialog; duplicates
|
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.
|
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
|
18
|
+
"numpy>=1.22.0",
|
19
19
|
"tqdm>=4.65.0",
|
20
20
|
"tkinterdnd2>=0.3.0",
|
21
21
|
"Pillow>=9.0.0",
|
@@ -11,6 +11,8 @@ import numpy as np
|
|
11
11
|
from audiotsm import phasevocoder
|
12
12
|
from audiotsm.io.array import ArrayReader, ArrayWriter
|
13
13
|
|
14
|
+
from .ffmpeg import get_ffprobe_path
|
15
|
+
|
14
16
|
|
15
17
|
def get_max_volume(samples: np.ndarray) -> float:
|
16
18
|
"""Return the maximum absolute volume in the provided sample array."""
|
@@ -21,8 +23,6 @@ def get_max_volume(samples: np.ndarray) -> float:
|
|
21
23
|
def is_valid_input_file(filename: str) -> bool:
|
22
24
|
"""Check whether ``ffprobe`` recognises the input file and finds an audio stream."""
|
23
25
|
|
24
|
-
from .ffmpeg import get_ffprobe_path
|
25
|
-
|
26
26
|
ffprobe_path = get_ffprobe_path()
|
27
27
|
command = [
|
28
28
|
ffprobe_path,
|
@@ -36,29 +36,31 @@ def is_valid_input_file(filename: str) -> bool:
|
|
36
36
|
"-show_entries",
|
37
37
|
"stream=codec_type",
|
38
38
|
]
|
39
|
-
|
39
|
+
|
40
40
|
# Hide console window on Windows
|
41
41
|
creationflags = 0
|
42
42
|
if sys.platform == "win32":
|
43
43
|
# CREATE_NO_WINDOW = 0x08000000
|
44
44
|
creationflags = 0x08000000
|
45
|
-
|
46
|
-
process = subprocess.Popen(
|
47
|
-
command,
|
48
|
-
stdout=subprocess.PIPE,
|
49
|
-
stderr=subprocess.PIPE,
|
50
|
-
creationflags=creationflags
|
51
|
-
)
|
52
|
-
outs, errs = None, None
|
45
|
+
|
53
46
|
try:
|
54
|
-
|
47
|
+
result = subprocess.run(
|
48
|
+
command,
|
49
|
+
capture_output=True,
|
50
|
+
text=True,
|
51
|
+
timeout=5,
|
52
|
+
creationflags=creationflags,
|
53
|
+
)
|
55
54
|
except subprocess.TimeoutExpired:
|
56
55
|
print("Timeout while checking the input file. Aborting. Command:")
|
57
56
|
print(" ".join(command))
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
return
|
57
|
+
return False
|
58
|
+
|
59
|
+
if result.returncode != 0:
|
60
|
+
return False
|
61
|
+
|
62
|
+
stdout = result.stdout or ""
|
63
|
+
return "codec_type=audio" in stdout
|
62
64
|
|
63
65
|
|
64
66
|
def process_audio_chunks(
|
@@ -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
|
-
|
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(
|
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
|
-
|
424
|
-
|
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(
|
432
|
-
|
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
|
-
|
437
|
+
checkbox_frame,
|
437
438
|
text="Open after convert",
|
438
439
|
variable=self.open_after_convert_var,
|
439
|
-
).grid(row=
|
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=
|
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=
|
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(
|
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(
|
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(
|
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(
|
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,
|
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(
|
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,
|
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
|
845
|
-
files
|
862
|
+
def _ask_for_input_files(self) -> tuple[str, ...]:
|
863
|
+
"""Prompt the user to select input files for processing."""
|
864
|
+
|
865
|
+
return self.filedialog.askopenfilenames(
|
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 (
|
1016
|
-
|
1017
|
-
|
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,
|
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,
|
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
|
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,
|
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,
|
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,
|
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.
|
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
|
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:**
|
54
|
-
> `talks-reducer
|
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
|
86
|
-
the Explorer/Finder dialog; duplicates
|
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.
|
@@ -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
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|