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.
- lectural-0.1.2/LICENSE +21 -0
- lectural-0.1.2/PKG-INFO +131 -0
- lectural-0.1.2/README.md +107 -0
- lectural-0.1.2/lectural/__init__.py +24 -0
- lectural-0.1.2/lectural/acquisition.py +260 -0
- lectural-0.1.2/lectural/cli.py +286 -0
- lectural-0.1.2/lectural/config.py +63 -0
- lectural-0.1.2/lectural/coverage.py +240 -0
- lectural-0.1.2/lectural/deps.py +275 -0
- lectural-0.1.2/lectural/doctor.py +500 -0
- lectural-0.1.2/lectural/notes_contract.py +174 -0
- lectural-0.1.2/lectural/ocr.py +354 -0
- lectural-0.1.2/lectural/runstate.py +98 -0
- lectural-0.1.2/lectural/speech.py +59 -0
- lectural-0.1.2/lectural/synthesis.py +258 -0
- lectural-0.1.2/lectural/vad.py +160 -0
- lectural-0.1.2/lectural/visual.py +406 -0
- lectural-0.1.2/lectural.egg-info/PKG-INFO +131 -0
- lectural-0.1.2/lectural.egg-info/SOURCES.txt +45 -0
- lectural-0.1.2/lectural.egg-info/dependency_links.txt +1 -0
- lectural-0.1.2/lectural.egg-info/entry_points.txt +2 -0
- lectural-0.1.2/lectural.egg-info/requires.txt +17 -0
- lectural-0.1.2/lectural.egg-info/top_level.txt +1 -0
- lectural-0.1.2/pyproject.toml +51 -0
- lectural-0.1.2/setup.cfg +4 -0
- lectural-0.1.2/tests/test_acquisition.py +111 -0
- lectural-0.1.2/tests/test_changelog_notes.py +89 -0
- lectural-0.1.2/tests/test_cli.py +280 -0
- lectural-0.1.2/tests/test_coverage.py +298 -0
- lectural-0.1.2/tests/test_dedup.py +176 -0
- lectural-0.1.2/tests/test_deps.py +211 -0
- lectural-0.1.2/tests/test_doctor.py +394 -0
- lectural-0.1.2/tests/test_hook.py +452 -0
- lectural-0.1.2/tests/test_notes.py +223 -0
- lectural-0.1.2/tests/test_ocr.py +250 -0
- lectural-0.1.2/tests/test_perf_smoke.py +63 -0
- lectural-0.1.2/tests/test_redteam_cli_hook.py +352 -0
- lectural-0.1.2/tests/test_redteam_core.py +168 -0
- lectural-0.1.2/tests/test_redteam_notes.py +473 -0
- lectural-0.1.2/tests/test_redteam_notes_contract.py +546 -0
- lectural-0.1.2/tests/test_redteam_packaging.py +179 -0
- lectural-0.1.2/tests/test_redteam_readme.py +56 -0
- lectural-0.1.2/tests/test_redteam_synthesis.py +225 -0
- lectural-0.1.2/tests/test_ssim.py +42 -0
- lectural-0.1.2/tests/test_summary_anchors.py +51 -0
- lectural-0.1.2/tests/test_vad.py +62 -0
- 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.
|
lectural-0.1.2/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://www.python.org/)
|
|
30
|
+
[](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).
|
lectural-0.1.2/README.md
ADDED
|
@@ -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
|
+
[](https://www.python.org/)
|
|
6
|
+
[](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
|