localcaption 0.2.0__tar.gz → 0.3.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.
- {localcaption-0.2.0 → localcaption-0.3.0}/CHANGELOG.md +15 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/PKG-INFO +10 -6
- {localcaption-0.2.0 → localcaption-0.3.0}/README.md +8 -4
- localcaption-0.3.0/docs/diagrams/architecture.png +0 -0
- localcaption-0.3.0/docs/diagrams/pipeline.png +0 -0
- localcaption-0.3.0/docs/diagrams/sequence.png +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/pyproject.toml +1 -1
- {localcaption-0.2.0 → localcaption-0.3.0}/src/localcaption/cli.py +11 -8
- {localcaption-0.2.0 → localcaption-0.3.0}/src/localcaption/pipeline.py +12 -2
- localcaption-0.3.0/tests/test_cli_transcribe.py +66 -0
- localcaption-0.3.0/tests/test_pipeline.py +215 -0
- localcaption-0.2.0/docs/diagrams/architecture.png +0 -0
- localcaption-0.2.0/docs/diagrams/pipeline.png +0 -0
- localcaption-0.2.0/docs/diagrams/sequence.png +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/.github/FUNDING.yml +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/.github/pull_request_template.md +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/.github/workflows/ci.yml +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/.github/workflows/release.yml +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/.gitignore +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/CODE_OF_CONDUCT.md +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/CONTRIBUTING.md +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/LICENSE +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/SECURITY.md +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/docs/RELEASING.md +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/docs/diagrams/architecture.mmd +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/docs/diagrams/pipeline.mmd +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/docs/diagrams/sequence.mmd +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/scripts/install.sh +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/scripts/setup.sh +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/scripts/uninstall.sh +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/src/localcaption/__init__.py +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/src/localcaption/__main__.py +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/src/localcaption/_logging.py +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/src/localcaption/audio.py +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/src/localcaption/download.py +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/src/localcaption/errors.py +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/src/localcaption/installer.py +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/src/localcaption/models.py +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/src/localcaption/whisper.py +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/tests/__init__.py +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/tests/test_cli_doctor.py +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/tests/test_cli_model.py +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/tests/test_imports.py +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/tests/test_installer.py +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/tests/test_models.py +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/tests/test_models_download.py +0 -0
- {localcaption-0.2.0 → localcaption-0.3.0}/tests/test_whisper_paths.py +0 -0
|
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-08-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Local video and audio files.** `localcaption /path/to/video.mp4` (or a
|
|
14
|
+
wav / mp3 / mkv / similar) runs the same ffmpeg + whisper.cpp path
|
|
15
|
+
without calling yt-dlp. Output names come from the input filename.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- CLI help, `doctor`, and the README now say `<url-or-file>` instead of
|
|
19
|
+
treating this as URL-only.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- Architecture, pipeline, and sequence diagrams render with a white
|
|
23
|
+
background so they stay readable on GitHub dark mode.
|
|
24
|
+
|
|
10
25
|
## [0.2.0] - 2026-06-06
|
|
11
26
|
|
|
12
27
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: localcaption
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Fully-local video → transcript pipeline using yt-dlp, ffmpeg, and whisper.cpp. Supports YouTube, Vimeo, Twitch, and 1000+ sites. No API keys.
|
|
5
5
|
Project-URL: Homepage, https://github.com/jatinkrmalik/localcaption
|
|
6
6
|
Project-URL: Repository, https://github.com/jatinkrmalik/localcaption
|
|
@@ -219,6 +219,10 @@ localcaption "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
|
219
219
|
|
|
220
220
|
# Vimeo, Twitch, Twitter/X, and 1000+ other sites work too
|
|
221
221
|
localcaption "https://vimeo.com/148751763"
|
|
222
|
+
|
|
223
|
+
# Local video/audio files
|
|
224
|
+
localcaption /path/to/video.mp4
|
|
225
|
+
localcaption ./recording.wav
|
|
222
226
|
```
|
|
223
227
|
|
|
224
228
|
| flag | default | what it does |
|
|
@@ -236,15 +240,15 @@ localcaption "https://vimeo.com/148751763"
|
|
|
236
240
|
3. `./whisper.cpp` (dev checkout).
|
|
237
241
|
4. `~/.local/share/localcaption/whisper.cpp` (where `install.sh` puts it).
|
|
238
242
|
|
|
239
|
-
Outputs `<videoId>.txt`, `.srt`, `.vtt`, and `.json` in the chosen directory.
|
|
243
|
+
Outputs `<videoId>.txt`, `.srt`, `.vtt`, and `.json` in the chosen directory. For local files, the output filename is derived from the input file's name.
|
|
240
244
|
|
|
241
|
-
You can also invoke it as a module: `python -m localcaption <url>`.
|
|
245
|
+
You can also invoke it as a module: `python -m localcaption <url-or-file>`.
|
|
242
246
|
|
|
243
247
|
### Subcommands
|
|
244
248
|
|
|
245
249
|
| Subcommand | What it does |
|
|
246
250
|
|---|---|
|
|
247
|
-
| _(default)_ `localcaption <url>` | Transcribe a
|
|
251
|
+
| _(default)_ `localcaption <url-or-file>` | Transcribe a URL or local video/audio file. |
|
|
248
252
|
| `localcaption doctor` | Read-only diagnostic: prereqs, whisper.cpp, available models. Useful before filing a bug. |
|
|
249
253
|
| `localcaption doctor --fix` | Self-heal: install missing system deps, clone+build whisper.cpp, download the default model, then re-verify. Idempotent. |
|
|
250
254
|
| `localcaption model list` | List every supported whisper model with size + install status. |
|
|
@@ -339,7 +343,7 @@ the subprocess hops to yt-dlp, ffmpeg, and whisper.cpp. The intermediate
|
|
|
339
343
|
> files alongside the rendered PNGs. Regenerate with:
|
|
340
344
|
> ```bash
|
|
341
345
|
> mmdc -i docs/diagrams/<name>.mmd -o docs/diagrams/<name>.png \
|
|
342
|
-
> -t default -b
|
|
346
|
+
> -t default -b white --width 1600 --scale 2
|
|
343
347
|
> ```
|
|
344
348
|
|
|
345
349
|
## Benchmarks
|
|
@@ -164,6 +164,10 @@ localcaption "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
|
164
164
|
|
|
165
165
|
# Vimeo, Twitch, Twitter/X, and 1000+ other sites work too
|
|
166
166
|
localcaption "https://vimeo.com/148751763"
|
|
167
|
+
|
|
168
|
+
# Local video/audio files
|
|
169
|
+
localcaption /path/to/video.mp4
|
|
170
|
+
localcaption ./recording.wav
|
|
167
171
|
```
|
|
168
172
|
|
|
169
173
|
| flag | default | what it does |
|
|
@@ -181,15 +185,15 @@ localcaption "https://vimeo.com/148751763"
|
|
|
181
185
|
3. `./whisper.cpp` (dev checkout).
|
|
182
186
|
4. `~/.local/share/localcaption/whisper.cpp` (where `install.sh` puts it).
|
|
183
187
|
|
|
184
|
-
Outputs `<videoId>.txt`, `.srt`, `.vtt`, and `.json` in the chosen directory.
|
|
188
|
+
Outputs `<videoId>.txt`, `.srt`, `.vtt`, and `.json` in the chosen directory. For local files, the output filename is derived from the input file's name.
|
|
185
189
|
|
|
186
|
-
You can also invoke it as a module: `python -m localcaption <url>`.
|
|
190
|
+
You can also invoke it as a module: `python -m localcaption <url-or-file>`.
|
|
187
191
|
|
|
188
192
|
### Subcommands
|
|
189
193
|
|
|
190
194
|
| Subcommand | What it does |
|
|
191
195
|
|---|---|
|
|
192
|
-
| _(default)_ `localcaption <url>` | Transcribe a
|
|
196
|
+
| _(default)_ `localcaption <url-or-file>` | Transcribe a URL or local video/audio file. |
|
|
193
197
|
| `localcaption doctor` | Read-only diagnostic: prereqs, whisper.cpp, available models. Useful before filing a bug. |
|
|
194
198
|
| `localcaption doctor --fix` | Self-heal: install missing system deps, clone+build whisper.cpp, download the default model, then re-verify. Idempotent. |
|
|
195
199
|
| `localcaption model list` | List every supported whisper model with size + install status. |
|
|
@@ -284,7 +288,7 @@ the subprocess hops to yt-dlp, ffmpeg, and whisper.cpp. The intermediate
|
|
|
284
288
|
> files alongside the rendered PNGs. Regenerate with:
|
|
285
289
|
> ```bash
|
|
286
290
|
> mmdc -i docs/diagrams/<name>.mmd -o docs/diagrams/<name>.png \
|
|
287
|
-
> -t default -b
|
|
291
|
+
> -t default -b white --width 1600 --scale 2
|
|
288
292
|
> ```
|
|
289
293
|
|
|
290
294
|
## Benchmarks
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "localcaption"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Fully-local video → transcript pipeline using yt-dlp, ffmpeg, and whisper.cpp. Supports YouTube, Vimeo, Twitch, and 1000+ sites. No API keys."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -4,8 +4,8 @@ Exposed as the ``localcaption`` console script via ``pyproject.toml``.
|
|
|
4
4
|
|
|
5
5
|
Two invocation styles are supported:
|
|
6
6
|
|
|
7
|
-
localcaption <url> [options]
|
|
8
|
-
localcaption doctor
|
|
7
|
+
localcaption <url-or-file> [options] # one-shot transcription (default)
|
|
8
|
+
localcaption doctor # diagnose your install
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
@@ -69,9 +69,12 @@ def _default_whisper_dir() -> Path:
|
|
|
69
69
|
def _build_transcribe_parser() -> argparse.ArgumentParser:
|
|
70
70
|
parser = argparse.ArgumentParser(
|
|
71
71
|
prog="localcaption",
|
|
72
|
-
description="Fully-local
|
|
72
|
+
description="Fully-local video → transcript using yt-dlp + ffmpeg + whisper.cpp.",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"url",
|
|
76
|
+
help="YouTube URL, any URL yt-dlp supports, or path to a local video/audio file",
|
|
73
77
|
)
|
|
74
|
-
parser.add_argument("url", help="YouTube URL (or any URL yt-dlp supports)")
|
|
75
78
|
parser.add_argument(
|
|
76
79
|
"-m", "--model", default=DEFAULT_MODEL,
|
|
77
80
|
help=f"whisper model name (default: {DEFAULT_MODEL})",
|
|
@@ -400,7 +403,7 @@ def _cmd_doctor(argv: list[str]) -> int:
|
|
|
400
403
|
all_ok, fix_hints, gaps = _run_doctor_diagnostics(whisper_dir)
|
|
401
404
|
|
|
402
405
|
if all_ok:
|
|
403
|
-
print("\nAll checks passed. You're good to go: localcaption <url>")
|
|
406
|
+
print("\nAll checks passed. You're good to go: localcaption <url-or-file>")
|
|
404
407
|
return 0
|
|
405
408
|
|
|
406
409
|
if not args.fix:
|
|
@@ -422,7 +425,7 @@ def _cmd_doctor(argv: list[str]) -> int:
|
|
|
422
425
|
print("Re-running diagnostics to verify…\n")
|
|
423
426
|
all_ok_after, _, _ = _run_doctor_diagnostics(whisper_dir)
|
|
424
427
|
if all_ok_after:
|
|
425
|
-
print("\nAll checks passed. You're good to go: localcaption <url>")
|
|
428
|
+
print("\nAll checks passed. You're good to go: localcaption <url-or-file>")
|
|
426
429
|
return 0
|
|
427
430
|
print("\nSome checks still failing — see output above.")
|
|
428
431
|
return 1
|
|
@@ -579,7 +582,7 @@ def _cmd_model_download(argv: list[str]) -> int:
|
|
|
579
582
|
return 130
|
|
580
583
|
|
|
581
584
|
print(f"✅ Done. {path}")
|
|
582
|
-
print(f" Use it with: localcaption --model {spec.name} <url>")
|
|
585
|
+
print(f" Use it with: localcaption --model {spec.name} <url-or-file>")
|
|
583
586
|
return 0
|
|
584
587
|
|
|
585
588
|
|
|
@@ -628,7 +631,7 @@ def _cmd_model_rm(argv: list[str]) -> int:
|
|
|
628
631
|
|
|
629
632
|
def _print_top_level_help() -> None:
|
|
630
633
|
print("""\
|
|
631
|
-
usage: localcaption <url> [options]
|
|
634
|
+
usage: localcaption <url-or-file> [options] transcribe a video (default)
|
|
632
635
|
localcaption doctor diagnose your install
|
|
633
636
|
localcaption model <subcommand> list / download / remove models
|
|
634
637
|
localcaption --help show transcribe help
|
|
@@ -25,6 +25,12 @@ class PipelineResult:
|
|
|
25
25
|
transcripts: TranscriptionResult
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
def _is_local_file(source: str) -> bool:
|
|
29
|
+
if "://" in source:
|
|
30
|
+
return False
|
|
31
|
+
return Path(source).is_file()
|
|
32
|
+
|
|
33
|
+
|
|
28
34
|
def transcribe_url(
|
|
29
35
|
url: str,
|
|
30
36
|
*,
|
|
@@ -36,10 +42,12 @@ def transcribe_url(
|
|
|
36
42
|
) -> PipelineResult:
|
|
37
43
|
"""Run the full pipeline on *url* and return the produced artefacts.
|
|
38
44
|
|
|
45
|
+
*url* may be an actual URL or a path to a local video/audio file.
|
|
46
|
+
|
|
39
47
|
Parameters
|
|
40
48
|
----------
|
|
41
49
|
url:
|
|
42
|
-
Any URL `yt-dlp` can resolve.
|
|
50
|
+
Any URL `yt-dlp` can resolve, or a local file path.
|
|
43
51
|
out_dir:
|
|
44
52
|
Directory for the final transcript files.
|
|
45
53
|
whisper_dir:
|
|
@@ -59,7 +67,9 @@ def transcribe_url(
|
|
|
59
67
|
audio_path: Path | None = None
|
|
60
68
|
wav_path: Path | None = None
|
|
61
69
|
try:
|
|
62
|
-
audio_path =
|
|
70
|
+
audio_path = (
|
|
71
|
+
Path(url).resolve() if _is_local_file(url) else download_audio(url, work_dir)
|
|
72
|
+
)
|
|
63
73
|
wav_path = work_dir / f"{audio_path.stem}.16k.wav"
|
|
64
74
|
to_whisper_wav(audio_path, wav_path)
|
|
65
75
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Tests for the default transcribe subcommand and dispatcher."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from localcaption.cli import main
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestCliHelpText:
|
|
13
|
+
def test_transcribe_help_mentions_local_file(self, capsys) -> None:
|
|
14
|
+
with pytest.raises(SystemExit) as excinfo:
|
|
15
|
+
main(["--help"])
|
|
16
|
+
assert excinfo.value.code == 0
|
|
17
|
+
out = capsys.readouterr().out
|
|
18
|
+
assert "local video/audio file" in out
|
|
19
|
+
|
|
20
|
+
def test_top_level_help_mentions_url_or_file(self, capsys) -> None:
|
|
21
|
+
rc = main([])
|
|
22
|
+
assert rc == 2
|
|
23
|
+
out = capsys.readouterr().out
|
|
24
|
+
assert "url-or-file" in out
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestCliLocalFileDispatch:
|
|
28
|
+
def test_local_file_path_passed_to_pipeline(self, monkeypatch, tmp_path: Path) -> None:
|
|
29
|
+
video = tmp_path / "my_video.mp4"
|
|
30
|
+
video.write_text("fake")
|
|
31
|
+
sentinel: dict[str, str] = {}
|
|
32
|
+
|
|
33
|
+
def fake_transcribe_url(url, **kw):
|
|
34
|
+
sentinel["url"] = url
|
|
35
|
+
raise SystemExit(0)
|
|
36
|
+
|
|
37
|
+
monkeypatch.setattr("localcaption.cli.transcribe_url", fake_transcribe_url)
|
|
38
|
+
with pytest.raises(SystemExit):
|
|
39
|
+
main([str(video)])
|
|
40
|
+
assert sentinel["url"] == str(video)
|
|
41
|
+
|
|
42
|
+
def test_url_still_passed_to_pipeline(self, monkeypatch) -> None:
|
|
43
|
+
sentinel: dict[str, str] = {}
|
|
44
|
+
|
|
45
|
+
def fake_transcribe_url(url, **kw):
|
|
46
|
+
sentinel["url"] = url
|
|
47
|
+
raise SystemExit(0)
|
|
48
|
+
|
|
49
|
+
monkeypatch.setattr("localcaption.cli.transcribe_url", fake_transcribe_url)
|
|
50
|
+
with pytest.raises(SystemExit):
|
|
51
|
+
main(["https://www.youtube.com/watch?v=dQw4w9WgXcQ"])
|
|
52
|
+
assert sentinel["url"] == "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
53
|
+
|
|
54
|
+
def test_relative_local_file_passed_to_pipeline(self, monkeypatch, tmp_path: Path) -> None:
|
|
55
|
+
video = tmp_path / "relative.mp4"
|
|
56
|
+
video.write_text("fake")
|
|
57
|
+
sentinel: dict[str, str] = {}
|
|
58
|
+
|
|
59
|
+
def fake_transcribe_url(url, **kw):
|
|
60
|
+
sentinel["url"] = url
|
|
61
|
+
raise SystemExit(0)
|
|
62
|
+
|
|
63
|
+
monkeypatch.setattr("localcaption.cli.transcribe_url", fake_transcribe_url)
|
|
64
|
+
with pytest.raises(SystemExit):
|
|
65
|
+
main(["./relative.mp4"])
|
|
66
|
+
assert sentinel["url"] == "./relative.mp4"
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Tests for the pipeline orchestration layer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from unittest.mock import MagicMock
|
|
7
|
+
|
|
8
|
+
from localcaption.pipeline import PipelineResult, _is_local_file, transcribe_url
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestIsLocalFile:
|
|
12
|
+
def test_existing_file_returns_true(self, tmp_path: Path) -> None:
|
|
13
|
+
video = tmp_path / "video.mp4"
|
|
14
|
+
video.write_text("fake")
|
|
15
|
+
assert _is_local_file(str(video)) is True
|
|
16
|
+
|
|
17
|
+
def test_relative_existing_file_returns_true(self, tmp_path: Path) -> None:
|
|
18
|
+
video = tmp_path / "video.mp4"
|
|
19
|
+
video.write_text("fake")
|
|
20
|
+
import os
|
|
21
|
+
old_cwd = os.getcwd()
|
|
22
|
+
try:
|
|
23
|
+
os.chdir(tmp_path)
|
|
24
|
+
assert _is_local_file("./video.mp4") is True
|
|
25
|
+
assert _is_local_file("video.mp4") is True
|
|
26
|
+
finally:
|
|
27
|
+
os.chdir(old_cwd)
|
|
28
|
+
|
|
29
|
+
def test_url_returns_false(self) -> None:
|
|
30
|
+
assert _is_local_file("https://www.youtube.com/watch?v=dQw4w9WgXcQ") is False
|
|
31
|
+
assert _is_local_file("http://example.com/video.mp4") is False
|
|
32
|
+
|
|
33
|
+
def test_nonexistent_path_returns_false(self, tmp_path: Path) -> None:
|
|
34
|
+
assert _is_local_file(str(tmp_path / "does_not_exist.mp4")) is False
|
|
35
|
+
|
|
36
|
+
def test_file_url_returns_false(self, tmp_path: Path) -> None:
|
|
37
|
+
assert _is_local_file(f"file://{tmp_path}/video.mp4") is False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestTranscribeUrlLocalFile:
|
|
41
|
+
def test_local_file_skips_download(
|
|
42
|
+
self, monkeypatch, tmp_path: Path
|
|
43
|
+
) -> None:
|
|
44
|
+
video = tmp_path / "my_video.mp4"
|
|
45
|
+
video.write_text("fake video")
|
|
46
|
+
out_dir = tmp_path / "out"
|
|
47
|
+
whisper_dir = tmp_path / "whisper.cpp"
|
|
48
|
+
|
|
49
|
+
download_called = False
|
|
50
|
+
|
|
51
|
+
def fake_download(url, work_dir):
|
|
52
|
+
nonlocal download_called
|
|
53
|
+
download_called = True
|
|
54
|
+
return work_dir / "downloaded.mp4"
|
|
55
|
+
|
|
56
|
+
def fake_to_whisper_wav(src, dst):
|
|
57
|
+
dst.write_text("fake wav")
|
|
58
|
+
return dst
|
|
59
|
+
|
|
60
|
+
fake_transcripts = MagicMock()
|
|
61
|
+
fake_transcripts.existing.return_value = {}
|
|
62
|
+
|
|
63
|
+
def fake_transcribe(wav, model, out_base, *, whisper_dir, language):
|
|
64
|
+
return fake_transcripts
|
|
65
|
+
|
|
66
|
+
monkeypatch.setattr("localcaption.pipeline.download_audio", fake_download)
|
|
67
|
+
monkeypatch.setattr("localcaption.pipeline.to_whisper_wav", fake_to_whisper_wav)
|
|
68
|
+
monkeypatch.setattr("localcaption.pipeline.transcribe", fake_transcribe)
|
|
69
|
+
|
|
70
|
+
result = transcribe_url(
|
|
71
|
+
str(video),
|
|
72
|
+
out_dir=out_dir,
|
|
73
|
+
whisper_dir=whisper_dir,
|
|
74
|
+
model="base.en",
|
|
75
|
+
keep_intermediate=True,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
assert download_called is False
|
|
79
|
+
assert isinstance(result, PipelineResult)
|
|
80
|
+
assert result.source_url == str(video)
|
|
81
|
+
assert result.audio_path == video.resolve()
|
|
82
|
+
|
|
83
|
+
def test_local_file_uses_correct_stem_for_output(
|
|
84
|
+
self, monkeypatch, tmp_path: Path
|
|
85
|
+
) -> None:
|
|
86
|
+
video = tmp_path / "interview.mkv"
|
|
87
|
+
video.write_text("fake video")
|
|
88
|
+
out_dir = tmp_path / "out"
|
|
89
|
+
whisper_dir = tmp_path / "whisper.cpp"
|
|
90
|
+
|
|
91
|
+
captured = {}
|
|
92
|
+
|
|
93
|
+
def fake_to_whisper_wav(src, dst):
|
|
94
|
+
dst.write_text("fake wav")
|
|
95
|
+
return dst
|
|
96
|
+
|
|
97
|
+
def fake_transcribe(wav, model, out_base, *, whisper_dir, language):
|
|
98
|
+
captured["out_base"] = out_base
|
|
99
|
+
fake_transcripts = MagicMock()
|
|
100
|
+
fake_transcripts.existing.return_value = {}
|
|
101
|
+
return fake_transcripts
|
|
102
|
+
|
|
103
|
+
monkeypatch.setattr("localcaption.pipeline.to_whisper_wav", fake_to_whisper_wav)
|
|
104
|
+
monkeypatch.setattr("localcaption.pipeline.transcribe", fake_transcribe)
|
|
105
|
+
|
|
106
|
+
transcribe_url(
|
|
107
|
+
str(video),
|
|
108
|
+
out_dir=out_dir,
|
|
109
|
+
whisper_dir=whisper_dir,
|
|
110
|
+
model="base.en",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
assert captured["out_base"] == out_dir / "interview"
|
|
114
|
+
|
|
115
|
+
def test_url_still_calls_download(
|
|
116
|
+
self, monkeypatch, tmp_path: Path
|
|
117
|
+
) -> None:
|
|
118
|
+
out_dir = tmp_path / "out"
|
|
119
|
+
whisper_dir = tmp_path / "whisper.cpp"
|
|
120
|
+
work_dir = out_dir / ".work"
|
|
121
|
+
work_dir.mkdir(parents=True)
|
|
122
|
+
|
|
123
|
+
def fake_download(url, work_dir):
|
|
124
|
+
downloaded = work_dir / "yt_video.m4a"
|
|
125
|
+
downloaded.write_text("fake audio")
|
|
126
|
+
return downloaded
|
|
127
|
+
|
|
128
|
+
def fake_to_whisper_wav(src, dst):
|
|
129
|
+
dst.write_text("fake wav")
|
|
130
|
+
return dst
|
|
131
|
+
|
|
132
|
+
fake_transcripts = MagicMock()
|
|
133
|
+
fake_transcripts.existing.return_value = {}
|
|
134
|
+
|
|
135
|
+
def fake_transcribe(wav, model, out_base, *, whisper_dir, language):
|
|
136
|
+
return fake_transcripts
|
|
137
|
+
|
|
138
|
+
monkeypatch.setattr("localcaption.pipeline.download_audio", fake_download)
|
|
139
|
+
monkeypatch.setattr("localcaption.pipeline.to_whisper_wav", fake_to_whisper_wav)
|
|
140
|
+
monkeypatch.setattr("localcaption.pipeline.transcribe", fake_transcribe)
|
|
141
|
+
|
|
142
|
+
result = transcribe_url(
|
|
143
|
+
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
144
|
+
out_dir=out_dir,
|
|
145
|
+
whisper_dir=whisper_dir,
|
|
146
|
+
model="base.en",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
assert isinstance(result, PipelineResult)
|
|
150
|
+
assert result.source_url == "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
151
|
+
|
|
152
|
+
def test_keep_intermediate_preserves_local_audio_path(
|
|
153
|
+
self, monkeypatch, tmp_path: Path
|
|
154
|
+
) -> None:
|
|
155
|
+
video = tmp_path / "podcast.mp3"
|
|
156
|
+
video.write_text("fake audio")
|
|
157
|
+
out_dir = tmp_path / "out"
|
|
158
|
+
whisper_dir = tmp_path / "whisper.cpp"
|
|
159
|
+
|
|
160
|
+
def fake_to_whisper_wav(src, dst):
|
|
161
|
+
dst.write_text("fake wav")
|
|
162
|
+
return dst
|
|
163
|
+
|
|
164
|
+
fake_transcripts = MagicMock()
|
|
165
|
+
fake_transcripts.existing.return_value = {}
|
|
166
|
+
|
|
167
|
+
def fake_transcribe(wav, model, out_base, *, whisper_dir, language):
|
|
168
|
+
return fake_transcripts
|
|
169
|
+
|
|
170
|
+
monkeypatch.setattr("localcaption.pipeline.to_whisper_wav", fake_to_whisper_wav)
|
|
171
|
+
monkeypatch.setattr("localcaption.pipeline.transcribe", fake_transcribe)
|
|
172
|
+
|
|
173
|
+
result = transcribe_url(
|
|
174
|
+
str(video),
|
|
175
|
+
out_dir=out_dir,
|
|
176
|
+
whisper_dir=whisper_dir,
|
|
177
|
+
model="base.en",
|
|
178
|
+
keep_intermediate=True,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
assert result.audio_path == video.resolve()
|
|
182
|
+
assert result.wav_path is not None
|
|
183
|
+
assert result.wav_path.name == "podcast.16k.wav"
|
|
184
|
+
|
|
185
|
+
def test_no_keep_intermediate_clears_paths(
|
|
186
|
+
self, monkeypatch, tmp_path: Path
|
|
187
|
+
) -> None:
|
|
188
|
+
video = tmp_path / "podcast.mp3"
|
|
189
|
+
video.write_text("fake audio")
|
|
190
|
+
out_dir = tmp_path / "out"
|
|
191
|
+
whisper_dir = tmp_path / "whisper.cpp"
|
|
192
|
+
|
|
193
|
+
def fake_to_whisper_wav(src, dst):
|
|
194
|
+
dst.write_text("fake wav")
|
|
195
|
+
return dst
|
|
196
|
+
|
|
197
|
+
fake_transcripts = MagicMock()
|
|
198
|
+
fake_transcripts.existing.return_value = {}
|
|
199
|
+
|
|
200
|
+
def fake_transcribe(wav, model, out_base, *, whisper_dir, language):
|
|
201
|
+
return fake_transcripts
|
|
202
|
+
|
|
203
|
+
monkeypatch.setattr("localcaption.pipeline.to_whisper_wav", fake_to_whisper_wav)
|
|
204
|
+
monkeypatch.setattr("localcaption.pipeline.transcribe", fake_transcribe)
|
|
205
|
+
|
|
206
|
+
result = transcribe_url(
|
|
207
|
+
str(video),
|
|
208
|
+
out_dir=out_dir,
|
|
209
|
+
whisper_dir=whisper_dir,
|
|
210
|
+
model="base.en",
|
|
211
|
+
keep_intermediate=False,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
assert result.audio_path is None
|
|
215
|
+
assert result.wav_path is None
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|