talks-reducer 0.4.1__py3-none-any.whl → 0.5.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.4.1"
5
+ __version__ = "0.5.1"
talks_reducer/cli.py CHANGED
@@ -18,7 +18,7 @@ try:
18
18
  except Exception: # pragma: no cover - fallback if metadata file missing
19
19
  _about_version = ""
20
20
  from .ffmpeg import FFmpegNotFoundError
21
- from .models import ProcessingOptions
21
+ from .models import ProcessingOptions, default_temp_folder
22
22
  from .pipeline import speed_up_video
23
23
  from .progress import TqdmProgressReporter
24
24
 
@@ -55,7 +55,7 @@ def _build_parser() -> argparse.ArgumentParser:
55
55
  parser.add_argument(
56
56
  "--temp_folder",
57
57
  type=str,
58
- default="TEMP",
58
+ default=str(default_temp_folder()),
59
59
  help="The file path of the temporary working folder.",
60
60
  )
61
61
  parser.add_argument(
@@ -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
@@ -52,6 +52,9 @@ def find_ffmpeg() -> Optional[str]:
52
52
  "C:\\ProgramData\\chocolatey\\bin\\ffmpeg.exe",
53
53
  "C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe",
54
54
  "C:\\ffmpeg\\bin\\ffmpeg.exe",
55
+ "/usr/local/bin/ffmpeg",
56
+ "/opt/homebrew/bin/ffmpeg",
57
+ "/usr/bin/ffmpeg",
55
58
  "ffmpeg",
56
59
  ]
57
60
 
@@ -92,6 +95,9 @@ def find_ffprobe() -> Optional[str]:
92
95
  "C:\\ProgramData\\chocolatey\\bin\\ffprobe.exe",
93
96
  "C:\\Program Files\\ffmpeg\\bin\\ffprobe.exe",
94
97
  "C:\\ffmpeg\\bin\\ffprobe.exe",
98
+ "/usr/local/bin/ffprobe",
99
+ "/opt/homebrew/bin/ffprobe",
100
+ "/usr/bin/ffprobe",
95
101
  "ffprobe",
96
102
  ]
97
103
 
@@ -242,11 +248,29 @@ def run_timed_ffmpeg_command(
242
248
  if not line:
243
249
  continue
244
250
 
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())
251
+ # Filter out excessive progress output, only show important lines
252
+ if any(
253
+ keyword in line.lower()
254
+ for keyword in [
255
+ "error",
256
+ "warning",
257
+ "encoded successfully",
258
+ "frame=",
259
+ "time=",
260
+ "size=",
261
+ "bitrate=",
262
+ "speed=",
263
+ ]
264
+ ):
265
+ sys.stderr.write(line)
266
+ sys.stderr.flush()
267
+
268
+ # Send FFmpeg output to reporter for GUI display (filtered)
269
+ if any(
270
+ keyword in line.lower()
271
+ for keyword in ["error", "warning", "encoded successfully", "frame="]
272
+ ):
273
+ progress_reporter.log(line.strip())
250
274
 
251
275
  match = re.search(r"frame=\s*(\d+)", line)
252
276
  if match:
@@ -365,7 +389,11 @@ def build_video_commands(
365
389
  # Use a fast software encoder instead
366
390
  video_encoder_args = ["-c:v libx264", "-preset veryfast", "-crf 23"]
367
391
 
368
- audio_parts = ["-c:a aac", f'"{output_file}"', "-loglevel info -stats -hide_banner"]
392
+ audio_parts = [
393
+ "-c:a aac",
394
+ f'"{output_file}"',
395
+ "-loglevel warning -stats -hide_banner",
396
+ ]
369
397
 
370
398
  full_command_parts = (
371
399
  global_parts + input_parts + output_parts + video_encoder_args + audio_parts
talks_reducer/gui.py CHANGED
@@ -20,7 +20,7 @@ try:
20
20
  from .cli import gather_input_files
21
21
  from .cli import main as cli_main
22
22
  from .ffmpeg import FFmpegNotFoundError
23
- from .models import ProcessingOptions
23
+ from .models import ProcessingOptions, default_temp_folder
24
24
  from .pipeline import speed_up_video
25
25
  from .progress import ProgressHandle, SignalProgressReporter
26
26
  except ImportError: # pragma: no cover - handled at runtime
@@ -34,7 +34,7 @@ except ImportError: # pragma: no cover - handled at runtime
34
34
  from talks_reducer.cli import gather_input_files
35
35
  from talks_reducer.cli import main as cli_main
36
36
  from talks_reducer.ffmpeg import FFmpegNotFoundError
37
- from talks_reducer.models import ProcessingOptions
37
+ from talks_reducer.models import ProcessingOptions, default_temp_folder
38
38
  from talks_reducer.pipeline import speed_up_video
39
39
  from talks_reducer.progress import ProgressHandle, SignalProgressReporter
40
40
 
@@ -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)
@@ -468,7 +480,7 @@ class TalksReducerGUI:
468
480
  self.advanced_frame, "Output file", self.output_var, row=0, browse=True
469
481
  )
470
482
 
471
- self.temp_var = self.tk.StringVar(value="TEMP")
483
+ self.temp_var = self.tk.StringVar(value=str(default_temp_folder()))
472
484
  self._add_entry(
473
485
  self.advanced_frame, "Temp folder", self.temp_var, row=1, browse=True
474
486
  )
@@ -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
@@ -2,11 +2,36 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from dataclasses import dataclass
5
+ import os
6
+ import sys
7
+ import tempfile
8
+ from dataclasses import dataclass, field
6
9
  from pathlib import Path
7
10
  from typing import Optional
8
11
 
9
12
 
13
+ def default_temp_folder() -> Path:
14
+ """Return an OS-appropriate default temporary workspace directory."""
15
+
16
+ if sys.platform == "darwin":
17
+ base = Path.home() / "Library" / "Application Support" / "talks-reducer"
18
+ elif sys.platform == "win32":
19
+ appdata = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA")
20
+ base = (
21
+ Path(appdata)
22
+ if appdata
23
+ else Path.home() / "AppData" / "Local" / "talks-reducer"
24
+ )
25
+ else:
26
+ xdg_runtime = os.environ.get("XDG_RUNTIME_DIR")
27
+ if xdg_runtime:
28
+ base = Path(xdg_runtime) / "talks-reducer"
29
+ else:
30
+ base = Path(tempfile.gettempdir()) / "talks-reducer"
31
+
32
+ return base / "temp"
33
+
34
+
10
35
  @dataclass(frozen=True)
11
36
  class ProcessingOptions:
12
37
  """Configuration values controlling how the talks reducer processes media.
@@ -18,13 +43,13 @@ class ProcessingOptions:
18
43
  input_file: Path
19
44
  output_file: Optional[Path] = None
20
45
  frame_rate: float = 30.0
21
- sample_rate: int = 44100
22
- silent_threshold: float = 0.03
46
+ sample_rate: int = 48000
47
+ silent_threshold: float = 0.05
23
48
  silent_speed: float = 4.0
24
49
  sounded_speed: float = 1.0
25
50
  frame_spreadage: int = 2
26
51
  audio_fade_envelope_size: int = 400
27
- temp_folder: Path = Path("TEMP")
52
+ temp_folder: Path = field(default_factory=default_temp_folder)
28
53
  small: bool = False
29
54
 
30
55