talks-reducer 0.4.1__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.
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.4.1"
5
+ __version__ = "0.5.0"
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.03.",
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
- sys.stderr.write(line)
246
- sys.stderr.flush()
247
-
248
- # Send FFmpeg output to reporter for GUI display
249
- progress_reporter.log(line.strip())
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 = ["-c:a aac", f'"{output_file}"', "-loglevel info -stats -hide_banner"]
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__(self) -> None:
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 = (760, 680)
288
- self._simple_size = (255, 330)
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=(16, 0), sticky="ew")
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
- self._notify(
1018
- lambda: self.messagebox.showerror(
1019
- "Error", f"Processing failed: {exc}"
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
- time_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
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
- # Calculate percentage if we have duration
1189
- if self._video_duration_seconds and self._video_duration_seconds > 0:
1190
- current_seconds = hours * 3600 + minutes * 60 + seconds
1191
- percentage = min(
1192
- 100, int((current_seconds / self._video_duration_seconds) * 100)
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
- self._set_status("processing", f"{time_str}, {speed_str}x")
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{2}:\d{2}:\d{2}.*\d+\.?\d*x", status)
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 = 44100
22
- silent_threshold: float = 0.03
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
- suffix = "_speedup_small" if small else "_speedup"
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
- return {"frame_rate": frame_rate, "duration": original_duration}
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
- options.sample_rate,
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=int(original_duration * frame_rate),
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
- options.sample_rate,
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=updated_chunks[-1][3],
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=updated_chunks[-1][3],
337
+ total=total_frames_arg,
264
338
  unit="frames",
265
339
  desc="Generating final (fallback):",
266
340
  process_callback=process_callback,
@@ -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.1
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
- Provides-Extra: dev
18
- Requires-Dist: build>=1.0.0; extra == "dev"
19
- Requires-Dist: twine>=4.0.0; extra == "dev"
20
- Requires-Dist: pytest>=7.0.0; extra == "dev"
21
- Requires-Dist: black>=23.0.0; extra == "dev"
22
- Requires-Dist: isort>=5.12.0; extra == "dev"
23
- Requires-Dist: bump-my-version>=0.5.0; extra == "dev"
24
- Requires-Dist: pyinstaller>=6.4.0; extra == "dev"
25
- Dynamic: license-file
26
-
27
- # Talks Reducer
28
- Talks Reducer shortens long-form presentations by removing silent gaps and optionally re-encoding them to smaller files. The
29
- project was renamed from **jumpcutter** to emphasize its focus on conference talks and screencasts.
30
-
31
- ![Main demo](docs/assets/screencast-main.gif)
32
-
33
- ## Example
34
- - 1h 37m, 571 MB — Original OBS video recording
35
- - 1h 19m, 751 MB — Talks Reducer
36
- - 1h 19m, 171 MB — Talks Reducer `--small`
37
-
38
- ## Changelog
39
-
40
- See [CHANGELOG.md](CHANGELOG.md).
41
-
42
- ## Install GUI (Windows, macOS)
43
- Go to the [releases page](https://github.com/popstas/talks-reducer/releases) and download the appropriate artifact:
44
-
45
- - **Windows** — `talks-reducer-windows-0.4.0.zip`
46
- - **macOS** — `talks-reducer.app.zip` (but it doesn't work for me)
47
-
48
- ## Install CLI (Linux, Windows, macOS)
49
- ```
50
- pip install talks-reducer
51
- ```
52
-
53
- **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.
54
-
55
- The `--small` preset applies a 720p video scale and 128 kbps audio bitrate, making it useful for sharing talks over constrained
56
- connections. Without `--small`, the script aims to preserve original quality while removing silence.
57
-
58
- Example CLI usage:
59
-
60
- ```sh
61
- talks-reducer --small input.mp4
62
- ```
63
-
64
- When CUDA-capable hardware is available the pipeline leans on GPU encoders to keep export times low, but it still runs great on
65
- CPUs.
66
-
67
- ## Contributing
68
- See `CONTRIBUTION.md` for development setup details and guidance on sharing improvements.
69
-
70
- ## License
71
- Talks Reducer is released under the MIT License. See `LICENSE` for the full text.
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
+ ![Main demo](docs/assets/screencast-main.gif)
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,3 +1,4 @@
1
1
  [console_scripts]
2
2
  talks-reducer = talks_reducer.cli:main
3
3
  talks-reducer-gui = talks_reducer.gui:main
4
+ talks-reducer-server = talks_reducer.server:main
@@ -1,17 +0,0 @@
1
- talks_reducer/__about__.py,sha256=alr6lZlZdo3EO0f71sv2_kzVDYc2BcevvykHDr0-W8U,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.1.dist-info/licenses/LICENSE,sha256=jN17mHNR3e84awmH3AbpWBcBDBzPxEH0rcOFoj1s7sQ,1124
13
- talks_reducer-0.4.1.dist-info/METADATA,sha256=nATcAfXLYIF61laKzOtKPbJ0of3owOuxxZYSDcBinjs,2449
14
- talks_reducer-0.4.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
15
- talks_reducer-0.4.1.dist-info/entry_points.txt,sha256=LCzfSnh_7VXhvl9twoFSAj0C3sG7bayWs2LkxpH7hoI,100
16
- talks_reducer-0.4.1.dist-info/top_level.txt,sha256=pJWGcy__LR9JIEKH3QJyFmk9XrIsiFtqvuMNxFdIzDU,14
17
- talks_reducer-0.4.1.dist-info/RECORD,,