talks-reducer 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {talks_reducer-0.4.0/talks_reducer.egg-info → talks_reducer-0.5.0}/PKG-INFO +93 -71
  2. {talks_reducer-0.4.0 → talks_reducer-0.5.0}/README.md +22 -1
  3. {talks_reducer-0.4.0 → talks_reducer-0.5.0}/pyproject.toml +3 -1
  4. {talks_reducer-0.4.0 → talks_reducer-0.5.0}/setup.cfg +4 -4
  5. {talks_reducer-0.4.0 → talks_reducer-0.5.0}/talks_reducer/__about__.py +1 -1
  6. {talks_reducer-0.4.0 → talks_reducer-0.5.0}/talks_reducer/cli.py +23 -2
  7. {talks_reducer-0.4.0 → talks_reducer-0.5.0}/talks_reducer/ffmpeg.py +28 -6
  8. {talks_reducer-0.4.0 → talks_reducer-0.5.0}/talks_reducer/gui.py +159 -24
  9. {talks_reducer-0.4.0 → talks_reducer-0.5.0}/talks_reducer/models.py +2 -2
  10. {talks_reducer-0.4.0 → talks_reducer-0.5.0}/talks_reducer/pipeline.py +82 -8
  11. talks_reducer-0.5.0/talks_reducer/server.py +353 -0
  12. {talks_reducer-0.4.0 → talks_reducer-0.5.0/talks_reducer.egg-info}/PKG-INFO +93 -71
  13. {talks_reducer-0.4.0 → talks_reducer-0.5.0}/talks_reducer.egg-info/SOURCES.txt +3 -1
  14. {talks_reducer-0.4.0 → talks_reducer-0.5.0}/talks_reducer.egg-info/entry_points.txt +1 -0
  15. {talks_reducer-0.4.0 → talks_reducer-0.5.0}/talks_reducer.egg-info/requires.txt +2 -1
  16. {talks_reducer-0.4.0 → talks_reducer-0.5.0}/tests/test_cli.py +27 -0
  17. talks_reducer-0.5.0/tests/test_server.py +68 -0
  18. {talks_reducer-0.4.0 → talks_reducer-0.5.0}/LICENSE +0 -0
  19. {talks_reducer-0.4.0 → talks_reducer-0.5.0}/talks_reducer/__init__.py +0 -0
  20. {talks_reducer-0.4.0 → talks_reducer-0.5.0}/talks_reducer/__main__.py +0 -0
  21. {talks_reducer-0.4.0 → talks_reducer-0.5.0}/talks_reducer/audio.py +0 -0
  22. {talks_reducer-0.4.0 → talks_reducer-0.5.0}/talks_reducer/chunks.py +0 -0
  23. {talks_reducer-0.4.0 → talks_reducer-0.5.0}/talks_reducer/progress.py +0 -0
  24. {talks_reducer-0.4.0 → talks_reducer-0.5.0}/talks_reducer.egg-info/dependency_links.txt +0 -0
  25. {talks_reducer-0.4.0 → talks_reducer-0.5.0}/talks_reducer.egg-info/top_level.txt +0 -0
  26. {talks_reducer-0.4.0 → talks_reducer-0.5.0}/tests/test_audio.py +0 -0
  27. {talks_reducer-0.4.0 → talks_reducer-0.5.0}/tests/test_pipeline_service.py +0 -0
@@ -1,71 +1,93 @@
1
- Metadata-Version: 2.4
2
- Name: talks-reducer
3
- Version: 0.4.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
- 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.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.
@@ -16,9 +16,14 @@ See [CHANGELOG.md](CHANGELOG.md).
16
16
  ## Install GUI (Windows, macOS)
17
17
  Go to the [releases page](https://github.com/popstas/talks-reducer/releases) and download the appropriate artifact:
18
18
 
19
- - **Windows** — `talks-reducer-windows.zip`
19
+ - **Windows** — `talks-reducer-windows-0.4.0.zip`
20
20
  - **macOS** — `talks-reducer.app.zip` (but it doesn't work for me)
21
21
 
22
+ When extracted on Windows the bundled `talks-reducer.exe` behaves like the
23
+ `python talks_reducer/gui.py` entry point: double-clicking it launches the GUI
24
+ and passing a video file path (for example via *Open with…* or drag-and-drop
25
+ onto the executable) automatically queues that recording for processing.
26
+
22
27
  ## Install CLI (Linux, Windows, macOS)
23
28
  ```
24
29
  pip install talks-reducer
@@ -35,9 +40,25 @@ Example CLI usage:
35
40
  talks-reducer --small input.mp4
36
41
  ```
37
42
 
43
+ ### Speech detection
44
+
45
+ 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.
46
+
38
47
  When CUDA-capable hardware is available the pipeline leans on GPU encoders to keep export times low, but it still runs great on
39
48
  CPUs.
40
49
 
50
+ ## Simple web server
51
+
52
+ Prefer a lightweight browser interface? Launch the Gradio-powered simple mode with:
53
+
54
+ ```sh
55
+ talks-reducer server
56
+ ```
57
+
58
+ This opens a local web page featuring a drag-and-drop upload zone, a **Small video** checkbox that mirrors the CLI preset, a live
59
+ progress indicator, and automatic previews of the processed output. Once the job completes you can inspect the resulting compression
60
+ ratio and download the rendered video directly from the page.
61
+
41
62
  ## Contributing
42
63
  See `CONTRIBUTION.md` for development setup details and guidance on sharing improvements.
43
64
 
@@ -20,6 +20,7 @@ dependencies = [
20
20
  "tkinterdnd2>=0.3.0",
21
21
  "Pillow>=9.0.0",
22
22
  "imageio-ffmpeg>=0.4.8",
23
+ "gradio>=4.0.0",
23
24
  ]
24
25
 
25
26
  [project.optional-dependencies]
@@ -30,12 +31,13 @@ dev = [
30
31
  "black>=23.0.0",
31
32
  "isort>=5.12.0",
32
33
  "bump-my-version>=0.5.0",
33
- "pyinstaller==6.4.0",
34
+ "pyinstaller>=6.4.0",
34
35
  ]
35
36
 
36
37
  [project.scripts]
37
38
  talks-reducer = "talks_reducer.cli:main"
38
39
  talks-reducer-gui = "talks_reducer.gui:main"
40
+ talks-reducer-server = "talks_reducer.server:main"
39
41
 
40
42
  [tool.setuptools.dynamic]
41
43
  version = {attr = "talks_reducer.__about__.__version__"}
@@ -1,4 +1,4 @@
1
- [egg_info]
2
- tag_build =
3
- tag_date = 0
4
-
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.4.0"
5
+ __version__ = "0.5.0"
@@ -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:
@@ -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
@@ -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
 
@@ -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