talks-reducer 0.4.0__py3-none-any.whl → 0.5.0__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/__about__.py +1 -1
- talks_reducer/cli.py +23 -2
- talks_reducer/ffmpeg.py +28 -6
- talks_reducer/gui.py +159 -24
- talks_reducer/models.py +2 -2
- talks_reducer/pipeline.py +82 -8
- talks_reducer/server.py +353 -0
- {talks_reducer-0.4.0.dist-info → talks_reducer-0.5.0.dist-info}/METADATA +93 -71
- talks_reducer-0.5.0.dist-info/RECORD +18 -0
- {talks_reducer-0.4.0.dist-info → talks_reducer-0.5.0.dist-info}/entry_points.txt +1 -0
- talks_reducer-0.4.0.dist-info/RECORD +0 -17
- {talks_reducer-0.4.0.dist-info → talks_reducer-0.5.0.dist-info}/WHEEL +0 -0
- {talks_reducer-0.4.0.dist-info → talks_reducer-0.5.0.dist-info}/licenses/LICENSE +0 -0
- {talks_reducer-0.4.0.dist-info → talks_reducer-0.5.0.dist-info}/top_level.txt +0 -0
talks_reducer/__about__.py
CHANGED
talks_reducer/cli.py
CHANGED
@@ -63,7 +63,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
63
63
|
"--silent_threshold",
|
64
64
|
type=float,
|
65
65
|
dest="silent_threshold",
|
66
|
-
help="The volume amount that frames' audio needs to surpass to be considered sounded. Defaults to 0.
|
66
|
+
help="The volume amount that frames' audio needs to surpass to be considered sounded. Defaults to 0.05.",
|
67
67
|
)
|
68
68
|
parser.add_argument(
|
69
69
|
"-S",
|
@@ -143,6 +143,22 @@ def _launch_gui(argv: Sequence[str]) -> bool:
|
|
143
143
|
return bool(gui_main(list(argv)))
|
144
144
|
|
145
145
|
|
146
|
+
def _launch_server(argv: Sequence[str]) -> bool:
|
147
|
+
"""Attempt to launch the Gradio web server with the provided arguments."""
|
148
|
+
|
149
|
+
try:
|
150
|
+
server_module = import_module(".server", __package__)
|
151
|
+
except ImportError:
|
152
|
+
return False
|
153
|
+
|
154
|
+
server_main = getattr(server_module, "main", None)
|
155
|
+
if server_main is None:
|
156
|
+
return False
|
157
|
+
|
158
|
+
server_main(list(argv))
|
159
|
+
return True
|
160
|
+
|
161
|
+
|
146
162
|
def main(argv: Optional[Sequence[str]] = None) -> None:
|
147
163
|
"""Entry point for the command line interface.
|
148
164
|
|
@@ -154,6 +170,12 @@ def main(argv: Optional[Sequence[str]] = None) -> None:
|
|
154
170
|
else:
|
155
171
|
argv_list = list(argv)
|
156
172
|
|
173
|
+
if argv_list and argv_list[0] in {"server", "serve"}:
|
174
|
+
if not _launch_server(argv_list[1:]):
|
175
|
+
print("Gradio server mode is unavailable.", file=sys.stderr)
|
176
|
+
sys.exit(1)
|
177
|
+
return
|
178
|
+
|
157
179
|
if not argv_list:
|
158
180
|
if _launch_gui(argv_list):
|
159
181
|
return
|
@@ -200,7 +222,6 @@ def main(argv: Optional[Sequence[str]] = None) -> None:
|
|
200
222
|
option_kwargs["sample_rate"] = int(local_options["sample_rate"])
|
201
223
|
if "small" in local_options:
|
202
224
|
option_kwargs["small"] = bool(local_options["small"])
|
203
|
-
|
204
225
|
options = ProcessingOptions(**option_kwargs)
|
205
226
|
|
206
227
|
try:
|
talks_reducer/ffmpeg.py
CHANGED
@@ -242,11 +242,29 @@ def run_timed_ffmpeg_command(
|
|
242
242
|
if not line:
|
243
243
|
continue
|
244
244
|
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
245
|
+
# Filter out excessive progress output, only show important lines
|
246
|
+
if any(
|
247
|
+
keyword in line.lower()
|
248
|
+
for keyword in [
|
249
|
+
"error",
|
250
|
+
"warning",
|
251
|
+
"encoded successfully",
|
252
|
+
"frame=",
|
253
|
+
"time=",
|
254
|
+
"size=",
|
255
|
+
"bitrate=",
|
256
|
+
"speed=",
|
257
|
+
]
|
258
|
+
):
|
259
|
+
sys.stderr.write(line)
|
260
|
+
sys.stderr.flush()
|
261
|
+
|
262
|
+
# Send FFmpeg output to reporter for GUI display (filtered)
|
263
|
+
if any(
|
264
|
+
keyword in line.lower()
|
265
|
+
for keyword in ["error", "warning", "encoded successfully", "frame="]
|
266
|
+
):
|
267
|
+
progress_reporter.log(line.strip())
|
250
268
|
|
251
269
|
match = re.search(r"frame=\s*(\d+)", line)
|
252
270
|
if match:
|
@@ -365,7 +383,11 @@ def build_video_commands(
|
|
365
383
|
# Use a fast software encoder instead
|
366
384
|
video_encoder_args = ["-c:v libx264", "-preset veryfast", "-crf 23"]
|
367
385
|
|
368
|
-
audio_parts = [
|
386
|
+
audio_parts = [
|
387
|
+
"-c:a aac",
|
388
|
+
f'"{output_file}"',
|
389
|
+
"-loglevel warning -stats -hide_banner",
|
390
|
+
]
|
369
391
|
|
370
392
|
full_command_parts = (
|
371
393
|
global_parts + input_parts + output_parts + video_encoder_args + audio_parts
|
talks_reducer/gui.py
CHANGED
@@ -200,6 +200,7 @@ class _TkProgressReporter(SignalProgressReporter):
|
|
200
200
|
|
201
201
|
def log(self, message: str) -> None:
|
202
202
|
self._log_callback(message)
|
203
|
+
print(message, flush=True)
|
203
204
|
|
204
205
|
def task(
|
205
206
|
self, *, desc: str = "", total: Optional[int] = None, unit: str = ""
|
@@ -256,7 +257,12 @@ class TalksReducerGUI:
|
|
256
257
|
self._settings[key] = value
|
257
258
|
self._save_settings()
|
258
259
|
|
259
|
-
def __init__(
|
260
|
+
def __init__(
|
261
|
+
self,
|
262
|
+
initial_inputs: Optional[Sequence[str]] = None,
|
263
|
+
*,
|
264
|
+
auto_run: bool = False,
|
265
|
+
) -> None:
|
260
266
|
self._config_path = self._determine_config_path()
|
261
267
|
self._settings = self._load_settings()
|
262
268
|
|
@@ -284,8 +290,8 @@ class TalksReducerGUI:
|
|
284
290
|
|
285
291
|
self._apply_window_icon()
|
286
292
|
|
287
|
-
self._full_size = (
|
288
|
-
self._simple_size = (
|
293
|
+
self._full_size = (1000, 800)
|
294
|
+
self._simple_size = (300, 270)
|
289
295
|
self.root.geometry(f"{self._full_size[0]}x{self._full_size[1]}")
|
290
296
|
self.style = self.ttk.Style(self.root)
|
291
297
|
|
@@ -298,6 +304,9 @@ class TalksReducerGUI:
|
|
298
304
|
self._status_animation_job: Optional[str] = None
|
299
305
|
self._status_animation_phase = 0
|
300
306
|
self._video_duration_seconds: Optional[float] = None
|
307
|
+
self._encode_target_duration_seconds: Optional[float] = None
|
308
|
+
self._encode_total_frames: Optional[int] = None
|
309
|
+
self._encode_current_frame: Optional[int] = None
|
301
310
|
self.progress_var = tk.IntVar(value=0)
|
302
311
|
self._ffmpeg_process: Optional[subprocess.Popen] = None
|
303
312
|
self._stop_requested = False
|
@@ -333,6 +342,9 @@ class TalksReducerGUI:
|
|
333
342
|
"Drag and drop requires the tkinterdnd2 package. Install it to enable the drop zone."
|
334
343
|
)
|
335
344
|
|
345
|
+
if initial_inputs:
|
346
|
+
self._populate_initial_inputs(initial_inputs, auto_run=auto_run)
|
347
|
+
|
336
348
|
# ------------------------------------------------------------------ UI --
|
337
349
|
def _apply_window_icon(self) -> None:
|
338
350
|
"""Configure the application icon when the asset is available."""
|
@@ -423,7 +435,7 @@ class TalksReducerGUI:
|
|
423
435
|
|
424
436
|
# Options frame
|
425
437
|
options = self.ttk.Frame(main, padding=self.PADDING)
|
426
|
-
options.grid(row=2, column=0, pady=(
|
438
|
+
options.grid(row=2, column=0, pady=(0, 0), sticky="ew")
|
427
439
|
options.columnconfigure(0, weight=1)
|
428
440
|
|
429
441
|
checkbox_frame = self.ttk.Frame(options)
|
@@ -496,7 +508,7 @@ class TalksReducerGUI:
|
|
496
508
|
self.advanced_frame, "Frame margin", self.frame_margin_var, row=5
|
497
509
|
)
|
498
510
|
|
499
|
-
self.sample_rate_var = self.tk.StringVar()
|
511
|
+
self.sample_rate_var = self.tk.StringVar(value="48000")
|
500
512
|
self._add_entry(self.advanced_frame, "Sample rate", self.sample_rate_var, row=6)
|
501
513
|
|
502
514
|
self.ttk.Label(self.advanced_frame, text="Theme").grid(
|
@@ -862,6 +874,26 @@ class TalksReducerGUI:
|
|
862
874
|
widget.drop_target_register(DND_FILES) # type: ignore[arg-type]
|
863
875
|
widget.dnd_bind("<<Drop>>", self._on_drop) # type: ignore[attr-defined]
|
864
876
|
|
877
|
+
def _populate_initial_inputs(
|
878
|
+
self, inputs: Sequence[str], *, auto_run: bool = False
|
879
|
+
) -> None:
|
880
|
+
"""Seed the GUI with preselected inputs and optionally start processing."""
|
881
|
+
|
882
|
+
normalized: list[str] = []
|
883
|
+
for path in inputs:
|
884
|
+
if not path:
|
885
|
+
continue
|
886
|
+
resolved = os.fspath(Path(path))
|
887
|
+
if resolved not in self.input_files:
|
888
|
+
self.input_files.append(resolved)
|
889
|
+
self.input_list.insert(self.tk.END, resolved)
|
890
|
+
normalized.append(resolved)
|
891
|
+
|
892
|
+
if auto_run and normalized:
|
893
|
+
# Kick off processing once the event loop becomes idle so the
|
894
|
+
# interface has a chance to render before the work starts.
|
895
|
+
self.root.after_idle(self._start_run)
|
896
|
+
|
865
897
|
# -------------------------------------------------------------- actions --
|
866
898
|
def _ask_for_input_files(self) -> tuple[str, ...]:
|
867
899
|
"""Prompt the user to select input files for processing."""
|
@@ -1014,11 +1046,10 @@ class TalksReducerGUI:
|
|
1014
1046
|
self._append_log("Processing aborted by user.")
|
1015
1047
|
self._set_status("Aborted")
|
1016
1048
|
else:
|
1017
|
-
|
1018
|
-
|
1019
|
-
|
1020
|
-
|
1021
|
-
)
|
1049
|
+
error_msg = f"Processing failed: {exc}"
|
1050
|
+
self._append_log(error_msg)
|
1051
|
+
print(error_msg, file=sys.stderr) # Also output to console
|
1052
|
+
self._notify(lambda: self.messagebox.showerror("Error", error_msg))
|
1022
1053
|
self._set_status("Error")
|
1023
1054
|
finally:
|
1024
1055
|
self._notify(self._hide_stop_button)
|
@@ -1093,7 +1124,6 @@ class TalksReducerGUI:
|
|
1093
1124
|
)
|
1094
1125
|
if self.small_var.get():
|
1095
1126
|
args["small"] = True
|
1096
|
-
|
1097
1127
|
return args
|
1098
1128
|
|
1099
1129
|
def _parse_float(self, value: str, label: str) -> float:
|
@@ -1155,16 +1185,73 @@ class TalksReducerGUI:
|
|
1155
1185
|
self._set_status("success", status_msg)
|
1156
1186
|
self._set_progress(100) # 100% on success
|
1157
1187
|
self._video_duration_seconds = None # Reset for next video
|
1188
|
+
self._encode_target_duration_seconds = None
|
1189
|
+
self._encode_total_frames = None
|
1190
|
+
self._encode_current_frame = None
|
1158
1191
|
elif normalized.startswith("extracting audio"):
|
1159
1192
|
self._set_status("processing", "Extracting audio...")
|
1160
1193
|
self._set_progress(0) # 0% on start
|
1161
1194
|
self._video_duration_seconds = None # Reset for new processing
|
1195
|
+
self._encode_target_duration_seconds = None
|
1196
|
+
self._encode_total_frames = None
|
1197
|
+
self._encode_current_frame = None
|
1162
1198
|
elif normalized.startswith("starting processing") or normalized.startswith(
|
1163
1199
|
"processing"
|
1164
1200
|
):
|
1165
1201
|
self._set_status("processing", "Processing")
|
1166
1202
|
self._set_progress(0) # 0% on start
|
1167
1203
|
self._video_duration_seconds = None # Reset for new processing
|
1204
|
+
self._encode_target_duration_seconds = None
|
1205
|
+
self._encode_total_frames = None
|
1206
|
+
self._encode_current_frame = None
|
1207
|
+
|
1208
|
+
frame_total_match = re.search(
|
1209
|
+
r"Final encode target frames(?: \(fallback\))?:\s*(\d+)", message
|
1210
|
+
)
|
1211
|
+
if frame_total_match:
|
1212
|
+
self._encode_total_frames = int(frame_total_match.group(1))
|
1213
|
+
return
|
1214
|
+
|
1215
|
+
if "final encode target frames" in normalized and "unknown" in normalized:
|
1216
|
+
self._encode_total_frames = None
|
1217
|
+
return
|
1218
|
+
|
1219
|
+
frame_match = re.search(r"frame=\s*(\d+)", message)
|
1220
|
+
if frame_match:
|
1221
|
+
try:
|
1222
|
+
current_frame = int(frame_match.group(1))
|
1223
|
+
except ValueError:
|
1224
|
+
current_frame = None
|
1225
|
+
|
1226
|
+
if current_frame is not None:
|
1227
|
+
if self._encode_current_frame == current_frame:
|
1228
|
+
return
|
1229
|
+
|
1230
|
+
self._encode_current_frame = current_frame
|
1231
|
+
if self._encode_total_frames and self._encode_total_frames > 0:
|
1232
|
+
percentage = min(
|
1233
|
+
100,
|
1234
|
+
int((current_frame / self._encode_total_frames) * 100),
|
1235
|
+
)
|
1236
|
+
self._set_progress(percentage)
|
1237
|
+
else:
|
1238
|
+
self._set_status("processing", f"{current_frame} frames encoded")
|
1239
|
+
|
1240
|
+
# Parse encode target duration reported by the pipeline
|
1241
|
+
encode_duration_match = re.search(
|
1242
|
+
r"Final encode target duration(?: \(fallback\))?:\s*([\d.]+)s",
|
1243
|
+
message,
|
1244
|
+
)
|
1245
|
+
if encode_duration_match:
|
1246
|
+
try:
|
1247
|
+
self._encode_target_duration_seconds = float(
|
1248
|
+
encode_duration_match.group(1)
|
1249
|
+
)
|
1250
|
+
except ValueError:
|
1251
|
+
self._encode_target_duration_seconds = None
|
1252
|
+
|
1253
|
+
if "final encode target duration" in normalized and "unknown" in normalized:
|
1254
|
+
self._encode_target_duration_seconds = None
|
1168
1255
|
|
1169
1256
|
# Parse video duration from FFmpeg output
|
1170
1257
|
duration_match = re.search(r"Duration:\s*(\d{2}):(\d{2}):(\d{2}\.\d+)", message)
|
@@ -1182,21 +1269,34 @@ class TalksReducerGUI:
|
|
1182
1269
|
hours = int(time_match.group(1))
|
1183
1270
|
minutes = int(time_match.group(2))
|
1184
1271
|
seconds = int(time_match.group(3))
|
1185
|
-
|
1272
|
+
current_seconds = hours * 3600 + minutes * 60 + seconds
|
1273
|
+
time_str = self._format_progress_time(current_seconds)
|
1186
1274
|
speed_str = speed_match.group(1)
|
1187
1275
|
|
1188
|
-
|
1189
|
-
|
1190
|
-
|
1191
|
-
|
1192
|
-
|
1193
|
-
|
1194
|
-
self._set_status(
|
1195
|
-
"processing", f"{time_str}, {speed_str}x ({percentage}%)"
|
1196
|
-
)
|
1197
|
-
self._set_progress(percentage) # Update progress bar
|
1276
|
+
total_seconds = (
|
1277
|
+
self._encode_target_duration_seconds or self._video_duration_seconds
|
1278
|
+
)
|
1279
|
+
if total_seconds:
|
1280
|
+
total_str = self._format_progress_time(total_seconds)
|
1281
|
+
time_display = f"{time_str} / {total_str}"
|
1198
1282
|
else:
|
1199
|
-
|
1283
|
+
time_display = time_str
|
1284
|
+
|
1285
|
+
status_msg = f"{time_display}, {speed_str}x"
|
1286
|
+
|
1287
|
+
if (
|
1288
|
+
(
|
1289
|
+
not self._encode_total_frames
|
1290
|
+
or self._encode_total_frames <= 0
|
1291
|
+
or self._encode_current_frame is None
|
1292
|
+
)
|
1293
|
+
and total_seconds
|
1294
|
+
and total_seconds > 0
|
1295
|
+
):
|
1296
|
+
percentage = min(100, int((current_seconds / total_seconds) * 100))
|
1297
|
+
self._set_progress(percentage)
|
1298
|
+
|
1299
|
+
self._set_status("processing", status_msg)
|
1200
1300
|
|
1201
1301
|
def _apply_status_style(self, status: str) -> None:
|
1202
1302
|
color = STATUS_COLORS.get(status.lower())
|
@@ -1208,7 +1308,7 @@ class TalksReducerGUI:
|
|
1208
1308
|
status_lower = status.lower()
|
1209
1309
|
if (
|
1210
1310
|
"extracting audio" in status_lower
|
1211
|
-
or re.search(r"\d
|
1311
|
+
or re.search(r"\d+:\d{2}(?: / \d+:\d{2})?.*\d+\.?\d*x", status)
|
1212
1312
|
or ("time:" in status_lower and "size:" in status_lower)
|
1213
1313
|
):
|
1214
1314
|
if "time:" in status_lower and "size:" in status_lower:
|
@@ -1261,6 +1361,23 @@ class TalksReducerGUI:
|
|
1261
1361
|
|
1262
1362
|
self.root.after(0, apply)
|
1263
1363
|
|
1364
|
+
def _format_progress_time(self, total_seconds: float) -> str:
|
1365
|
+
"""Format a duration in seconds as h:mm or m:ss for status display."""
|
1366
|
+
|
1367
|
+
try:
|
1368
|
+
rounded_seconds = max(0, int(round(total_seconds)))
|
1369
|
+
except (TypeError, ValueError):
|
1370
|
+
return "0:00"
|
1371
|
+
|
1372
|
+
hours, remainder = divmod(rounded_seconds, 3600)
|
1373
|
+
minutes, seconds = divmod(remainder, 60)
|
1374
|
+
|
1375
|
+
if hours > 0:
|
1376
|
+
return f"{hours}:{minutes:02d}"
|
1377
|
+
|
1378
|
+
total_minutes = rounded_seconds // 60
|
1379
|
+
return f"{total_minutes}:{seconds:02d}"
|
1380
|
+
|
1264
1381
|
def _calculate_gradient_color(self, percentage: int, darken: float = 1.0) -> str:
|
1265
1382
|
"""Calculate color gradient from red (0%) to green (100%).
|
1266
1383
|
|
@@ -1377,6 +1494,24 @@ def main(argv: Optional[Sequence[str]] = None) -> bool:
|
|
1377
1494
|
argv = sys.argv[1:]
|
1378
1495
|
|
1379
1496
|
if argv:
|
1497
|
+
launch_gui = False
|
1498
|
+
if sys.platform == "win32" and not any(arg.startswith("-") for arg in argv):
|
1499
|
+
# Only attempt to launch the GUI automatically when the arguments
|
1500
|
+
# look like file or directory paths. This matches the behaviour of
|
1501
|
+
# file association launches on Windows while still allowing the CLI
|
1502
|
+
# to be used explicitly with option flags.
|
1503
|
+
if any(Path(arg).exists() for arg in argv if arg):
|
1504
|
+
launch_gui = True
|
1505
|
+
|
1506
|
+
if launch_gui:
|
1507
|
+
try:
|
1508
|
+
app = TalksReducerGUI(argv, auto_run=True)
|
1509
|
+
app.run()
|
1510
|
+
return True
|
1511
|
+
except Exception:
|
1512
|
+
# Fall back to the CLI if the GUI cannot be started.
|
1513
|
+
pass
|
1514
|
+
|
1380
1515
|
cli_main(argv)
|
1381
1516
|
return False
|
1382
1517
|
|
talks_reducer/models.py
CHANGED
@@ -18,8 +18,8 @@ class ProcessingOptions:
|
|
18
18
|
input_file: Path
|
19
19
|
output_file: Optional[Path] = None
|
20
20
|
frame_rate: float = 30.0
|
21
|
-
sample_rate: int =
|
22
|
-
silent_threshold: float = 0.
|
21
|
+
sample_rate: int = 48000
|
22
|
+
silent_threshold: float = 0.05
|
23
23
|
silent_speed: float = 4.0
|
24
24
|
sounded_speed: float = 1.0
|
25
25
|
frame_spreadage: int = 2
|
talks_reducer/pipeline.py
CHANGED
@@ -27,7 +27,15 @@ from .progress import NullProgressReporter, ProgressReporter
|
|
27
27
|
|
28
28
|
def _input_to_output_filename(filename: Path, small: bool = False) -> Path:
|
29
29
|
dot_index = filename.name.rfind(".")
|
30
|
-
|
30
|
+
suffix_parts = []
|
31
|
+
|
32
|
+
if small:
|
33
|
+
suffix_parts.append("_small")
|
34
|
+
|
35
|
+
if not suffix_parts:
|
36
|
+
suffix_parts.append("") # Default case
|
37
|
+
|
38
|
+
suffix = "_speedup" + "".join(suffix_parts)
|
31
39
|
new_name = (
|
32
40
|
filename.name[:dot_index] + suffix + filename.name[dot_index:]
|
33
41
|
if dot_index != -1
|
@@ -74,7 +82,7 @@ def _extract_video_metadata(input_file: Path, frame_rate: float) -> Dict[str, fl
|
|
74
82
|
"-select_streams",
|
75
83
|
"v",
|
76
84
|
"-show_entries",
|
77
|
-
"format=duration:stream=avg_frame_rate",
|
85
|
+
"format=duration:stream=avg_frame_rate,nb_frames",
|
78
86
|
]
|
79
87
|
process = subprocess.Popen(
|
80
88
|
command,
|
@@ -92,7 +100,14 @@ def _extract_video_metadata(input_file: Path, frame_rate: float) -> Dict[str, fl
|
|
92
100
|
match_duration = re.search(r"duration=([\d.]*)", str(stdout))
|
93
101
|
original_duration = float(match_duration.group(1)) if match_duration else 0.0
|
94
102
|
|
95
|
-
|
103
|
+
match_frames = re.search(r"nb_frames=(\d+)", str(stdout))
|
104
|
+
frame_count = int(match_frames.group(1)) if match_frames else 0
|
105
|
+
|
106
|
+
return {
|
107
|
+
"frame_rate": frame_rate,
|
108
|
+
"duration": original_duration,
|
109
|
+
"frame_count": frame_count,
|
110
|
+
}
|
96
111
|
|
97
112
|
|
98
113
|
def _ensure_two_dimensional(audio_data: np.ndarray) -> np.ndarray:
|
@@ -135,6 +150,18 @@ def speed_up_video(
|
|
135
150
|
metadata = _extract_video_metadata(input_path, options.frame_rate)
|
136
151
|
frame_rate = metadata["frame_rate"]
|
137
152
|
original_duration = metadata["duration"]
|
153
|
+
frame_count = metadata.get("frame_count", 0)
|
154
|
+
|
155
|
+
reporter.log(
|
156
|
+
(
|
157
|
+
"Source metadata — duration: {duration:.2f}s, frame rate: {fps:.3f} fps,"
|
158
|
+
" reported frames: {frames}"
|
159
|
+
).format(
|
160
|
+
duration=original_duration,
|
161
|
+
fps=frame_rate,
|
162
|
+
frames=frame_count if frame_count > 0 else "unknown",
|
163
|
+
)
|
164
|
+
)
|
138
165
|
|
139
166
|
reporter.log("Processing on: {}".format("GPU (CUDA)" if cuda_available else "CPU"))
|
140
167
|
if options.small:
|
@@ -148,10 +175,12 @@ def speed_up_video(
|
|
148
175
|
audio_bitrate = "128k" if options.small else "160k"
|
149
176
|
audio_wav = temp_path / "audio.wav"
|
150
177
|
|
178
|
+
extraction_sample_rate = options.sample_rate
|
179
|
+
|
151
180
|
extract_command = build_extract_audio_command(
|
152
181
|
os.fspath(input_path),
|
153
182
|
os.fspath(audio_wav),
|
154
|
-
|
183
|
+
extraction_sample_rate,
|
155
184
|
audio_bitrate,
|
156
185
|
hwaccel,
|
157
186
|
ffmpeg_path=ffmpeg_path,
|
@@ -159,10 +188,19 @@ def speed_up_video(
|
|
159
188
|
|
160
189
|
reporter.log("Extracting audio...")
|
161
190
|
process_callback = getattr(reporter, "process_callback", None)
|
191
|
+
estimated_total_frames = frame_count
|
192
|
+
if estimated_total_frames <= 0 and original_duration > 0 and frame_rate > 0:
|
193
|
+
estimated_total_frames = int(math.ceil(original_duration * frame_rate))
|
194
|
+
|
195
|
+
if estimated_total_frames > 0:
|
196
|
+
reporter.log(f"Extract audio target frames: {estimated_total_frames}")
|
197
|
+
else:
|
198
|
+
reporter.log("Extract audio target frames: unknown")
|
199
|
+
|
162
200
|
run_timed_ffmpeg_command(
|
163
201
|
extract_command,
|
164
202
|
reporter=reporter,
|
165
|
-
total=
|
203
|
+
total=estimated_total_frames if estimated_total_frames > 0 else None,
|
166
204
|
unit="frames",
|
167
205
|
desc="Extracting audio:",
|
168
206
|
process_callback=process_callback,
|
@@ -202,9 +240,11 @@ def speed_up_video(
|
|
202
240
|
)
|
203
241
|
|
204
242
|
audio_new_path = temp_path / "audioNew.wav"
|
243
|
+
# Use the sample rate that was actually used for processing
|
244
|
+
output_sample_rate = extraction_sample_rate
|
205
245
|
wavfile.write(
|
206
246
|
os.fspath(audio_new_path),
|
207
|
-
|
247
|
+
output_sample_rate,
|
208
248
|
_prepare_output_audio(output_audio_data),
|
209
249
|
)
|
210
250
|
|
@@ -246,10 +286,29 @@ def speed_up_video(
|
|
246
286
|
raise FileNotFoundError("Filter graph file was not generated")
|
247
287
|
|
248
288
|
try:
|
289
|
+
final_total_frames = updated_chunks[-1][3] if updated_chunks else 0
|
290
|
+
if final_total_frames > 0:
|
291
|
+
reporter.log(f"Final encode target frames: {final_total_frames}")
|
292
|
+
if frame_rate > 0:
|
293
|
+
final_duration_seconds = final_total_frames / frame_rate
|
294
|
+
reporter.log(
|
295
|
+
(
|
296
|
+
"Final encode target duration: {duration:.2f}s at {fps:.3f} fps"
|
297
|
+
).format(duration=final_duration_seconds, fps=frame_rate)
|
298
|
+
)
|
299
|
+
else:
|
300
|
+
reporter.log(
|
301
|
+
"Final encode target duration: unknown (missing frame rate)"
|
302
|
+
)
|
303
|
+
else:
|
304
|
+
reporter.log("Final encode target frames: unknown")
|
305
|
+
|
306
|
+
total_frames_arg = final_total_frames if final_total_frames > 0 else None
|
307
|
+
|
249
308
|
run_timed_ffmpeg_command(
|
250
309
|
command_str,
|
251
310
|
reporter=reporter,
|
252
|
-
total=
|
311
|
+
total=total_frames_arg,
|
253
312
|
unit="frames",
|
254
313
|
desc="Generating final:",
|
255
314
|
process_callback=process_callback,
|
@@ -257,10 +316,25 @@ def speed_up_video(
|
|
257
316
|
except subprocess.CalledProcessError as exc:
|
258
317
|
if fallback_command_str and use_cuda_encoder:
|
259
318
|
reporter.log("CUDA encoding failed, retrying with CPU encoder...")
|
319
|
+
if final_total_frames > 0:
|
320
|
+
reporter.log(
|
321
|
+
f"Final encode target frames (fallback): {final_total_frames}"
|
322
|
+
)
|
323
|
+
else:
|
324
|
+
reporter.log("Final encode target frames (fallback): unknown")
|
325
|
+
if final_total_frames > 0 and frame_rate > 0:
|
326
|
+
reporter.log(
|
327
|
+
(
|
328
|
+
"Final encode target duration (fallback): {duration:.2f}s at {fps:.3f} fps"
|
329
|
+
).format(
|
330
|
+
duration=final_total_frames / frame_rate,
|
331
|
+
fps=frame_rate,
|
332
|
+
)
|
333
|
+
)
|
260
334
|
run_timed_ffmpeg_command(
|
261
335
|
fallback_command_str,
|
262
336
|
reporter=reporter,
|
263
|
-
total=
|
337
|
+
total=total_frames_arg,
|
264
338
|
unit="frames",
|
265
339
|
desc="Generating final (fallback):",
|
266
340
|
process_callback=process_callback,
|
talks_reducer/server.py
ADDED
@@ -0,0 +1,353 @@
|
|
1
|
+
"""Gradio-powered simple server for running Talks Reducer in a browser."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import argparse
|
6
|
+
import atexit
|
7
|
+
import shutil
|
8
|
+
import tempfile
|
9
|
+
from contextlib import AbstractContextManager, suppress
|
10
|
+
from pathlib import Path
|
11
|
+
from typing import Callable, Optional, Sequence
|
12
|
+
|
13
|
+
import gradio as gr
|
14
|
+
|
15
|
+
from talks_reducer.ffmpeg import FFmpegNotFoundError
|
16
|
+
from talks_reducer.models import ProcessingOptions, ProcessingResult
|
17
|
+
from talks_reducer.pipeline import speed_up_video
|
18
|
+
from talks_reducer.progress import ProgressHandle, SignalProgressReporter
|
19
|
+
|
20
|
+
|
21
|
+
class _GradioProgressHandle(AbstractContextManager[ProgressHandle]):
|
22
|
+
"""Translate pipeline progress updates into Gradio progress callbacks."""
|
23
|
+
|
24
|
+
def __init__(
|
25
|
+
self,
|
26
|
+
reporter: "GradioProgressReporter",
|
27
|
+
*,
|
28
|
+
desc: str,
|
29
|
+
total: Optional[int],
|
30
|
+
unit: str,
|
31
|
+
) -> None:
|
32
|
+
self._reporter = reporter
|
33
|
+
self._desc = desc.strip() or "Processing"
|
34
|
+
self._unit = unit
|
35
|
+
self._total = total
|
36
|
+
self._current = 0
|
37
|
+
self._reporter._start_task(self._desc, self._total)
|
38
|
+
|
39
|
+
@property
|
40
|
+
def current(self) -> int:
|
41
|
+
"""Return the number of processed units reported so far."""
|
42
|
+
|
43
|
+
return self._current
|
44
|
+
|
45
|
+
def ensure_total(self, total: int) -> None:
|
46
|
+
"""Update the total units when FFmpeg discovers a larger frame count."""
|
47
|
+
|
48
|
+
if total > 0 and (self._total is None or total > self._total):
|
49
|
+
self._total = total
|
50
|
+
self._reporter._update_progress(self._current, self._total, self._desc)
|
51
|
+
|
52
|
+
def advance(self, amount: int) -> None:
|
53
|
+
"""Advance the current progress and notify the UI."""
|
54
|
+
|
55
|
+
if amount <= 0:
|
56
|
+
return
|
57
|
+
self._current += amount
|
58
|
+
self._reporter._update_progress(self._current, self._total, self._desc)
|
59
|
+
|
60
|
+
def finish(self) -> None:
|
61
|
+
"""Fill the progress bar when FFmpeg completes."""
|
62
|
+
|
63
|
+
if self._total is not None:
|
64
|
+
self._current = self._total
|
65
|
+
else:
|
66
|
+
# Without a known total, treat the final frame count as the total so the
|
67
|
+
# progress bar reaches 100%.
|
68
|
+
inferred_total = self._current if self._current > 0 else 1
|
69
|
+
self._reporter._update_progress(self._current, inferred_total, self._desc)
|
70
|
+
return
|
71
|
+
self._reporter._update_progress(self._current, self._total, self._desc)
|
72
|
+
|
73
|
+
def __enter__(self) -> "_GradioProgressHandle":
|
74
|
+
return self
|
75
|
+
|
76
|
+
def __exit__(self, exc_type, exc, tb) -> bool:
|
77
|
+
if exc_type is None:
|
78
|
+
self.finish()
|
79
|
+
return False
|
80
|
+
|
81
|
+
|
82
|
+
class GradioProgressReporter(SignalProgressReporter):
|
83
|
+
"""Progress reporter that forwards updates to Gradio's progress widget."""
|
84
|
+
|
85
|
+
def __init__(
|
86
|
+
self,
|
87
|
+
progress_callback: Optional[Callable[[int, int, str], None]] = None,
|
88
|
+
*,
|
89
|
+
max_log_lines: int = 500,
|
90
|
+
) -> None:
|
91
|
+
super().__init__()
|
92
|
+
self._progress_callback = progress_callback
|
93
|
+
self._max_log_lines = max_log_lines
|
94
|
+
self._active_desc = "Processing"
|
95
|
+
self.logs: list[str] = []
|
96
|
+
|
97
|
+
def log(self, message: str) -> None:
|
98
|
+
"""Collect log messages for display in the web interface."""
|
99
|
+
|
100
|
+
text = message.strip()
|
101
|
+
if not text:
|
102
|
+
return
|
103
|
+
self.logs.append(text)
|
104
|
+
if len(self.logs) > self._max_log_lines:
|
105
|
+
self.logs = self.logs[-self._max_log_lines :]
|
106
|
+
|
107
|
+
def task(
|
108
|
+
self,
|
109
|
+
*,
|
110
|
+
desc: str = "",
|
111
|
+
total: Optional[int] = None,
|
112
|
+
unit: str = "",
|
113
|
+
) -> AbstractContextManager[ProgressHandle]:
|
114
|
+
"""Create a context manager bridging pipeline progress to Gradio."""
|
115
|
+
|
116
|
+
return _GradioProgressHandle(self, desc=desc, total=total, unit=unit)
|
117
|
+
|
118
|
+
# Internal helpers -------------------------------------------------
|
119
|
+
|
120
|
+
def _start_task(self, desc: str, total: Optional[int]) -> None:
|
121
|
+
self._active_desc = desc or "Processing"
|
122
|
+
self._update_progress(0, total, self._active_desc)
|
123
|
+
|
124
|
+
def _update_progress(
|
125
|
+
self, current: int, total: Optional[int], desc: Optional[str]
|
126
|
+
) -> None:
|
127
|
+
if self._progress_callback is None:
|
128
|
+
return
|
129
|
+
if total is None or total <= 0:
|
130
|
+
total_value = max(1, int(current) + 1 if current >= 0 else 1)
|
131
|
+
bounded_current = max(0, int(current))
|
132
|
+
else:
|
133
|
+
total_value = max(int(total), 1, int(current))
|
134
|
+
bounded_current = max(0, min(int(current), int(total_value)))
|
135
|
+
display_desc = desc or self._active_desc
|
136
|
+
self._progress_callback(bounded_current, total_value, display_desc)
|
137
|
+
|
138
|
+
|
139
|
+
_WORKSPACES: list[Path] = []
|
140
|
+
|
141
|
+
|
142
|
+
def _allocate_workspace() -> Path:
|
143
|
+
"""Create and remember a workspace directory for a single request."""
|
144
|
+
|
145
|
+
path = Path(tempfile.mkdtemp(prefix="talks_reducer_web_"))
|
146
|
+
_WORKSPACES.append(path)
|
147
|
+
return path
|
148
|
+
|
149
|
+
|
150
|
+
def _cleanup_workspaces() -> None:
|
151
|
+
"""Remove any workspaces that remain when the process exits."""
|
152
|
+
|
153
|
+
for workspace in _WORKSPACES:
|
154
|
+
if workspace.exists():
|
155
|
+
with suppress(Exception):
|
156
|
+
shutil.rmtree(workspace)
|
157
|
+
_WORKSPACES.clear()
|
158
|
+
|
159
|
+
|
160
|
+
def _build_output_path(input_path: Path, workspace: Path, small: bool) -> Path:
|
161
|
+
"""Mirror the CLI output naming scheme inside the workspace directory."""
|
162
|
+
|
163
|
+
suffix = input_path.suffix or ".mp4"
|
164
|
+
stem = input_path.stem
|
165
|
+
marker = "_speedup_small" if small else "_speedup"
|
166
|
+
return workspace / f"{stem}{marker}{suffix}"
|
167
|
+
|
168
|
+
|
169
|
+
def _format_duration(seconds: float) -> str:
|
170
|
+
"""Return a compact human-readable duration string."""
|
171
|
+
|
172
|
+
if seconds <= 0:
|
173
|
+
return "0s"
|
174
|
+
total_seconds = int(round(seconds))
|
175
|
+
hours, remainder = divmod(total_seconds, 3600)
|
176
|
+
minutes, secs = divmod(remainder, 60)
|
177
|
+
parts: list[str] = []
|
178
|
+
if hours:
|
179
|
+
parts.append(f"{hours}h")
|
180
|
+
if minutes or hours:
|
181
|
+
parts.append(f"{minutes}m")
|
182
|
+
parts.append(f"{secs}s")
|
183
|
+
return " ".join(parts)
|
184
|
+
|
185
|
+
|
186
|
+
def _format_summary(result: ProcessingResult) -> str:
|
187
|
+
"""Produce a Markdown summary of the processing result."""
|
188
|
+
|
189
|
+
lines = [
|
190
|
+
f"**Input:** `{result.input_file.name}`",
|
191
|
+
f"**Output:** `{result.output_file.name}`",
|
192
|
+
]
|
193
|
+
|
194
|
+
duration_line = (
|
195
|
+
f"**Duration:** {_format_duration(result.output_duration)}"
|
196
|
+
f" ({_format_duration(result.original_duration)} original)"
|
197
|
+
)
|
198
|
+
if result.time_ratio is not None:
|
199
|
+
duration_line += f" — {result.time_ratio * 100:.1f}% of the original"
|
200
|
+
lines.append(duration_line)
|
201
|
+
|
202
|
+
if result.size_ratio is not None:
|
203
|
+
size_percent = result.size_ratio * 100
|
204
|
+
lines.append(f"**Size:** {size_percent:.1f}% of the original file")
|
205
|
+
|
206
|
+
lines.append(f"**Chunks merged:** {result.chunk_count}")
|
207
|
+
lines.append(f"**Encoder:** {'CUDA' if result.used_cuda else 'CPU'}")
|
208
|
+
|
209
|
+
return "\n".join(lines)
|
210
|
+
|
211
|
+
|
212
|
+
def process_video(
|
213
|
+
file_path: Optional[str],
|
214
|
+
small_video: bool,
|
215
|
+
progress: Optional[gr.Progress] = gr.Progress(track_tqdm=False),
|
216
|
+
) -> tuple[Optional[str], str, str, Optional[str]]:
|
217
|
+
"""Run the Talks Reducer pipeline for a single uploaded file."""
|
218
|
+
|
219
|
+
if not file_path:
|
220
|
+
raise gr.Error("Please upload a video file to begin processing.")
|
221
|
+
|
222
|
+
input_path = Path(file_path)
|
223
|
+
if not input_path.exists():
|
224
|
+
raise gr.Error("The uploaded file is no longer available on the server.")
|
225
|
+
|
226
|
+
workspace = _allocate_workspace()
|
227
|
+
temp_folder = workspace / "temp"
|
228
|
+
output_file = _build_output_path(input_path, workspace, small_video)
|
229
|
+
|
230
|
+
progress_callback: Optional[Callable[[int, int, str], None]] = None
|
231
|
+
if progress is not None:
|
232
|
+
|
233
|
+
def _callback(current: int, total: int, desc: str) -> None:
|
234
|
+
progress(current, total=total, desc=desc)
|
235
|
+
|
236
|
+
progress_callback = _callback
|
237
|
+
|
238
|
+
reporter = GradioProgressReporter(progress_callback=progress_callback)
|
239
|
+
|
240
|
+
options = ProcessingOptions(
|
241
|
+
input_file=input_path,
|
242
|
+
output_file=output_file,
|
243
|
+
temp_folder=temp_folder,
|
244
|
+
small=small_video,
|
245
|
+
)
|
246
|
+
|
247
|
+
try:
|
248
|
+
result = speed_up_video(options, reporter=reporter)
|
249
|
+
except FFmpegNotFoundError as exc: # pragma: no cover - depends on runtime env
|
250
|
+
raise gr.Error(str(exc)) from exc
|
251
|
+
except FileNotFoundError as exc:
|
252
|
+
raise gr.Error(str(exc)) from exc
|
253
|
+
except Exception as exc: # pragma: no cover - defensive fallback
|
254
|
+
reporter.log(f"Error: {exc}")
|
255
|
+
raise gr.Error(f"Failed to process the video: {exc}") from exc
|
256
|
+
|
257
|
+
reporter.log("Processing complete.")
|
258
|
+
log_text = "\n".join(reporter.logs)
|
259
|
+
summary = _format_summary(result)
|
260
|
+
|
261
|
+
return (
|
262
|
+
str(result.output_file),
|
263
|
+
log_text,
|
264
|
+
summary,
|
265
|
+
str(result.output_file),
|
266
|
+
)
|
267
|
+
|
268
|
+
|
269
|
+
def build_interface() -> gr.Blocks:
|
270
|
+
"""Construct the Gradio Blocks application for the simple web UI."""
|
271
|
+
|
272
|
+
with gr.Blocks(title="Talks Reducer Web UI") as demo:
|
273
|
+
gr.Markdown(
|
274
|
+
"""
|
275
|
+
## Talks Reducer — Simple Server
|
276
|
+
Drop a video into the zone below or click to browse. Toggle **Small video** to
|
277
|
+
apply the 720p/128k preset before processing starts.
|
278
|
+
""".strip()
|
279
|
+
)
|
280
|
+
|
281
|
+
with gr.Row():
|
282
|
+
file_input = gr.File(
|
283
|
+
label="Video file",
|
284
|
+
file_types=["video"],
|
285
|
+
type="filepath",
|
286
|
+
)
|
287
|
+
small_checkbox = gr.Checkbox(label="Small video", value=False)
|
288
|
+
|
289
|
+
video_output = gr.Video(label="Processed video")
|
290
|
+
summary_output = gr.Markdown()
|
291
|
+
download_output = gr.File(label="Download processed file", interactive=False)
|
292
|
+
log_output = gr.Textbox(label="Log", lines=12, interactive=False)
|
293
|
+
|
294
|
+
file_input.upload(
|
295
|
+
process_video,
|
296
|
+
inputs=[file_input, small_checkbox],
|
297
|
+
outputs=[video_output, log_output, summary_output, download_output],
|
298
|
+
queue=True,
|
299
|
+
)
|
300
|
+
|
301
|
+
demo.queue(default_concurrency_limit=1)
|
302
|
+
return demo
|
303
|
+
|
304
|
+
|
305
|
+
def main(argv: Optional[Sequence[str]] = None) -> None:
|
306
|
+
"""Launch the Gradio server from the command line."""
|
307
|
+
|
308
|
+
parser = argparse.ArgumentParser(description="Launch the Talks Reducer web UI.")
|
309
|
+
parser.add_argument(
|
310
|
+
"--host", dest="host", default=None, help="Custom host to bind."
|
311
|
+
)
|
312
|
+
parser.add_argument(
|
313
|
+
"--port",
|
314
|
+
dest="port",
|
315
|
+
type=int,
|
316
|
+
default=9005,
|
317
|
+
help="Port number for the web server (default: 9005).",
|
318
|
+
)
|
319
|
+
parser.add_argument(
|
320
|
+
"--share",
|
321
|
+
action="store_true",
|
322
|
+
help="Create a temporary public Gradio link.",
|
323
|
+
)
|
324
|
+
parser.add_argument(
|
325
|
+
"--no-browser",
|
326
|
+
action="store_true",
|
327
|
+
help="Do not automatically open the browser window.",
|
328
|
+
)
|
329
|
+
|
330
|
+
args = parser.parse_args(argv)
|
331
|
+
|
332
|
+
demo = build_interface()
|
333
|
+
demo.launch(
|
334
|
+
server_name=args.host,
|
335
|
+
server_port=args.port,
|
336
|
+
share=args.share,
|
337
|
+
inbrowser=not args.no_browser,
|
338
|
+
)
|
339
|
+
|
340
|
+
|
341
|
+
atexit.register(_cleanup_workspaces)
|
342
|
+
|
343
|
+
|
344
|
+
__all__ = [
|
345
|
+
"GradioProgressReporter",
|
346
|
+
"build_interface",
|
347
|
+
"main",
|
348
|
+
"process_video",
|
349
|
+
]
|
350
|
+
|
351
|
+
|
352
|
+
if __name__ == "__main__": # pragma: no cover - convenience entry point
|
353
|
+
main()
|
@@ -1,71 +1,93 @@
|
|
1
|
-
Metadata-Version: 2.4
|
2
|
-
Name: talks-reducer
|
3
|
-
Version: 0.
|
4
|
-
Summary: CLI for speeding up long-form talks by removing silence
|
5
|
-
Author: Talks Reducer Maintainers
|
6
|
-
License-Expression: MIT
|
7
|
-
Requires-Python: >=3.9
|
8
|
-
Description-Content-Type: text/markdown
|
9
|
-
License-File: LICENSE
|
10
|
-
Requires-Dist: audiotsm>=0.1.2
|
11
|
-
Requires-Dist: scipy>=1.10.0
|
12
|
-
Requires-Dist: numpy>=1.22.0
|
13
|
-
Requires-Dist: tqdm>=4.65.0
|
14
|
-
Requires-Dist: tkinterdnd2>=0.3.0
|
15
|
-
Requires-Dist: Pillow>=9.0.0
|
16
|
-
Requires-Dist: imageio-ffmpeg>=0.4.8
|
17
|
-
|
18
|
-
|
19
|
-
Requires-Dist:
|
20
|
-
Requires-Dist:
|
21
|
-
Requires-Dist:
|
22
|
-
Requires-Dist:
|
23
|
-
Requires-Dist:
|
24
|
-
Requires-Dist:
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
Talks Reducer
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
- 1h
|
36
|
-
- 1h 19m,
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
- **
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: talks-reducer
|
3
|
+
Version: 0.5.0
|
4
|
+
Summary: CLI for speeding up long-form talks by removing silence
|
5
|
+
Author: Talks Reducer Maintainers
|
6
|
+
License-Expression: MIT
|
7
|
+
Requires-Python: >=3.9
|
8
|
+
Description-Content-Type: text/markdown
|
9
|
+
License-File: LICENSE
|
10
|
+
Requires-Dist: audiotsm>=0.1.2
|
11
|
+
Requires-Dist: scipy>=1.10.0
|
12
|
+
Requires-Dist: numpy>=1.22.0
|
13
|
+
Requires-Dist: tqdm>=4.65.0
|
14
|
+
Requires-Dist: tkinterdnd2>=0.3.0
|
15
|
+
Requires-Dist: Pillow>=9.0.0
|
16
|
+
Requires-Dist: imageio-ffmpeg>=0.4.8
|
17
|
+
Requires-Dist: gradio>=4.0.0
|
18
|
+
Provides-Extra: dev
|
19
|
+
Requires-Dist: build>=1.0.0; extra == "dev"
|
20
|
+
Requires-Dist: twine>=4.0.0; extra == "dev"
|
21
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
22
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
23
|
+
Requires-Dist: isort>=5.12.0; extra == "dev"
|
24
|
+
Requires-Dist: bump-my-version>=0.5.0; extra == "dev"
|
25
|
+
Requires-Dist: pyinstaller>=6.4.0; extra == "dev"
|
26
|
+
Dynamic: license-file
|
27
|
+
|
28
|
+
# Talks Reducer
|
29
|
+
Talks Reducer shortens long-form presentations by removing silent gaps and optionally re-encoding them to smaller files. The
|
30
|
+
project was renamed from **jumpcutter** to emphasize its focus on conference talks and screencasts.
|
31
|
+
|
32
|
+

|
33
|
+
|
34
|
+
## Example
|
35
|
+
- 1h 37m, 571 MB — Original OBS video recording
|
36
|
+
- 1h 19m, 751 MB — Talks Reducer
|
37
|
+
- 1h 19m, 171 MB — Talks Reducer `--small`
|
38
|
+
|
39
|
+
## Changelog
|
40
|
+
|
41
|
+
See [CHANGELOG.md](CHANGELOG.md).
|
42
|
+
|
43
|
+
## Install GUI (Windows, macOS)
|
44
|
+
Go to the [releases page](https://github.com/popstas/talks-reducer/releases) and download the appropriate artifact:
|
45
|
+
|
46
|
+
- **Windows** — `talks-reducer-windows-0.4.0.zip`
|
47
|
+
- **macOS** — `talks-reducer.app.zip` (but it doesn't work for me)
|
48
|
+
|
49
|
+
When extracted on Windows the bundled `talks-reducer.exe` behaves like the
|
50
|
+
`python talks_reducer/gui.py` entry point: double-clicking it launches the GUI
|
51
|
+
and passing a video file path (for example via *Open with…* or drag-and-drop
|
52
|
+
onto the executable) automatically queues that recording for processing.
|
53
|
+
|
54
|
+
## Install CLI (Linux, Windows, macOS)
|
55
|
+
```
|
56
|
+
pip install talks-reducer
|
57
|
+
```
|
58
|
+
|
59
|
+
**Note:** FFmpeg is now bundled automatically with the package, so you don't need to install it separately. You you need, don't know actually.
|
60
|
+
|
61
|
+
The `--small` preset applies a 720p video scale and 128 kbps audio bitrate, making it useful for sharing talks over constrained
|
62
|
+
connections. Without `--small`, the script aims to preserve original quality while removing silence.
|
63
|
+
|
64
|
+
Example CLI usage:
|
65
|
+
|
66
|
+
```sh
|
67
|
+
talks-reducer --small input.mp4
|
68
|
+
```
|
69
|
+
|
70
|
+
### Speech detection
|
71
|
+
|
72
|
+
Talks Reducer now relies on its built-in volume thresholding to detect speech. Adjust `--silent_threshold` if you need to fine-tune when segments count as silence. Dropping the optional Silero VAD integration keeps the install lightweight and avoids pulling in PyTorch.
|
73
|
+
|
74
|
+
When CUDA-capable hardware is available the pipeline leans on GPU encoders to keep export times low, but it still runs great on
|
75
|
+
CPUs.
|
76
|
+
|
77
|
+
## Simple web server
|
78
|
+
|
79
|
+
Prefer a lightweight browser interface? Launch the Gradio-powered simple mode with:
|
80
|
+
|
81
|
+
```sh
|
82
|
+
talks-reducer server
|
83
|
+
```
|
84
|
+
|
85
|
+
This opens a local web page featuring a drag-and-drop upload zone, a **Small video** checkbox that mirrors the CLI preset, a live
|
86
|
+
progress indicator, and automatic previews of the processed output. Once the job completes you can inspect the resulting compression
|
87
|
+
ratio and download the rendered video directly from the page.
|
88
|
+
|
89
|
+
## Contributing
|
90
|
+
See `CONTRIBUTION.md` for development setup details and guidance on sharing improvements.
|
91
|
+
|
92
|
+
## License
|
93
|
+
Talks Reducer is released under the MIT License. See `LICENSE` for the full text.
|
@@ -0,0 +1,18 @@
|
|
1
|
+
talks_reducer/__about__.py,sha256=RhSau8kONixzPMrozb3d8HJbGwsQmAY3l5_HbL3elh4,92
|
2
|
+
talks_reducer/__init__.py,sha256=Kzh1hXaw6Vq3DyTqrnJGOq8pn0P8lvaDcsg1bFUjFKk,208
|
3
|
+
talks_reducer/__main__.py,sha256=azR_vh8HFPLaOnh-L6gUFWsL67I6iHtbeH5rQhsipGY,299
|
4
|
+
talks_reducer/audio.py,sha256=sjHMeY0H9ESG-Gn5BX0wFRBX7sXjWwsgS8u9Vb0bJ88,4396
|
5
|
+
talks_reducer/chunks.py,sha256=IpdZxRFPURSG5wP-OQ_p09CVP8wcKwIFysV29zOTSWI,2959
|
6
|
+
talks_reducer/cli.py,sha256=9Lj47GTtvr1feYBrNtQ2aB3r4sLquLODMZk_K_YAIEk,7990
|
7
|
+
talks_reducer/ffmpeg.py,sha256=Joqtkq-bktP-Hq3j3I394FYB_VQ-7GyF0n7bqTiknrg,12356
|
8
|
+
talks_reducer/gui.py,sha256=6OTUfIMH30XBOFq-BYZmxnODp0HhW3oj7CcTqLdpKyI,59739
|
9
|
+
talks_reducer/models.py,sha256=Ax7OIV7WECRROi5km-Se0Z1LQsLxd5J7GnGXDbWrNjg,1197
|
10
|
+
talks_reducer/pipeline.py,sha256=kemU_Txoh38jLzJCjy0HvjUS1gtvmVItnxXhlZcdw5Y,12195
|
11
|
+
talks_reducer/progress.py,sha256=Mh43M6VWhjjUv9CI22xfD2EJ_7Aq3PCueqefQ9Bd5-o,4565
|
12
|
+
talks_reducer/server.py,sha256=r5P7fGfU9SGxwPYDaSsSnEllzwjlombOJ-FF8B5iAZQ,11128
|
13
|
+
talks_reducer-0.5.0.dist-info/licenses/LICENSE,sha256=jN17mHNR3e84awmH3AbpWBcBDBzPxEH0rcOFoj1s7sQ,1124
|
14
|
+
talks_reducer-0.5.0.dist-info/METADATA,sha256=Rrw6kiDxbQT7q6I0QnbWcNxsJsMLLe0z145f4Zr9kBM,3636
|
15
|
+
talks_reducer-0.5.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
16
|
+
talks_reducer-0.5.0.dist-info/entry_points.txt,sha256=no-NVP5Z9LrzaJL4-2ltKe9IkLZo8dQ32zilIb1gbZE,149
|
17
|
+
talks_reducer-0.5.0.dist-info/top_level.txt,sha256=pJWGcy__LR9JIEKH3QJyFmk9XrIsiFtqvuMNxFdIzDU,14
|
18
|
+
talks_reducer-0.5.0.dist-info/RECORD,,
|
@@ -1,17 +0,0 @@
|
|
1
|
-
talks_reducer/__about__.py,sha256=2XVXbkR7SSSCzPemYAwJFimV_pxTmx61hlVaLn82ERk,92
|
2
|
-
talks_reducer/__init__.py,sha256=Kzh1hXaw6Vq3DyTqrnJGOq8pn0P8lvaDcsg1bFUjFKk,208
|
3
|
-
talks_reducer/__main__.py,sha256=azR_vh8HFPLaOnh-L6gUFWsL67I6iHtbeH5rQhsipGY,299
|
4
|
-
talks_reducer/audio.py,sha256=sjHMeY0H9ESG-Gn5BX0wFRBX7sXjWwsgS8u9Vb0bJ88,4396
|
5
|
-
talks_reducer/chunks.py,sha256=IpdZxRFPURSG5wP-OQ_p09CVP8wcKwIFysV29zOTSWI,2959
|
6
|
-
talks_reducer/cli.py,sha256=OYmahiEo7ivhix4861pN9Kp1DkRvU7WBj6fBE2cVVWU,7377
|
7
|
-
talks_reducer/ffmpeg.py,sha256=CVrxwNcWHrzvxTzoALtx5UdNWXxxfOFYF3FES7lvaO4,11680
|
8
|
-
talks_reducer/gui.py,sha256=xsJj1uO1WX14rNVSrkQf2b4K6BdNDbeZ-A1bB0fsSIM,54463
|
9
|
-
talks_reducer/models.py,sha256=vdQLliiHKUuYtNlZzS796kGK39cbtjkUfYcT95KwwKE,1197
|
10
|
-
talks_reducer/pipeline.py,sha256=nfAX8dooN3-009WqMyYTv4nINNMtVmbWtsmzQeBM9Wg,9415
|
11
|
-
talks_reducer/progress.py,sha256=Mh43M6VWhjjUv9CI22xfD2EJ_7Aq3PCueqefQ9Bd5-o,4565
|
12
|
-
talks_reducer-0.4.0.dist-info/licenses/LICENSE,sha256=jN17mHNR3e84awmH3AbpWBcBDBzPxEH0rcOFoj1s7sQ,1124
|
13
|
-
talks_reducer-0.4.0.dist-info/METADATA,sha256=nPW6e70RrF2FrVpUnF7L0CE02_UNuvurigmiAQqW5kg,2443
|
14
|
-
talks_reducer-0.4.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
15
|
-
talks_reducer-0.4.0.dist-info/entry_points.txt,sha256=LCzfSnh_7VXhvl9twoFSAj0C3sG7bayWs2LkxpH7hoI,100
|
16
|
-
talks_reducer-0.4.0.dist-info/top_level.txt,sha256=pJWGcy__LR9JIEKH3QJyFmk9XrIsiFtqvuMNxFdIzDU,14
|
17
|
-
talks_reducer-0.4.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|