• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

popstas / talks-reducer / 26900471568

03 Jun 2026 05:06PM UTC coverage: 72.204% (+1.6%) from 70.559%
26900471568

Pull #137

github

web-flow
Merge afd3dfba2 into a957644ad
Pull Request #137: feat: GUI launch options and remote transfer progress

232 of 261 new or added lines in 5 files covered. (88.89%)

456 existing lines in 5 files now uncovered.

7076 of 9800 relevant lines covered (72.2%)

0.72 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.71
/talks_reducer/server.py
1
"""Gradio-powered simple server for running Talks Reducer in a browser."""
2

3
from __future__ import annotations
1✔
4

5
import atexit
1✔
6
import shutil
1✔
7
import socket
1✔
8
import sys
1✔
9
import tempfile
1✔
10
from contextlib import AbstractContextManager, suppress
1✔
11
from dataclasses import dataclass
1✔
12
from pathlib import Path
1✔
13
from queue import SimpleQueue
1✔
14
from threading import Thread
1✔
15
from typing import Callable, Iterator, Optional, Sequence, cast
1✔
16

17
import gradio as gr
1✔
18

19
from talks_reducer.ffmpeg import FFmpegNotFoundError, is_global_ffmpeg_available
1✔
20
from talks_reducer.icons import find_icon_path
1✔
21
from talks_reducer.models import ProcessingOptions, ProcessingResult
1✔
22
from talks_reducer.pipeline import _input_to_output_filename, speed_up_video
1✔
23
from talks_reducer.progress import (
1✔
24
    CallbackProgressHandle,
25
    ProgressHandle,
26
    SignalProgressReporter,
27
)
28
from talks_reducer.server_args import build_server_parser
1✔
29
from talks_reducer.version_utils import resolve_version
1✔
30

31

32
class _GradioProgressHandle(CallbackProgressHandle):
1✔
33
    """Translate pipeline progress updates into Gradio progress callbacks."""
34

35
    def __init__(
1✔
36
        self,
37
        reporter: "GradioProgressReporter",
38
        *,
39
        desc: str,
40
        total: Optional[int],
41
        unit: str,
42
    ) -> None:
43
        self._reporter = reporter
1✔
44
        super().__init__(
1✔
45
            desc=desc.strip() or "Processing",
46
            total=total,
47
            on_start=self._on_start,
48
            on_update=self._on_update,
49
            infer_total_on_finish=True,
50
        )
51
        self._unit = unit
1✔
52

53
    def _on_start(self, desc: str, total: Optional[int]) -> None:
1✔
54
        self._reporter._start_task(desc, total)
1✔
55

56
    def _on_update(self, current: int, total: Optional[int], desc: str) -> None:
1✔
57
        self._reporter._update_progress(current, total, desc)
1✔
58

59

60
class GradioProgressReporter(SignalProgressReporter):
1✔
61
    """Progress reporter that forwards updates to Gradio's progress widget."""
62

63
    def __init__(
1✔
64
        self,
65
        progress_callback: Optional[Callable[[int, int, str], None]] = None,
66
        *,
67
        log_callback: Optional[Callable[[str], None]] = None,
68
        max_log_lines: int = 500,
69
    ) -> None:
70
        super().__init__()
1✔
71
        self._progress_callback = progress_callback
1✔
72
        self._log_callback = log_callback
1✔
73
        self._max_log_lines = max_log_lines
1✔
74
        self._active_desc = "Processing"
1✔
75
        self.logs: list[str] = []
1✔
76

77
    def log(self, message: str) -> None:
1✔
78
        """Collect log messages for display in the web interface."""
79

80
        text = message.strip()
1✔
81
        if not text:
1✔
82
            return
×
83
        self.logs.append(text)
1✔
84
        if len(self.logs) > self._max_log_lines:
1✔
85
            self.logs = self.logs[-self._max_log_lines :]
×
86
        if self._log_callback is not None:
1✔
87
            self._log_callback(text)
1✔
88

89
    def task(
1✔
90
        self,
91
        *,
92
        desc: str = "",
93
        total: Optional[int] = None,
94
        unit: str = "",
95
    ) -> AbstractContextManager[ProgressHandle]:
96
        """Create a context manager bridging pipeline progress to Gradio."""
97

98
        return _GradioProgressHandle(self, desc=desc, total=total, unit=unit)
1✔
99

100
    # Internal helpers -------------------------------------------------
101

102
    def _start_task(self, desc: str, total: Optional[int]) -> None:
1✔
103
        self._active_desc = desc or "Processing"
1✔
104
        self._update_progress(0, total, self._active_desc)
1✔
105

106
    def _update_progress(
1✔
107
        self, current: int, total: Optional[int], desc: Optional[str]
108
    ) -> None:
109
        if self._progress_callback is None:
1✔
110
            return
1✔
111
        if total is None or total <= 0:
1✔
112
            total_value = max(1, int(current) + 1 if current >= 0 else 1)
×
113
            bounded_current = max(0, int(current))
×
114
        else:
115
            total_value = max(int(total), 1, int(current))
1✔
116
            bounded_current = max(0, min(int(current), int(total_value)))
1✔
117
        display_desc = desc or self._active_desc
1✔
118
        self._progress_callback(bounded_current, total_value, display_desc)
1✔
119

120

121
def _format_progress_percent(received: int, total: Optional[int]) -> str:
1✔
122
    """Return a compact ``n/total (xx%)`` description for transfer logging."""
123

124
    if total and total > 0:
1✔
125
        percent = min(int(received * 100 / total), 100)
1✔
126
        return f"{_format_file_size(received)}/{_format_file_size(total)} ({percent}%)"
1✔
NEW
127
    return _format_file_size(received)
×
128

129

130
class TransferProgressMiddleware:
1✔
131
    """ASGI middleware that logs incremental upload/download byte progress.
132

133
    Gradio handles file uploads and downloads through its own HTTP routes, so
134
    the pipeline never observes those transfers. This middleware watches the raw
135
    request/response byte streams for the upload and ``file=`` download routes
136
    and logs progress to the server console as the bytes flow, mirroring the
137
    client-side ``Uploading``/``Downloading`` progress.
138
    """
139

140
    def __init__(
1✔
141
        self,
142
        app: Callable,
143
        *,
144
        log: Optional[Callable[[str], None]] = None,
145
        step_percent: int = 20,
146
    ) -> None:
147
        self.app = app
1✔
148
        self._log = log or (lambda message: print(message, flush=True))
1✔
149
        self._step_percent = max(1, int(step_percent))
1✔
150

151
    def _make_reporter(
1✔
152
        self, label: str, total: Optional[int]
153
    ) -> Callable[[int, bool], None]:
154
        last_step = {"value": -1}
1✔
155

156
        def report(received: int, final: bool) -> None:
1✔
157
            if total and total > 0:
1✔
158
                step = int(received * 100 / total) // self._step_percent
1✔
159
            else:
NEW
160
                step = received // (8 * 1024 * 1024)
×
161
            if final or step != last_step["value"]:
1✔
162
                last_step["value"] = step
1✔
163
                self._log(f"{label}: {_format_progress_percent(received, total)}")
1✔
164

165
        return report
1✔
166

167
    @staticmethod
1✔
168
    def _content_length(raw_headers: object) -> Optional[int]:
1✔
169
        for key, value in raw_headers or []:
1✔
170
            if key.lower() == b"content-length":
1✔
171
                with suppress(TypeError, ValueError):
1✔
172
                    return int(value.decode("latin-1"))
1✔
NEW
173
        return None
×
174

175
    async def __call__(self, scope: dict, receive: Callable, send: Callable) -> None:
1✔
176
        if scope.get("type") != "http":
1✔
NEW
177
            await self.app(scope, receive, send)
×
NEW
178
            return
×
179

180
        path = scope.get("path", "") or ""
1✔
181
        method = scope.get("method", "GET")
1✔
182
        is_upload = method == "POST" and path.rstrip("/").endswith("upload")
1✔
183
        is_download = method == "GET" and "file=" in path
1✔
184

185
        if not is_upload and not is_download:
1✔
186
            await self.app(scope, receive, send)
1✔
187
            return
1✔
188

189
        if is_upload:
1✔
190
            total = self._content_length(scope.get("headers"))
1✔
191
            report = self._make_reporter("Receiving upload", total)
1✔
192
            received = 0
1✔
193

194
            async def wrapped_receive() -> dict:
1✔
195
                nonlocal received
196
                message = await receive()
1✔
197
                if message.get("type") == "http.request":
1✔
198
                    body = message.get("body", b"")
1✔
199
                    if body:
1✔
200
                        received += len(body)
1✔
201
                        report(received, not message.get("more_body", False))
1✔
202
                return message
1✔
203

204
            await self.app(scope, wrapped_receive, send)
1✔
205
            return
1✔
206

207
        sent = {"value": 0}
1✔
208
        reporter: dict[str, Optional[Callable[[int, bool], None]]] = {"fn": None}
1✔
209

210
        async def wrapped_send(message: dict) -> None:
1✔
211
            message_type = message.get("type")
1✔
212
            if message_type == "http.response.start":
1✔
213
                total = self._content_length(message.get("headers"))
1✔
214
                filename = path.split("file=", 1)[-1].rsplit("/", 1)[-1] or "file"
1✔
215
                reporter["fn"] = self._make_reporter(
1✔
216
                    f"Sending download {filename}", total
217
                )
218
            elif message_type == "http.response.body":
1✔
219
                body = message.get("body", b"")
1✔
220
                if body:
1✔
221
                    sent["value"] += len(body)
1✔
222
                if reporter["fn"] is not None:
1✔
223
                    reporter["fn"](sent["value"], not message.get("more_body", False))
1✔
224
            await send(message)
1✔
225

226
        await self.app(scope, receive, wrapped_send)
1✔
227

228

229
def build_launch_app_kwargs() -> dict[str, object]:
1✔
230
    """Return ``app_kwargs`` enabling server-side transfer progress logging."""
231

232
    try:
1✔
233
        from starlette.middleware import Middleware
1✔
234
    except Exception:  # pragma: no cover - starlette ships with gradio
235
        return {}
236

237
    return {"middleware": [Middleware(TransferProgressMiddleware)]}
1✔
238

239

240
_FAVICON_FILENAMES = (
1✔
241
    ("app.ico", "app-256.png", "app.png")
242
    if sys.platform.startswith("win")
243
    else ("app-256.png", "app.png", "app.ico")
244
)
245
_FAVICON_PATH = find_icon_path(filenames=_FAVICON_FILENAMES)
1✔
246
_FAVICON_PATH_STR = str(_FAVICON_PATH) if _FAVICON_PATH else None
1✔
247
_WORKSPACES: list[Path] = []
1✔
248

249

250
def _allocate_workspace() -> Path:
1✔
251
    """Create and remember a workspace directory for a single request."""
252

253
    path = Path(tempfile.mkdtemp(prefix="talks_reducer_web_"))
1✔
254
    _WORKSPACES.append(path)
1✔
255
    return path
1✔
256

257

258
def _cleanup_workspaces() -> None:
1✔
259
    """Remove any workspaces that remain when the process exits."""
260

261
    for workspace in _WORKSPACES:
1✔
262
        if workspace.exists():
1✔
263
            with suppress(Exception):
1✔
264
                shutil.rmtree(workspace)
1✔
265
    _WORKSPACES.clear()
1✔
266

267

268
def _describe_server_host() -> str:
1✔
269
    """Return a human-readable description of the server hostname and IP."""
270

271
    hostname = socket.gethostname().strip()
1✔
272
    ip_address = ""
1✔
273

274
    with suppress(OSError):
1✔
275
        resolved_ip = socket.gethostbyname(hostname or "localhost")
1✔
276
        if resolved_ip:
1✔
277
            ip_address = resolved_ip
1✔
278

279
    if hostname and ip_address and hostname != ip_address:
1✔
280
        return f"{hostname} ({ip_address})"
1✔
281
    if ip_address:
1✔
282
        return ip_address
×
283
    if hostname:
1✔
284
        return hostname
×
285
    return "unknown"
1✔
286

287

288
def _build_output_path(
1✔
289
    input_path: Path,
290
    workspace: Path,
291
    small: bool,
292
    *,
293
    small_480: bool = False,
294
    add_codec_suffix: bool = False,
295
    video_codec: str = "hevc",
296
    silent_speed: float | None = None,
297
    sounded_speed: float | None = None,
298
) -> Path:
299
    """Mirror the CLI output naming scheme inside the workspace directory."""
300

301
    normalized_codec = str(video_codec or "hevc").strip().lower()
1✔
302
    target_height = 480 if small and small_480 else None
1✔
303
    output_name = _input_to_output_filename(
1✔
304
        input_path,
305
        small,
306
        target_height,
307
        video_codec=normalized_codec,
308
        add_codec_suffix=add_codec_suffix,
309
        silent_speed=silent_speed,
310
        sounded_speed=sounded_speed,
311
    )
312
    return workspace / output_name.name
1✔
313

314

315
def _format_duration(seconds: float) -> str:
1✔
316
    """Return a compact human-readable duration string."""
317

318
    if seconds <= 0:
1✔
319
        return "0s"
1✔
320
    total_seconds = int(round(seconds))
1✔
321
    hours, remainder = divmod(total_seconds, 3600)
1✔
322
    minutes, secs = divmod(remainder, 60)
1✔
323
    parts: list[str] = []
1✔
324
    if hours:
1✔
325
        parts.append(f"{hours}h")
1✔
326
    if minutes or hours:
1✔
327
        parts.append(f"{minutes}m")
1✔
328
    parts.append(f"{secs}s")
1✔
329
    return " ".join(parts)
1✔
330

331

332
def _format_file_size(num_bytes: int) -> str:
1✔
333
    """Return a compact human-readable file size string."""
334

335
    size = float(max(0, int(num_bytes)))
1✔
336
    for unit in ("B", "KB", "MB", "GB"):
1✔
337
        if size < 1024.0:
1✔
338
            if unit == "B":
1✔
339
                return f"{int(size)} {unit}"
1✔
340
            return f"{size:.1f} {unit}"
1✔
341
        size /= 1024.0
1✔
342
    return f"{size:.1f} TB"
×
343

344

345
def _format_summary(result: ProcessingResult) -> str:
1✔
346
    """Produce a Markdown summary of the processing result."""
347

348
    lines = [
1✔
349
        f"**Input:** `{result.input_file.name}`",
350
        f"**Output:** `{result.output_file.name}`",
351
    ]
352

353
    duration_line = (
1✔
354
        f"**Duration:** {_format_duration(result.output_duration)}"
355
        f" ({_format_duration(result.original_duration)} original)"
356
    )
357
    if result.time_ratio is not None:
1✔
358
        duration_line += f" — {result.time_ratio * 100:.1f}% of the original"
1✔
359
    lines.append(duration_line)
1✔
360

361
    if result.size_ratio is not None:
1✔
362
        size_percent = result.size_ratio * 100
1✔
363
        lines.append(f"**Size:** {size_percent:.1f}% of the original file")
1✔
364

365
    lines.append(f"**Chunks merged:** {result.chunk_count}")
1✔
366
    lines.append(f"**Encoder:** {'CUDA' if result.used_cuda else 'CPU'}")
1✔
367

368
    return "\n".join(lines)
1✔
369

370

371
PipelineEvent = tuple[str, object]
1✔
372

373

374
def _default_reporter_factory(
1✔
375
    progress_callback: Optional[Callable[[int, int, str], None]],
376
    log_callback: Callable[[str], None],
377
) -> SignalProgressReporter:
378
    """Construct a :class:`GradioProgressReporter` with the given callbacks."""
379

380
    return GradioProgressReporter(
1✔
381
        progress_callback=progress_callback,
382
        log_callback=log_callback,
383
    )
384

385

386
def run_pipeline_job(
1✔
387
    options: ProcessingOptions,
388
    *,
389
    speed_up: Callable[[ProcessingOptions, SignalProgressReporter], ProcessingResult],
390
    reporter_factory: Callable[
391
        [Optional[Callable[[int, int, str], None]], Callable[[str], None]],
392
        SignalProgressReporter,
393
    ],
394
    events: SimpleQueue[PipelineEvent],
395
    enable_progress: bool = True,
396
    start_in_thread: bool = True,
397
) -> Iterator[PipelineEvent]:
398
    """Execute the processing pipeline and yield emitted events."""
399

400
    def _emit(kind: str, payload: object) -> None:
1✔
401
        events.put((kind, payload))
1✔
402

403
    progress_callback: Optional[Callable[[int, int, str], None]] = None
1✔
404
    if enable_progress:
1✔
405
        progress_callback = lambda current, total, desc: _emit(
1✔
406
            "progress", (current, total, desc)
407
        )
408

409
    reporter = reporter_factory(
1✔
410
        progress_callback, lambda message: _emit("log", message)
411
    )
412

413
    def _worker() -> None:
1✔
414
        try:
1✔
415
            result = speed_up(options, reporter=reporter)
1✔
416
        except FFmpegNotFoundError as exc:  # pragma: no cover - depends on runtime env
417
            _emit("error", gr.Error(str(exc)))
418
        except FileNotFoundError as exc:
1✔
419
            _emit("error", gr.Error(str(exc)))
×
420
        except Exception as exc:  # pragma: no cover - defensive fallback
421
            reporter.log(f"Error: {exc}")
422
            _emit("error", gr.Error(f"Failed to process the video: {exc}"))
423
        else:
424
            reporter.log("Processing complete.")
1✔
425
            _emit("result", result)
1✔
426
        finally:
427
            _emit("done", None)
1✔
428

429
    thread: Optional[Thread] = None
1✔
430
    if start_in_thread:
1✔
431
        thread = Thread(target=_worker, daemon=True)
×
432
        thread.start()
×
433
    else:
434
        _worker()
1✔
435

436
    try:
1✔
437
        while True:
1✔
438
            kind, payload = events.get()
1✔
439
            if kind == "done":
1✔
440
                break
1✔
441
            yield (kind, payload)
1✔
442
    finally:
443
        if thread is not None:
1✔
444
            thread.join()
×
445

446

447
@dataclass
1✔
448
class ProcessVideoDependencies:
1✔
449
    """Container for dependencies used by :func:`process_video`."""
450

451
    speed_up: Callable[
1✔
452
        [ProcessingOptions, SignalProgressReporter], ProcessingResult
453
    ] = speed_up_video
454
    reporter_factory: Callable[
1✔
455
        [Optional[Callable[[int, int, str], None]], Callable[[str], None]],
456
        SignalProgressReporter,
457
    ] = _default_reporter_factory
458
    queue_factory: Callable[[], SimpleQueue[PipelineEvent]] = SimpleQueue
1✔
459
    run_pipeline_job_func: Callable[..., Iterator[PipelineEvent]] = run_pipeline_job
1✔
460
    start_in_thread: bool = True
1✔
461

462

463
def process_video(
1✔
464
    file_path: Optional[str],
465
    small_video: bool,
466
    small_480: bool = False,
467
    optimize: bool = True,
468
    video_codec: str = "hevc",
469
    add_codec_suffix: bool = False,
470
    use_global_ffmpeg: bool = False,
471
    silent_threshold: Optional[float] = None,
472
    sounded_speed: Optional[float] = None,
473
    silent_speed: Optional[float] = None,
474
    progress: Optional[gr.Progress] = gr.Progress(track_tqdm=False),
475
    *,
476
    dependencies: Optional[ProcessVideoDependencies] = None,
477
) -> Iterator[tuple[Optional[str], str, str, Optional[str]]]:
478
    """Run the Talks Reducer pipeline for a single uploaded file."""
479

480
    if not file_path:
1✔
481
        raise gr.Error("Please upload a video file to begin processing.")
1✔
482

483
    input_path = Path(file_path)
1✔
484
    if not input_path.exists():
1✔
485
        raise gr.Error("The uploaded file is no longer available on the server.")
1✔
486

487
    upload_size = input_path.stat().st_size
1✔
488
    upload_received_message = (
1✔
489
        f"Upload received: {input_path.name} ({_format_file_size(upload_size)})"
490
    )
491

492
    codec_value = (video_codec or "hevc").strip().lower()
1✔
493
    if codec_value not in {"h264", "hevc", "av1"}:
1✔
494
        codec_value = "hevc"
×
495

496
    normalized_sounded_speed: Optional[float] = None
1✔
497
    if sounded_speed is not None:
1✔
498
        normalized_sounded_speed = float(sounded_speed)
1✔
499

500
    normalized_silent_speed: Optional[float] = None
1✔
501
    if silent_speed is not None:
1✔
502
        normalized_silent_speed = float(silent_speed)
1✔
503

504
    workspace = _allocate_workspace()
1✔
505
    temp_folder = workspace / "temp"
1✔
506
    output_file = _build_output_path(
1✔
507
        input_path,
508
        workspace,
509
        small_video,
510
        small_480=small_480,
511
        add_codec_suffix=add_codec_suffix,
512
        video_codec=codec_value,
513
        silent_speed=normalized_silent_speed,
514
        sounded_speed=normalized_sounded_speed,
515
    )
516

517
    deps = dependencies or ProcessVideoDependencies()
1✔
518
    events = deps.queue_factory()
1✔
519

520
    option_kwargs: dict[str, float | str | bool] = {
1✔
521
        "video_codec": codec_value,
522
        "prefer_global_ffmpeg": bool(use_global_ffmpeg),
523
        "optimize": bool(optimize),
524
    }
525
    if add_codec_suffix:
1✔
526
        option_kwargs["add_codec_suffix"] = True
1✔
527
    if silent_threshold is not None:
1✔
528
        option_kwargs["silent_threshold"] = float(silent_threshold)
1✔
529
    if normalized_sounded_speed is not None:
1✔
530
        option_kwargs["sounded_speed"] = normalized_sounded_speed
1✔
531
    if normalized_silent_speed is not None:
1✔
532
        option_kwargs["silent_speed"] = normalized_silent_speed
1✔
533

534
    if small_video and small_480:
1✔
535
        option_kwargs["small_target_height"] = 480
1✔
536

537
    options = ProcessingOptions(
1✔
538
        input_file=input_path,
539
        output_file=output_file,
540
        temp_folder=temp_folder,
541
        small=small_video,
542
        **option_kwargs,
543
    )
544

545
    event_stream = deps.run_pipeline_job_func(
1✔
546
        options,
547
        speed_up=deps.speed_up,
548
        reporter_factory=deps.reporter_factory,
549
        events=events,
550
        enable_progress=progress is not None,
551
        start_in_thread=deps.start_in_thread,
552
    )
553

554
    collected_logs: list[str] = [upload_received_message]
1✔
555
    final_result: Optional[ProcessingResult] = None
1✔
556
    error: Optional[gr.Error] = None
1✔
557

558
    yield (
1✔
559
        gr.update(),
560
        "\n".join(collected_logs),
561
        gr.update(),
562
        gr.update(),
563
    )
564

565
    for kind, payload in event_stream:
1✔
566
        if kind == "log":
1✔
567
            text = str(payload).strip()
1✔
568
            if text:
1✔
569
                collected_logs.append(text)
1✔
570
                yield (
1✔
571
                    gr.update(),
572
                    "\n".join(collected_logs),
573
                    gr.update(),
574
                    gr.update(),
575
                )
576
        elif kind == "progress":
1✔
577
            if progress is not None:
1✔
578
                current, total, desc = cast(tuple[int, int, str], payload)
1✔
579
                percent = current / total if total > 0 else 0
1✔
580
                progress(percent, total=total, desc=desc)
1✔
581
        elif kind == "result":
1✔
582
            final_result = payload  # type: ignore[assignment]
1✔
583
        elif kind == "error":
1✔
584
            error = payload  # type: ignore[assignment]
1✔
585

586
    if error is not None:
1✔
587
        raise error
1✔
588

589
    if final_result is None:
1✔
590
        raise gr.Error("Failed to process the video.")
1✔
591

592
    log_text = "\n".join(collected_logs)
1✔
593
    summary = _format_summary(final_result)
1✔
594

595
    yield (
1✔
596
        str(final_result.output_file),
597
        log_text,
598
        summary,
599
        str(final_result.output_file),
600
    )
601

602

603
def build_interface() -> gr.Blocks:
1✔
604
    """Construct the Gradio Blocks application for the simple web UI."""
605

606
    server_identity = _describe_server_host()
1✔
607
    global_ffmpeg_available = is_global_ffmpeg_available()
1✔
608

609
    app_version = resolve_version()
1✔
610
    version_suffix = (
1✔
611
        f" v{app_version}" if app_version and app_version != "unknown" else ""
612
    )
613

614
    with gr.Blocks(title=f"Talks Reducer Web UI{version_suffix}") as demo:
1✔
615
        gr.Markdown(f"""
1✔
616
            ## Talks Reducer Web UI{version_suffix}
617
            Drop a video into the zone below or click to browse. **Small video** is enabled
618
            by default to apply the 720p/128k preset before processing starts—clear it to
619
            keep the original resolution or pair it with **Target 480p** to downscale
620
            further. Choose **Video codec** to switch between h.265 (≈25% smaller),
621
            h.264 (≈10% faster), and av1 (no advantages) compression, and enable
622
            **Use global FFmpeg** when your system install offers hardware encoders that the
623
            bundled build lacks.
624

625
            Video will be rendered on server **{server_identity}**.
626
            """.strip())
627

628
        with gr.Column():
1✔
629
            file_input = gr.File(
1✔
630
                label="Video file",
631
                file_types=["video"],
632
                type="filepath",
633
            )
634

635
        with gr.Row():
1✔
636
            small_checkbox = gr.Checkbox(label="Small video", value=True)
1✔
637
            small_480_checkbox = gr.Checkbox(label="Target 480p", value=False)
1✔
638
            optimize_checkbox = gr.Checkbox(label="Optimized encoding", value=True)
1✔
639

640
        codec_dropdown = gr.Dropdown(
1✔
641
            choices=[
642
                ("h.265 (25% smaller)", "hevc"),
643
                ("h.264 (10% faster)", "h264"),
644
                ("av1 (no advantages)", "av1"),
645
            ],
646
            value="hevc",
647
            label="Video codec",
648
        )
649

650
        global_ffmpeg_info = (
1✔
651
            "Prefer the FFmpeg binary from PATH instead of the bundled build."
652
            if global_ffmpeg_available
653
            else "Global FFmpeg not detected; the bundled build will be used."
654
        )
655
        add_codec_suffix_checkbox = gr.Checkbox(
1✔
656
            label="Append codec to filename",
657
            value=False,
658
            info="Append the selected codec (e.g. _h264) to the output filename.",
659
        )
660

661
        use_global_ffmpeg_checkbox = gr.Checkbox(
1✔
662
            label="Use global FFmpeg",
663
            value=False,
664
            info=global_ffmpeg_info,
665
            interactive=global_ffmpeg_available,
666
        )
667

668
        with gr.Column():
1✔
669
            silent_speed_input = gr.Slider(
1✔
670
                minimum=1.0,
671
                maximum=10.0,
672
                value=4.0,
673
                step=0.1,
674
                label="Silent speed",
675
            )
676
            sounded_speed_input = gr.Slider(
1✔
677
                minimum=0.5,
678
                maximum=3.0,
679
                value=1.0,
680
                step=0.01,
681
                label="Sounded speed",
682
            )
683
            silent_threshold_input = gr.Slider(
1✔
684
                minimum=0.0,
685
                maximum=1.0,
686
                value=0.01,
687
                step=0.01,
688
                label="Silent threshold",
689
            )
690

691
        video_output = gr.Video(label="Processed video")
1✔
692
        summary_output = gr.Markdown()
1✔
693
        download_output = gr.File(label="Download processed file", interactive=False)
1✔
694
        log_output = gr.Textbox(label="Log", lines=12, interactive=False)
1✔
695

696
        file_input.upload(
1✔
697
            process_video,
698
            inputs=[
699
                file_input,
700
                small_checkbox,
701
                small_480_checkbox,
702
                optimize_checkbox,
703
                codec_dropdown,
704
                add_codec_suffix_checkbox,
705
                use_global_ffmpeg_checkbox,
706
                silent_threshold_input,
707
                sounded_speed_input,
708
                silent_speed_input,
709
            ],
710
            outputs=[video_output, log_output, summary_output, download_output],
711
            queue=True,
712
            api_name="process_video",
713
        )
714

715
    demo.queue(default_concurrency_limit=1)
1✔
716
    return demo
1✔
717

718

719
def main(argv: Optional[Sequence[str]] = None) -> None:
1✔
720
    """Launch the Gradio server from the command line."""
721

722
    parser = build_server_parser(
×
723
        description="Launch the Talks Reducer web UI.", default_open_browser=True
724
    )
725
    args = parser.parse_args(argv)
×
726

727
    demo = build_interface()
×
728
    demo.launch(
×
729
        server_name=args.host,
730
        server_port=args.port,
731
        share=args.share,
732
        inbrowser=args.open_browser,
733
        favicon_path=_FAVICON_PATH_STR,
734
        app_kwargs=build_launch_app_kwargs(),
735
    )
736

737

738
atexit.register(_cleanup_workspaces)
1✔
739

740

741
__all__ = [
1✔
742
    "GradioProgressReporter",
743
    "TransferProgressMiddleware",
744
    "build_interface",
745
    "build_launch_app_kwargs",
746
    "main",
747
    "process_video",
748
]
749

750

751
if __name__ == "__main__":  # pragma: no cover - convenience entry point
752
    main()
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc