lectural 0.1.2__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 (47) hide show
  1. lectural-0.1.2/LICENSE +21 -0
  2. lectural-0.1.2/PKG-INFO +131 -0
  3. lectural-0.1.2/README.md +107 -0
  4. lectural-0.1.2/lectural/__init__.py +24 -0
  5. lectural-0.1.2/lectural/acquisition.py +260 -0
  6. lectural-0.1.2/lectural/cli.py +286 -0
  7. lectural-0.1.2/lectural/config.py +63 -0
  8. lectural-0.1.2/lectural/coverage.py +240 -0
  9. lectural-0.1.2/lectural/deps.py +275 -0
  10. lectural-0.1.2/lectural/doctor.py +500 -0
  11. lectural-0.1.2/lectural/notes_contract.py +174 -0
  12. lectural-0.1.2/lectural/ocr.py +354 -0
  13. lectural-0.1.2/lectural/runstate.py +98 -0
  14. lectural-0.1.2/lectural/speech.py +59 -0
  15. lectural-0.1.2/lectural/synthesis.py +258 -0
  16. lectural-0.1.2/lectural/vad.py +160 -0
  17. lectural-0.1.2/lectural/visual.py +406 -0
  18. lectural-0.1.2/lectural.egg-info/PKG-INFO +131 -0
  19. lectural-0.1.2/lectural.egg-info/SOURCES.txt +45 -0
  20. lectural-0.1.2/lectural.egg-info/dependency_links.txt +1 -0
  21. lectural-0.1.2/lectural.egg-info/entry_points.txt +2 -0
  22. lectural-0.1.2/lectural.egg-info/requires.txt +17 -0
  23. lectural-0.1.2/lectural.egg-info/top_level.txt +1 -0
  24. lectural-0.1.2/pyproject.toml +51 -0
  25. lectural-0.1.2/setup.cfg +4 -0
  26. lectural-0.1.2/tests/test_acquisition.py +111 -0
  27. lectural-0.1.2/tests/test_changelog_notes.py +89 -0
  28. lectural-0.1.2/tests/test_cli.py +280 -0
  29. lectural-0.1.2/tests/test_coverage.py +298 -0
  30. lectural-0.1.2/tests/test_dedup.py +176 -0
  31. lectural-0.1.2/tests/test_deps.py +211 -0
  32. lectural-0.1.2/tests/test_doctor.py +394 -0
  33. lectural-0.1.2/tests/test_hook.py +452 -0
  34. lectural-0.1.2/tests/test_notes.py +223 -0
  35. lectural-0.1.2/tests/test_ocr.py +250 -0
  36. lectural-0.1.2/tests/test_perf_smoke.py +63 -0
  37. lectural-0.1.2/tests/test_redteam_cli_hook.py +352 -0
  38. lectural-0.1.2/tests/test_redteam_core.py +168 -0
  39. lectural-0.1.2/tests/test_redteam_notes.py +473 -0
  40. lectural-0.1.2/tests/test_redteam_notes_contract.py +546 -0
  41. lectural-0.1.2/tests/test_redteam_packaging.py +179 -0
  42. lectural-0.1.2/tests/test_redteam_readme.py +56 -0
  43. lectural-0.1.2/tests/test_redteam_synthesis.py +225 -0
  44. lectural-0.1.2/tests/test_ssim.py +42 -0
  45. lectural-0.1.2/tests/test_summary_anchors.py +51 -0
  46. lectural-0.1.2/tests/test_vad.py +62 -0
  47. lectural-0.1.2/tests/test_version_changelog.py +87 -0
lectural-0.1.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Haesol Shin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: lectural
3
+ Version: 0.1.2
4
+ Summary: Turn a YouTube lecture URL into complete study notes: every utterance, every on-screen text, every scene.
5
+ Requires-Python: <3.13,>=3.10
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Provides-Extra: run
9
+ Requires-Dist: yt-dlp>=2024.1.0; extra == "run"
10
+ Requires-Dist: youtube-transcript-api>=0.6.2; extra == "run"
11
+ Requires-Dist: faster-whisper>=1.0.0; extra == "run"
12
+ Requires-Dist: opencv-python<=4.6.0.66,>=4.5; extra == "run"
13
+ Requires-Dist: numpy<2,>=1.24; extra == "run"
14
+ Requires-Dist: paddleocr<3,>=2.7; extra == "run"
15
+ Requires-Dist: paddlepaddle<3,>=2.6; extra == "run"
16
+ Requires-Dist: pytesseract>=0.3.10; extra == "run"
17
+ Requires-Dist: Pillow>=10.0.0; extra == "run"
18
+ Requires-Dist: webrtcvad>=2.0.10; extra == "run"
19
+ Requires-Dist: setuptools<81,>=68; extra == "run"
20
+ Provides-Extra: test
21
+ Requires-Dist: pytest>=7.0; extra == "test"
22
+ Requires-Dist: numpy>=1.24; extra == "test"
23
+ Dynamic: license-file
24
+
25
+ # LecturAL
26
+
27
+ > A Claude Code plugin that turns one YouTube video into complete markdown notes — **every utterance, every on-screen text, every scene**. Best on lecture and slide-style videos.
28
+
29
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/)
30
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
31
+
32
+ ## Features
33
+
34
+ - 🧾 **Full transcript + study notes** — a raw `transcript.md` (every utterance) and a seven-section `notes.md` (3줄 요약 / 목차 / 흐름 / 핵심 개념·이론 / 정리 노트 / 복습 질문 / 정리 커버리지). Note prose is Korean by design.
35
+ - 🔗 **Video deeplinks** — concept bullets and review-question answers carry `youtu.be?t=` links that jump to the exact moment.
36
+ - 🇰🇷 **Korean & English** — uses captions when available, falls back to speech-to-text (faster-whisper) otherwise.
37
+ - 🚧 **Completeness gate** — checks speech gaps, scene coverage, and artifact presence, and blocks "done" until they pass.
38
+
39
+ ## How it works
40
+
41
+ ```mermaid
42
+ flowchart TD
43
+ A[YouTube URL] --> B{Captions available?}
44
+ B -->|yes| C[Acquire: captions]
45
+ B -->|no / --force-stt| D[Download audio -> STT faster-whisper]
46
+ C --> E[Visual: ffmpeg keyframes / scene cuts]
47
+ D --> E
48
+ E --> F[Dedup: histogram / SSIM]
49
+ F --> G[OCR: PaddleOCR -> Tesseract fallback]
50
+ G --> H[Synthesize: transcript.md / notes.md / frames/ / coverage.json]
51
+ H --> I{Completeness gate}
52
+ I -->|pass| J[Done]
53
+ I -->|fail · exit 2| K[Fix the gap, then retry]
54
+ K --> H
55
+ ```
56
+
57
+ ## Requirements
58
+
59
+ - **Python 3.10+**
60
+ - **uv** — installs and runs the Python dependencies
61
+ - **ffmpeg** — system binary, must be on PATH
62
+ - **yt-dlp** — checked and installed by `/lectural:setup` (doctor)
63
+
64
+ ## Install
65
+
66
+ ### 1. Install the plugin (Claude Code)
67
+
68
+ ```text
69
+ /plugin marketplace add haesol-shin/lectural
70
+ /plugin install lectural@lectural
71
+ ```
72
+
73
+ This registers the completeness Stop hook and the `/lectural:notes` and `/lectural:setup` commands.
74
+
75
+ ### 2. Prepare the runtime
76
+
77
+ Run once after installing:
78
+
79
+ ```text
80
+ /lectural:setup
81
+ ```
82
+
83
+ It installs the Python run dependencies → checks/repairs `ffmpeg` and `yt-dlp` → reports anything left to do.
84
+
85
+ > Manual setup: `uv pip install -e ".[run]"`, then install `ffmpeg` per OS (Windows `winget install --id Gyan.FFmpeg -e`, Linux `sudo apt-get install ffmpeg`, macOS `brew install ffmpeg`).
86
+
87
+ ## Quick start
88
+
89
+ ```text
90
+ /lectural:notes https://youtu.be/<VIDEO_ID>
91
+ ```
92
+
93
+ Or run the CLI directly without Claude Code (ffmpeg must be installed separately):
94
+
95
+ ```bash
96
+ uvx --from ".[run]" lectural "https://youtu.be/<VIDEO_ID>" --out ./output
97
+ ```
98
+
99
+ ## Usage
100
+
101
+ | Command | Description |
102
+ |---------|-------------|
103
+ | `/lectural:setup` | Prepare and verify the runtime (first run) |
104
+ | `/lectural:notes <url> [options]` | Turn a lecture URL into complete notes |
105
+
106
+ Options: `--force-stt` (ignore captions, force STT), `--model medium|small` (STT model size), `--out ./output` (output location). Pass multiple URLs to process them sequentially.
107
+
108
+ The commands run **only on explicit request** (they do not auto-trigger on a stray YouTube link). At session end, the Stop hook re-verifies note completeness.
109
+
110
+ ## Output
111
+
112
+ ```text
113
+ output/<video-title>/
114
+ ├── transcript.md # raw timestamped transcript — every utterance
115
+ ├── notes.md # study notes: seven sections + video deeplinks
116
+ ├── frames/ # slide images
117
+ ├── coverage.json # completeness-gate results
118
+ └── synthesis_input.json # text input used to enrich the notes
119
+ ```
120
+
121
+ ## FAQ
122
+
123
+ **No captions?** STT transcribes audio when captions are missing or weak (`--force-stt` forces it).
124
+
125
+ **Long video (1–2h)?** CPU STT gets slower with length; LecturAL warns on very long inputs, and `--model small` trades accuracy for speed.
126
+
127
+ **Empty OCR text on some frames?** Expected. Many frames (e.g. the speaker only) have no text, so OCR miss rate is not used as a gate.
128
+
129
+ ## License
130
+
131
+ [MIT](LICENSE).
@@ -0,0 +1,107 @@
1
+ # LecturAL
2
+
3
+ > A Claude Code plugin that turns one YouTube video into complete markdown notes — **every utterance, every on-screen text, every scene**. Best on lecture and slide-style videos.
4
+
5
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7
+
8
+ ## Features
9
+
10
+ - 🧾 **Full transcript + study notes** — a raw `transcript.md` (every utterance) and a seven-section `notes.md` (3줄 요약 / 목차 / 흐름 / 핵심 개념·이론 / 정리 노트 / 복습 질문 / 정리 커버리지). Note prose is Korean by design.
11
+ - 🔗 **Video deeplinks** — concept bullets and review-question answers carry `youtu.be?t=` links that jump to the exact moment.
12
+ - 🇰🇷 **Korean & English** — uses captions when available, falls back to speech-to-text (faster-whisper) otherwise.
13
+ - 🚧 **Completeness gate** — checks speech gaps, scene coverage, and artifact presence, and blocks "done" until they pass.
14
+
15
+ ## How it works
16
+
17
+ ```mermaid
18
+ flowchart TD
19
+ A[YouTube URL] --> B{Captions available?}
20
+ B -->|yes| C[Acquire: captions]
21
+ B -->|no / --force-stt| D[Download audio -> STT faster-whisper]
22
+ C --> E[Visual: ffmpeg keyframes / scene cuts]
23
+ D --> E
24
+ E --> F[Dedup: histogram / SSIM]
25
+ F --> G[OCR: PaddleOCR -> Tesseract fallback]
26
+ G --> H[Synthesize: transcript.md / notes.md / frames/ / coverage.json]
27
+ H --> I{Completeness gate}
28
+ I -->|pass| J[Done]
29
+ I -->|fail · exit 2| K[Fix the gap, then retry]
30
+ K --> H
31
+ ```
32
+
33
+ ## Requirements
34
+
35
+ - **Python 3.10+**
36
+ - **uv** — installs and runs the Python dependencies
37
+ - **ffmpeg** — system binary, must be on PATH
38
+ - **yt-dlp** — checked and installed by `/lectural:setup` (doctor)
39
+
40
+ ## Install
41
+
42
+ ### 1. Install the plugin (Claude Code)
43
+
44
+ ```text
45
+ /plugin marketplace add haesol-shin/lectural
46
+ /plugin install lectural@lectural
47
+ ```
48
+
49
+ This registers the completeness Stop hook and the `/lectural:notes` and `/lectural:setup` commands.
50
+
51
+ ### 2. Prepare the runtime
52
+
53
+ Run once after installing:
54
+
55
+ ```text
56
+ /lectural:setup
57
+ ```
58
+
59
+ It installs the Python run dependencies → checks/repairs `ffmpeg` and `yt-dlp` → reports anything left to do.
60
+
61
+ > Manual setup: `uv pip install -e ".[run]"`, then install `ffmpeg` per OS (Windows `winget install --id Gyan.FFmpeg -e`, Linux `sudo apt-get install ffmpeg`, macOS `brew install ffmpeg`).
62
+
63
+ ## Quick start
64
+
65
+ ```text
66
+ /lectural:notes https://youtu.be/<VIDEO_ID>
67
+ ```
68
+
69
+ Or run the CLI directly without Claude Code (ffmpeg must be installed separately):
70
+
71
+ ```bash
72
+ uvx --from ".[run]" lectural "https://youtu.be/<VIDEO_ID>" --out ./output
73
+ ```
74
+
75
+ ## Usage
76
+
77
+ | Command | Description |
78
+ |---------|-------------|
79
+ | `/lectural:setup` | Prepare and verify the runtime (first run) |
80
+ | `/lectural:notes <url> [options]` | Turn a lecture URL into complete notes |
81
+
82
+ Options: `--force-stt` (ignore captions, force STT), `--model medium|small` (STT model size), `--out ./output` (output location). Pass multiple URLs to process them sequentially.
83
+
84
+ The commands run **only on explicit request** (they do not auto-trigger on a stray YouTube link). At session end, the Stop hook re-verifies note completeness.
85
+
86
+ ## Output
87
+
88
+ ```text
89
+ output/<video-title>/
90
+ ├── transcript.md # raw timestamped transcript — every utterance
91
+ ├── notes.md # study notes: seven sections + video deeplinks
92
+ ├── frames/ # slide images
93
+ ├── coverage.json # completeness-gate results
94
+ └── synthesis_input.json # text input used to enrich the notes
95
+ ```
96
+
97
+ ## FAQ
98
+
99
+ **No captions?** STT transcribes audio when captions are missing or weak (`--force-stt` forces it).
100
+
101
+ **Long video (1–2h)?** CPU STT gets slower with length; LecturAL warns on very long inputs, and `--model small` trades accuracy for speed.
102
+
103
+ **Empty OCR text on some frames?** Expected. Many frames (e.g. the speaker only) have no text, so OCR miss rate is not used as a gate.
104
+
105
+ ## License
106
+
107
+ [MIT](LICENSE).
@@ -0,0 +1,24 @@
1
+ """LecturAL: complete study-note extraction from YouTube lectures.
2
+
3
+ The package is import-safe without heavy runtime dependencies (faster-whisper,
4
+ opencv, paddleocr, yt-dlp). Those are imported lazily inside the functions that
5
+ need them, so deterministic logic can be unit-tested offline.
6
+ """
7
+
8
+ from .config import (
9
+ DEDUP_HIST_THRESHOLD,
10
+ DEDUP_SSIM_THRESHOLD,
11
+ MAX_GAP_SEC,
12
+ SCENE_BINS_N,
13
+ SCHEMA_VERSION,
14
+ )
15
+
16
+ __all__ = [
17
+ "DEDUP_HIST_THRESHOLD",
18
+ "DEDUP_SSIM_THRESHOLD",
19
+ "MAX_GAP_SEC",
20
+ "SCENE_BINS_N",
21
+ "SCHEMA_VERSION",
22
+ ]
23
+
24
+ __version__ = "0.1.2"
@@ -0,0 +1,260 @@
1
+ """Acquire the speech track for a YouTube video.
2
+
3
+ Strategy (captions-first, token-zero):
4
+ 1. Try captions via youtube-transcript-api / yt-dlp (manual then auto).
5
+ 2. If captions are absent/poor OR --force-stt, download audio for STT.
6
+
7
+ The network/binary calls are isolated; the subtitle PARSERS below are pure
8
+ functions over text and are unit-tested offline.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ import subprocess
16
+ import warnings
17
+ from dataclasses import dataclass, field
18
+
19
+
20
+ @dataclass
21
+ class Segment:
22
+ """One timestamped utterance. `t` is the start time in seconds."""
23
+
24
+ t: float
25
+ text: str
26
+
27
+ def as_dict(self) -> dict:
28
+ return {"t": round(self.t, 3), "text": self.text}
29
+
30
+
31
+ @dataclass
32
+ class SpeechTrack:
33
+ segments: list[Segment]
34
+ source: str # "caption" | "stt"
35
+ language: str | None = None
36
+ meta: dict = field(default_factory=dict)
37
+
38
+ @property
39
+ def is_empty(self) -> bool:
40
+ return not self.segments
41
+
42
+
43
+ _URL_ID_PATTERNS = [
44
+ re.compile(r"(?:v=|/shorts/|youtu\.be/|/embed/)([0-9A-Za-z_-]{11})"),
45
+ re.compile(r"^([0-9A-Za-z_-]{11})$"),
46
+ ]
47
+
48
+
49
+ def extract_video_id(url: str) -> str | None:
50
+ """Pull the 11-char video id out of a URL or bare id. Pure."""
51
+ url = url.strip()
52
+ for pat in _URL_ID_PATTERNS:
53
+ m = pat.search(url)
54
+ if m:
55
+ return m.group(1)
56
+ return None
57
+
58
+ def _metadata_text(value: object) -> str | None:
59
+ text = str(value).strip() if value is not None else ""
60
+ return text or None
61
+
62
+
63
+ def _positive_float(value: object) -> float | None:
64
+ if value in (None, ""):
65
+ return None
66
+ try:
67
+ number = float(value)
68
+ except (TypeError, ValueError):
69
+ return None
70
+ return number if number > 0 else None
71
+
72
+
73
+ def parse_ytdlp_metadata(text: str) -> dict:
74
+ """Parse `yt-dlp --dump-json` output into the metadata LecturAL needs."""
75
+ data = json.loads(text)
76
+ if not isinstance(data, dict):
77
+ raise ValueError("yt-dlp metadata JSON must be an object")
78
+
79
+ metadata: dict = {}
80
+ title = _metadata_text(data.get("title"))
81
+ if title:
82
+ metadata["title"] = title
83
+
84
+ duration = _positive_float(data.get("duration"))
85
+ if duration is not None:
86
+ metadata["duration"] = duration
87
+
88
+ video_id = _metadata_text(data.get("id") or data.get("display_id"))
89
+ if video_id:
90
+ metadata["video_id"] = video_id
91
+
92
+ return metadata
93
+
94
+
95
+ def fetch_video_metadata(url: str) -> dict:
96
+ """Fetch title/duration/video id via yt-dlp without downloading media."""
97
+ proc = subprocess.run(
98
+ ["yt-dlp", "--skip-download", "--dump-json", url],
99
+ check=True,
100
+ capture_output=True,
101
+ text=True,
102
+ )
103
+ metadata = parse_ytdlp_metadata(proc.stdout)
104
+ fallback_id = extract_video_id(url)
105
+ if fallback_id:
106
+ metadata.setdefault("video_id", fallback_id)
107
+ return metadata
108
+
109
+ # --- Pure subtitle parsers --------------------------------------------------
110
+
111
+ _TS_RE = re.compile(r"(\d{1,2}):(\d{2}):(\d{2})[.,](\d{1,3})")
112
+
113
+
114
+ def _hms_to_seconds(h: str, m: str, s: str, ms: str) -> float:
115
+ return int(h) * 3600 + int(m) * 60 + int(s) + int(ms.ljust(3, "0")) / 1000.0
116
+
117
+
118
+ def parse_vtt(text: str) -> list[Segment]:
119
+ """Parse WebVTT / SRT-ish caption text into ordered Segments. Pure.
120
+
121
+ Handles `HH:MM:SS.mmm --> HH:MM:SS.mmm` cue headers, strips inline tags
122
+ like <c> and positioning, and collapses blank-separated cue bodies.
123
+ """
124
+ segments: list[Segment] = []
125
+ lines = text.replace("\r\n", "\n").split("\n")
126
+ i = 0
127
+ n = len(lines)
128
+ while i < n:
129
+ line = lines[i].strip()
130
+ if "-->" in line:
131
+ m = _TS_RE.search(line)
132
+ start = _hms_to_seconds(*m.groups()) if m else 0.0
133
+ i += 1
134
+ body: list[str] = []
135
+ while i < n and lines[i].strip() and "-->" not in lines[i]:
136
+ body.append(lines[i].strip())
137
+ i += 1
138
+ cue = " ".join(body)
139
+ cue = re.sub(r"<[^>]+>", "", cue) # strip <c>, <00:00:00.000> tags
140
+ cue = re.sub(r"\s+", " ", cue).strip()
141
+ if cue:
142
+ segments.append(Segment(t=start, text=cue))
143
+ else:
144
+ i += 1
145
+ return _dedupe_rolling(segments)
146
+
147
+
148
+ def parse_json3(text: str) -> list[Segment]:
149
+ """Parse YouTube `json3` caption payload into Segments. Pure."""
150
+ data = json.loads(text)
151
+ segments: list[Segment] = []
152
+ for event in data.get("events", []):
153
+ segs = event.get("segs")
154
+ if not segs:
155
+ continue
156
+ start_ms = event.get("tStartMs", 0)
157
+ body = "".join(s.get("utf8", "") for s in segs)
158
+ body = re.sub(r"\s+", " ", body).strip()
159
+ if body:
160
+ segments.append(Segment(t=start_ms / 1000.0, text=body))
161
+ return _dedupe_rolling(segments)
162
+
163
+
164
+ def _dedupe_rolling(segments: list[Segment]) -> list[Segment]:
165
+ """Drop consecutive duplicate cue text (common in auto-captions). Pure."""
166
+ out: list[Segment] = []
167
+ for seg in segments:
168
+ if out and out[-1].text == seg.text:
169
+ continue
170
+ out.append(seg)
171
+ return out
172
+
173
+
174
+ def captions_are_usable(segments: list[Segment], min_segments: int = 3) -> bool:
175
+ """Heuristic for "captions present and not garbage". Pure."""
176
+ if len(segments) < min_segments:
177
+ return False
178
+ total_chars = sum(len(s.text) for s in segments)
179
+ return total_chars >= 20
180
+
181
+
182
+ # --- Network/binary-backed acquisition (lazy) ------------------------------
183
+
184
+ def fetch_caption_segments(video_id: str, languages: tuple[str, ...] = ("ko", "en")) -> list[Segment]:
185
+ """Fetch captions via youtube-transcript-api. Lazy import; may raise."""
186
+ from youtube_transcript_api import YouTubeTranscriptApi # lazy
187
+
188
+ api = YouTubeTranscriptApi()
189
+ fetched = api.fetch(video_id, languages=list(languages))
190
+ segments = [
191
+ Segment(t=float(item.start), text=re.sub(r"\s+", " ", item.text).strip())
192
+ for item in fetched
193
+ if item.text.strip()
194
+ ]
195
+ return _dedupe_rolling(segments)
196
+
197
+
198
+ def download_audio(url: str, out_dir: str) -> str:
199
+ """Download bestaudio as wav via yt-dlp+ffmpeg for STT. Returns path."""
200
+ from .deps import assert_acquisition_ready
201
+
202
+ assert_acquisition_ready()
203
+ import os
204
+
205
+ os.makedirs(out_dir, exist_ok=True)
206
+ out_template = os.path.join(out_dir, "audio.%(ext)s")
207
+ subprocess.run(
208
+ [
209
+ "yt-dlp", "-x", "--audio-format", "wav",
210
+ "-o", out_template, url,
211
+ ],
212
+ check=True,
213
+ )
214
+ wav = os.path.join(out_dir, "audio.wav")
215
+ if not os.path.exists(wav):
216
+ raise RuntimeError("Audio download did not produce audio.wav")
217
+ return wav
218
+
219
+
220
+ def acquire_speech(
221
+ url: str,
222
+ out_dir: str,
223
+ force_stt: bool = False,
224
+ languages: tuple[str, ...] = ("ko", "en"),
225
+ ) -> SpeechTrack:
226
+ """Captions-first acquisition with STT fallback. Orchestration only."""
227
+ video_id = extract_video_id(url)
228
+ if not video_id:
229
+ raise ValueError(f"Could not extract a YouTube video id from: {url!r}")
230
+
231
+ fallback_reason: str | None = None
232
+ if force_stt:
233
+ fallback_reason = "force_stt requested"
234
+ else:
235
+ try:
236
+ segs = fetch_caption_segments(video_id, languages)
237
+ if captions_are_usable(segs):
238
+ return SpeechTrack(segments=segs, source="caption", meta={"video_id": video_id})
239
+ fallback_reason = f"captions present but unusable ({len(segs)} cues)"
240
+ except Exception as exc: # noqa: BLE001
241
+ # youtube-transcript-api raises several distinct types
242
+ # (NoTranscriptFound, TranscriptsDisabled, network errors) that
243
+ # cannot be imported without the optional dep, so we catch broadly
244
+ # here -- but the reason is preserved and surfaced, never discarded.
245
+ fallback_reason = f"caption fetch failed: {type(exc).__name__}: {exc}"
246
+
247
+ # STT fallback (heavy; delegated to speech.py). Make the degradation
248
+ # observable, mirroring the OCR Paddle->Tesseract fallback warning.
249
+ warnings.warn(
250
+ f"Captions unavailable; falling back to CPU STT. Reason: {fallback_reason}",
251
+ RuntimeWarning,
252
+ stacklevel=2,
253
+ )
254
+ from .speech import transcribe_audio
255
+
256
+ audio_path = download_audio(url, out_dir)
257
+ track = transcribe_audio(audio_path)
258
+ track.meta.setdefault("video_id", video_id)
259
+ track.meta["caption_fallback_reason"] = fallback_reason
260
+ return track