talks-reducer 0.7.2__tar.gz → 0.8.1__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 (71) hide show
  1. {talks_reducer-0.7.2/talks_reducer.egg-info → talks_reducer-0.8.1}/PKG-INFO +2 -2
  2. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/README.md +1 -1
  3. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/pyproject.toml +10 -0
  4. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer/__about__.py +1 -1
  5. talks_reducer-0.8.1/talks_reducer/cli.py +549 -0
  6. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer/discovery.py +78 -22
  7. talks_reducer-0.8.1/talks_reducer/gui/__init__.py +21 -0
  8. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer/gui/__main__.py +1 -1
  9. talks_reducer-0.7.2/talks_reducer/gui/__init__.py → talks_reducer-0.8.1/talks_reducer/gui/app.py +248 -413
  10. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer/gui/discovery.py +1 -1
  11. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer/gui/layout.py +1 -1
  12. talks_reducer-0.8.1/talks_reducer/gui/progress.py +80 -0
  13. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer/gui/remote.py +11 -3
  14. talks_reducer-0.8.1/talks_reducer/gui/startup.py +202 -0
  15. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer/icons.py +2 -0
  16. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer/pipeline.py +65 -31
  17. talks_reducer-0.8.1/talks_reducer/resources/icons/app-256.png +0 -0
  18. talks_reducer-0.8.1/talks_reducer/resources/icons/app.icns +0 -0
  19. talks_reducer-0.8.1/talks_reducer/resources/icons/app.ico +0 -0
  20. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer/server.py +106 -41
  21. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer/server_tray.py +116 -39
  22. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer/service_client.py +77 -14
  23. {talks_reducer-0.7.2 → talks_reducer-0.8.1/talks_reducer.egg-info}/PKG-INFO +2 -2
  24. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer.egg-info/SOURCES.txt +19 -0
  25. talks_reducer-0.8.1/tests/test_audio.py +253 -0
  26. talks_reducer-0.8.1/tests/test_chunks.py +123 -0
  27. talks_reducer-0.8.1/tests/test_cli.py +680 -0
  28. talks_reducer-0.8.1/tests/test_discovery.py +316 -0
  29. talks_reducer-0.8.1/tests/test_entrypoints.py +62 -0
  30. talks_reducer-0.8.1/tests/test_ffmpeg.py +401 -0
  31. talks_reducer-0.8.1/tests/test_gui_app.py +270 -0
  32. talks_reducer-0.8.1/tests/test_gui_discovery.py +332 -0
  33. talks_reducer-0.8.1/tests/test_gui_layout.py +604 -0
  34. talks_reducer-0.8.1/tests/test_gui_progress.py +38 -0
  35. talks_reducer-0.8.1/tests/test_gui_remote.py +330 -0
  36. talks_reducer-0.8.1/tests/test_gui_startup.py +145 -0
  37. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/tests/test_gui_summary_parsing.py +1 -1
  38. talks_reducer-0.8.1/tests/test_icons.py +37 -0
  39. talks_reducer-0.8.1/tests/test_models_version.py +149 -0
  40. talks_reducer-0.8.1/tests/test_pipeline.py +154 -0
  41. talks_reducer-0.8.1/tests/test_pipeline_service.py +292 -0
  42. talks_reducer-0.8.1/tests/test_progress.py +101 -0
  43. talks_reducer-0.8.1/tests/test_server.py +494 -0
  44. talks_reducer-0.8.1/tests/test_server_tray.py +297 -0
  45. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/tests/test_service_client.py +171 -0
  46. talks_reducer-0.7.2/talks_reducer/cli.py +0 -505
  47. talks_reducer-0.7.2/tests/test_audio.py +0 -47
  48. talks_reducer-0.7.2/tests/test_cli.py +0 -264
  49. talks_reducer-0.7.2/tests/test_discovery.py +0 -119
  50. talks_reducer-0.7.2/tests/test_gui_remote.py +0 -171
  51. talks_reducer-0.7.2/tests/test_pipeline_service.py +0 -105
  52. talks_reducer-0.7.2/tests/test_server.py +0 -181
  53. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/LICENSE +0 -0
  54. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/setup.cfg +0 -0
  55. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer/__init__.py +0 -0
  56. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer/__main__.py +0 -0
  57. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer/audio.py +0 -0
  58. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer/chunks.py +0 -0
  59. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer/ffmpeg.py +0 -0
  60. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer/gui/preferences.py +0 -0
  61. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer/gui/theme.py +0 -0
  62. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer/models.py +0 -0
  63. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer/progress.py +0 -0
  64. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer/resources/__init__.py +0 -0
  65. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer/version_utils.py +0 -0
  66. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer.egg-info/dependency_links.txt +0 -0
  67. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer.egg-info/entry_points.txt +0 -0
  68. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer.egg-info/requires.txt +0 -0
  69. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/talks_reducer.egg-info/top_level.txt +0 -0
  70. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/tests/test_gui_preferences.py +0 -0
  71. {talks_reducer-0.7.2 → talks_reducer-0.8.1}/tests/test_gui_theme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: talks-reducer
3
- Version: 0.7.2
3
+ Version: 0.8.1
4
4
  Summary: CLI for speeding up long-form talks by removing silence
5
5
  Author: Talks Reducer Maintainers
6
6
  License-Expression: MIT
@@ -26,7 +26,7 @@ Requires-Dist: bump-my-version>=0.5.0; extra == "dev"
26
26
  Requires-Dist: pyinstaller>=6.4.0; extra == "dev"
27
27
  Dynamic: license-file
28
28
 
29
- # Talks Reducer
29
+ # Talks Reducer [![Coverage Status](https://coveralls.io/repos/github/popstas/talks-reducer/badge.svg?branch=master)](https://coveralls.io/github/popstas/talks-reducer?branch=master)
30
30
 
31
31
  Talks Reducer shortens long-form presentations by removing silent gaps and optionally re-encoding them to smaller files. The
32
32
  project was renamed from **jumpcutter** to emphasize its focus on conference talks and screencasts.
@@ -1,4 +1,4 @@
1
- # Talks Reducer
1
+ # Talks Reducer [![Coverage Status](https://coveralls.io/repos/github/popstas/talks-reducer/badge.svg?branch=master)](https://coveralls.io/github/popstas/talks-reducer?branch=master)
2
2
 
3
3
  Talks Reducer shortens long-form presentations by removing silent gaps and optionally re-encoding them to smaller files. The
4
4
  project was renamed from **jumpcutter** to emphasize its focus on conference talks and screencasts.
@@ -53,3 +53,13 @@ profile = "black"
53
53
  line_length = 88
54
54
  known_first_party = ["talks_reducer"]
55
55
 
56
+ [tool.setuptools]
57
+ include-package-data = true
58
+
59
+ [tool.setuptools.package-data]
60
+ "talks_reducer.resources" = [
61
+ "icons/*.png",
62
+ "icons/*.ico",
63
+ "icons/*.icns",
64
+ ]
65
+
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.7.2"
5
+ __version__ = "0.8.1"
@@ -0,0 +1,549 @@
1
+ """Command line interface for the talks reducer package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from importlib import import_module
12
+ from pathlib import Path
13
+ from typing import Callable, Dict, List, Optional, Sequence, Tuple
14
+
15
+ from . import audio
16
+ from .ffmpeg import FFmpegNotFoundError
17
+ from .models import ProcessingOptions, default_temp_folder
18
+ from .pipeline import speed_up_video
19
+ from .progress import TqdmProgressReporter
20
+ from .version_utils import resolve_version
21
+
22
+
23
+ def _build_parser() -> argparse.ArgumentParser:
24
+ """Create the argument parser used by the command line interface."""
25
+
26
+ parser = argparse.ArgumentParser(
27
+ description="Modifies a video file to play at different speeds when there is sound vs. silence.",
28
+ )
29
+
30
+ # Add version argument
31
+ pkg_version = resolve_version()
32
+
33
+ parser.add_argument(
34
+ "--version",
35
+ action="version",
36
+ version=f"talks-reducer {pkg_version}",
37
+ )
38
+
39
+ parser.add_argument(
40
+ "input_file",
41
+ type=str,
42
+ nargs="+",
43
+ help="The video file(s) you want modified. Can be one or more directories and / or single files.",
44
+ )
45
+ parser.add_argument(
46
+ "-o",
47
+ "--output_file",
48
+ type=str,
49
+ dest="output_file",
50
+ help="The output file. Only usable if a single file is given. If not included, it'll append _ALTERED to the name.",
51
+ )
52
+ parser.add_argument(
53
+ "--temp_folder",
54
+ type=str,
55
+ default=str(default_temp_folder()),
56
+ help="The file path of the temporary working folder.",
57
+ )
58
+ parser.add_argument(
59
+ "-t",
60
+ "--silent_threshold",
61
+ type=float,
62
+ dest="silent_threshold",
63
+ help="The volume amount that frames' audio needs to surpass to be considered sounded. Defaults to 0.05.",
64
+ )
65
+ parser.add_argument(
66
+ "-S",
67
+ "--sounded_speed",
68
+ type=float,
69
+ dest="sounded_speed",
70
+ help="The speed that sounded (spoken) frames should be played at. Defaults to 1.",
71
+ )
72
+ parser.add_argument(
73
+ "-s",
74
+ "--silent_speed",
75
+ type=float,
76
+ dest="silent_speed",
77
+ help="The speed that silent frames should be played at. Defaults to 4.",
78
+ )
79
+ parser.add_argument(
80
+ "-fm",
81
+ "--frame_margin",
82
+ type=float,
83
+ dest="frame_spreadage",
84
+ help="Some silent frames adjacent to sounded frames are included to provide context. Defaults to 2.",
85
+ )
86
+ parser.add_argument(
87
+ "-sr",
88
+ "--sample_rate",
89
+ type=float,
90
+ dest="sample_rate",
91
+ help="Sample rate of the input and output videos. Usually extracted automatically by FFmpeg.",
92
+ )
93
+ parser.add_argument(
94
+ "--small",
95
+ action="store_true",
96
+ help="Apply small file optimizations: resize video to 720p, audio to 128k bitrate, best compression (uses CUDA if available).",
97
+ )
98
+ parser.add_argument(
99
+ "--url",
100
+ dest="server_url",
101
+ default=None,
102
+ help="Process videos via a Talks Reducer server at the provided base URL (for example, http://localhost:9005).",
103
+ )
104
+ parser.add_argument(
105
+ "--host",
106
+ dest="host",
107
+ default=None,
108
+ help="Shortcut for --url when targeting a Talks Reducer server on port 9005 (for example, localhost).",
109
+ )
110
+ parser.add_argument(
111
+ "--server-stream",
112
+ action="store_true",
113
+ help="Stream remote progress updates when using --url.",
114
+ )
115
+ return parser
116
+
117
+
118
+ def gather_input_files(paths: List[str]) -> List[str]:
119
+ """Expand provided paths into a flat list of files that contain audio streams."""
120
+
121
+ files: List[str] = []
122
+ for input_path in paths:
123
+ if os.path.isfile(input_path) and audio.is_valid_input_file(input_path):
124
+ files.append(os.path.abspath(input_path))
125
+ elif os.path.isdir(input_path):
126
+ for file in os.listdir(input_path):
127
+ candidate = os.path.join(input_path, file)
128
+ if audio.is_valid_input_file(candidate):
129
+ files.append(candidate)
130
+ return files
131
+
132
+
133
+ def _print_total_time(start_time: float) -> None:
134
+ """Print the elapsed processing time since *start_time*."""
135
+
136
+ end_time = time.time()
137
+ total_time = end_time - start_time
138
+ hours, remainder = divmod(total_time, 3600)
139
+ minutes, seconds = divmod(remainder, 60)
140
+ print(f"\nTime: {int(hours)}h {int(minutes)}m {seconds:.2f}s")
141
+
142
+
143
+ class CliApplication:
144
+ """Coordinator for CLI processing with dependency injection support."""
145
+
146
+ def __init__(
147
+ self,
148
+ *,
149
+ gather_files: Callable[[List[str]], List[str]],
150
+ send_video: Optional[Callable[..., Tuple[Path, str, str]]],
151
+ speed_up: Callable[[ProcessingOptions, object], object],
152
+ reporter_factory: Callable[[], object],
153
+ remote_error_message: Optional[str] = None,
154
+ ) -> None:
155
+ self._gather_files = gather_files
156
+ self._send_video = send_video
157
+ self._speed_up = speed_up
158
+ self._reporter_factory = reporter_factory
159
+ self._remote_error_message = remote_error_message
160
+
161
+ def run(self, parsed_args: argparse.Namespace) -> Tuple[int, List[str]]:
162
+ """Execute the CLI pipeline for *parsed_args*."""
163
+
164
+ start_time = time.time()
165
+ files = self._gather_files(parsed_args.input_file)
166
+
167
+ args: Dict[str, object] = {
168
+ key: value for key, value in vars(parsed_args).items() if value is not None
169
+ }
170
+ del args["input_file"]
171
+
172
+ if "host" in args:
173
+ del args["host"]
174
+
175
+ if len(files) > 1 and "output_file" in args:
176
+ del args["output_file"]
177
+
178
+ error_messages: List[str] = []
179
+ reporter_logs: List[str] = []
180
+
181
+ if getattr(parsed_args, "server_url", None):
182
+ remote_success, remote_errors, fallback_logs = self._process_via_server(
183
+ files, parsed_args, start_time
184
+ )
185
+ error_messages.extend(remote_errors)
186
+ reporter_logs.extend(fallback_logs)
187
+ if remote_success:
188
+ return 0, error_messages
189
+
190
+ reporter = self._reporter_factory()
191
+ for message in reporter_logs:
192
+ reporter.log(message)
193
+
194
+ for index, file in enumerate(files):
195
+ print(
196
+ f"Processing file {index + 1}/{len(files)} '{os.path.basename(file)}'"
197
+ )
198
+ local_options = dict(args)
199
+
200
+ option_kwargs: Dict[str, object] = {"input_file": Path(file)}
201
+
202
+ if "output_file" in local_options:
203
+ option_kwargs["output_file"] = Path(local_options["output_file"])
204
+ if "temp_folder" in local_options:
205
+ option_kwargs["temp_folder"] = Path(local_options["temp_folder"])
206
+ if "silent_threshold" in local_options:
207
+ option_kwargs["silent_threshold"] = float(
208
+ local_options["silent_threshold"]
209
+ )
210
+ if "silent_speed" in local_options:
211
+ option_kwargs["silent_speed"] = float(local_options["silent_speed"])
212
+ if "sounded_speed" in local_options:
213
+ option_kwargs["sounded_speed"] = float(local_options["sounded_speed"])
214
+ if "frame_spreadage" in local_options:
215
+ option_kwargs["frame_spreadage"] = int(local_options["frame_spreadage"])
216
+ if "sample_rate" in local_options:
217
+ option_kwargs["sample_rate"] = int(local_options["sample_rate"])
218
+ if "small" in local_options:
219
+ option_kwargs["small"] = bool(local_options["small"])
220
+ options = ProcessingOptions(**option_kwargs)
221
+
222
+ try:
223
+ result = self._speed_up(options, reporter=reporter)
224
+ except FFmpegNotFoundError as exc:
225
+ message = str(exc)
226
+ return 1, [*error_messages, message]
227
+
228
+ reporter.log(f"Completed: {result.output_file}")
229
+ summary_parts: List[str] = []
230
+ time_ratio = getattr(result, "time_ratio", None)
231
+ size_ratio = getattr(result, "size_ratio", None)
232
+ if time_ratio is not None:
233
+ summary_parts.append(f"{time_ratio * 100:.0f}% time")
234
+ if size_ratio is not None:
235
+ summary_parts.append(f"{size_ratio * 100:.0f}% size")
236
+ if summary_parts:
237
+ reporter.log("Result: " + ", ".join(summary_parts))
238
+
239
+ _print_total_time(start_time)
240
+ return 0, error_messages
241
+
242
+ def _process_via_server(
243
+ self,
244
+ files: Sequence[str],
245
+ parsed_args: argparse.Namespace,
246
+ start_time: float,
247
+ ) -> Tuple[bool, List[str], List[str]]:
248
+ """Upload *files* to the configured server and download the results."""
249
+
250
+ if not self._send_video:
251
+ message = self._remote_error_message or "Server processing is unavailable."
252
+ fallback_notice = "Falling back to local processing pipeline."
253
+ return False, [message, fallback_notice], [message, fallback_notice]
254
+
255
+ server_url = parsed_args.server_url
256
+ if not server_url:
257
+ message = "Server URL was not provided."
258
+ fallback_notice = "Falling back to local processing pipeline."
259
+ return False, [message, fallback_notice], [message, fallback_notice]
260
+
261
+ output_override: Optional[Path] = None
262
+ if parsed_args.output_file and len(files) == 1:
263
+ output_override = Path(parsed_args.output_file).expanduser()
264
+ elif parsed_args.output_file and len(files) > 1:
265
+ print(
266
+ "Warning: --output is ignored when processing multiple files via the server.",
267
+ file=sys.stderr,
268
+ )
269
+
270
+ remote_option_values: Dict[str, float] = {}
271
+ if parsed_args.silent_threshold is not None:
272
+ remote_option_values["silent_threshold"] = float(
273
+ parsed_args.silent_threshold
274
+ )
275
+ if parsed_args.silent_speed is not None:
276
+ remote_option_values["silent_speed"] = float(parsed_args.silent_speed)
277
+ if parsed_args.sounded_speed is not None:
278
+ remote_option_values["sounded_speed"] = float(parsed_args.sounded_speed)
279
+
280
+ unsupported_options: List[str] = []
281
+ for name in ("frame_spreadage", "sample_rate", "temp_folder"):
282
+ if getattr(parsed_args, name) is not None:
283
+ unsupported_options.append(f"--{name.replace('_', '-')}")
284
+
285
+ if unsupported_options:
286
+ print(
287
+ "Warning: the following options are ignored when using --url: "
288
+ + ", ".join(sorted(unsupported_options)),
289
+ file=sys.stderr,
290
+ )
291
+
292
+ for index, file in enumerate(files, start=1):
293
+ basename = os.path.basename(file)
294
+ print(
295
+ f"Processing file {index}/{len(files)} '{basename}' via server {server_url}"
296
+ )
297
+ printed_log_header = False
298
+ progress_state: dict[str, tuple[Optional[int], Optional[int], str]] = {}
299
+ stream_updates = bool(getattr(parsed_args, "server_stream", False))
300
+
301
+ def _stream_server_log(line: str) -> None:
302
+ nonlocal printed_log_header
303
+ if not printed_log_header:
304
+ print("\nServer log:", flush=True)
305
+ printed_log_header = True
306
+ print(line, flush=True)
307
+
308
+ def _stream_progress(
309
+ desc: str, current: Optional[int], total: Optional[int], unit: str
310
+ ) -> None:
311
+ key = desc or "Processing"
312
+ state = (current, total, unit)
313
+ if progress_state.get(key) == state:
314
+ return
315
+ progress_state[key] = state
316
+
317
+ parts: List[str] = []
318
+ if current is not None and total and total > 0:
319
+ percent = (current / total) * 100
320
+ parts.append(f"{current}/{total}")
321
+ parts.append(f"{percent:.1f}%")
322
+ elif current is not None:
323
+ parts.append(str(current))
324
+ if unit:
325
+ parts.append(unit)
326
+ message = " ".join(parts).strip()
327
+ print(f"{key}: {message or 'update'}", flush=True)
328
+
329
+ try:
330
+ destination, summary, log_text = self._send_video(
331
+ input_path=Path(file),
332
+ output_path=output_override,
333
+ server_url=server_url,
334
+ small=bool(parsed_args.small),
335
+ **remote_option_values,
336
+ log_callback=_stream_server_log,
337
+ stream_updates=stream_updates,
338
+ progress_callback=_stream_progress if stream_updates else None,
339
+ )
340
+ except Exception as exc: # pragma: no cover - network failure safeguard
341
+ message = f"Failed to process {basename} via server: {exc}"
342
+ fallback_notice = "Falling back to local processing pipeline."
343
+ return False, [message, fallback_notice], [message, fallback_notice]
344
+
345
+ print(summary)
346
+ print(f"Saved processed video to {destination}")
347
+ if log_text.strip() and not printed_log_header:
348
+ print("\nServer log:\n" + log_text)
349
+
350
+ _print_total_time(start_time)
351
+ return True, [], []
352
+
353
+
354
+ def _launch_gui(argv: Sequence[str]) -> bool:
355
+ """Attempt to launch the GUI with the provided arguments."""
356
+
357
+ try:
358
+ gui_module = import_module(".gui", __package__)
359
+ except ImportError:
360
+ return False
361
+
362
+ gui_main = getattr(gui_module, "main", None)
363
+ if gui_main is None:
364
+ return False
365
+
366
+ return bool(gui_main(list(argv)))
367
+
368
+
369
+ def _launch_server(argv: Sequence[str]) -> bool:
370
+ """Attempt to launch the Gradio server with the provided arguments."""
371
+
372
+ try:
373
+ server_module = import_module(".server", __package__)
374
+ except ImportError:
375
+ return False
376
+
377
+ server_main = getattr(server_module, "main", None)
378
+ if server_main is None:
379
+ return False
380
+
381
+ server_main(list(argv))
382
+ return True
383
+
384
+
385
+ def _find_server_tray_binary() -> Optional[Path]:
386
+ """Return the best available path to the server tray executable."""
387
+
388
+ binary_name = "talks-reducer-server-tray"
389
+ candidates: List[Path] = []
390
+
391
+ which_path = shutil.which(binary_name)
392
+ if which_path:
393
+ candidates.append(Path(which_path))
394
+
395
+ try:
396
+ launcher_dir = Path(sys.argv[0]).resolve().parent
397
+ except Exception:
398
+ launcher_dir = None
399
+
400
+ potential_names = [binary_name]
401
+ if sys.platform == "win32":
402
+ potential_names = [f"{binary_name}.exe", binary_name]
403
+
404
+ if launcher_dir is not None:
405
+ for name in potential_names:
406
+ candidates.append(launcher_dir / name)
407
+
408
+ for candidate in candidates:
409
+ if candidate and candidate.exists() and os.access(candidate, os.X_OK):
410
+ return candidate
411
+
412
+ return None
413
+
414
+
415
+ def _should_hide_subprocess_console() -> bool:
416
+ """Return ``True` ` when a detached Windows launch should hide the console."""
417
+
418
+ if sys.platform != "win32":
419
+ return False
420
+
421
+ try:
422
+ import ctypes
423
+ except Exception: # pragma: no cover - optional runtime dependency
424
+ return False
425
+
426
+ try:
427
+ get_console_window = ctypes.windll.kernel32.GetConsoleWindow # type: ignore[attr-defined]
428
+ except Exception: # pragma: no cover - platform specific guard
429
+ return False
430
+
431
+ try:
432
+ handle = get_console_window()
433
+ except Exception: # pragma: no cover - defensive fallback
434
+ return False
435
+
436
+ return handle == 0
437
+
438
+
439
+ def _launch_server_tray_binary(argv: Sequence[str]) -> bool:
440
+ """Launch the packaged server tray executable when available."""
441
+
442
+ command = _find_server_tray_binary()
443
+ if command is None:
444
+ return False
445
+
446
+ tray_args = [str(command), *list(argv)]
447
+
448
+ run_kwargs: Dict[str, object] = {"check": False}
449
+
450
+ if sys.platform == "win32":
451
+ no_window_flag = getattr(subprocess, "CREATE_NO_WINDOW", 0)
452
+ if no_window_flag and _should_hide_subprocess_console():
453
+ run_kwargs["creationflags"] = no_window_flag
454
+
455
+ try:
456
+ result = subprocess.run(tray_args, **run_kwargs)
457
+ except OSError:
458
+ return False
459
+
460
+ return result.returncode == 0
461
+
462
+
463
+ def _launch_server_tray(argv: Sequence[str]) -> bool:
464
+ """Attempt to launch the server tray helper with the provided arguments."""
465
+
466
+ if _launch_server_tray_binary(argv):
467
+ return True
468
+
469
+ try:
470
+ tray_module = import_module(".server_tray", __package__)
471
+ except ImportError:
472
+ return False
473
+
474
+ tray_main = getattr(tray_module, "main", None)
475
+ if tray_main is None:
476
+ return False
477
+
478
+ tray_main(list(argv))
479
+ return True
480
+
481
+
482
+ def main(argv: Optional[Sequence[str]] = None) -> None:
483
+ """Entry point for the command line interface.
484
+
485
+ Launch the GUI when run without arguments, otherwise defer to the CLI.
486
+ """
487
+
488
+ if argv is None:
489
+ argv_list = sys.argv[1:]
490
+ else:
491
+ argv_list = list(argv)
492
+
493
+ if "--server" in argv_list:
494
+ index = argv_list.index("--server")
495
+ tray_args = argv_list[index + 1 :]
496
+ if not _launch_server_tray(tray_args):
497
+ print("Server tray mode is unavailable.", file=sys.stderr)
498
+ sys.exit(1)
499
+ return
500
+
501
+ if argv_list and argv_list[0] in {"server", "serve"}:
502
+ if not _launch_server(argv_list[1:]):
503
+ print("Gradio server mode is unavailable.", file=sys.stderr)
504
+ sys.exit(1)
505
+ return
506
+
507
+ if not argv_list:
508
+ if _launch_gui(argv_list):
509
+ return
510
+
511
+ parser = _build_parser()
512
+ parser.print_help()
513
+ return
514
+
515
+ parser = _build_parser()
516
+ parsed_args = parser.parse_args(argv_list)
517
+
518
+ host_value = getattr(parsed_args, "host", None)
519
+ if host_value:
520
+ parsed_args.server_url = f"http://{host_value}:9005"
521
+
522
+ send_video = None
523
+ remote_error_message: Optional[str] = None
524
+ try: # pragma: no cover - optional dependency guard
525
+ from . import service_client
526
+ except ImportError as exc:
527
+ remote_error_message = (
528
+ "Server mode requires the gradio_client dependency. " f"({exc})"
529
+ )
530
+ else:
531
+ send_video = service_client.send_video
532
+
533
+ application = CliApplication(
534
+ gather_files=gather_input_files,
535
+ send_video=send_video,
536
+ speed_up=speed_up_video,
537
+ reporter_factory=TqdmProgressReporter,
538
+ remote_error_message=remote_error_message,
539
+ )
540
+
541
+ exit_code, error_messages = application.run(parsed_args)
542
+ for message in error_messages:
543
+ print(message, file=sys.stderr)
544
+ if exit_code:
545
+ sys.exit(exit_code)
546
+
547
+
548
+ if __name__ == "__main__":
549
+ main()