lectural 0.1.2__py3-none-any.whl
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/__init__.py +24 -0
- lectural/acquisition.py +260 -0
- lectural/cli.py +286 -0
- lectural/config.py +63 -0
- lectural/coverage.py +240 -0
- lectural/deps.py +275 -0
- lectural/doctor.py +500 -0
- lectural/notes_contract.py +174 -0
- lectural/ocr.py +354 -0
- lectural/runstate.py +98 -0
- lectural/speech.py +59 -0
- lectural/synthesis.py +258 -0
- lectural/vad.py +160 -0
- lectural/visual.py +406 -0
- lectural-0.1.2.dist-info/METADATA +131 -0
- lectural-0.1.2.dist-info/RECORD +20 -0
- lectural-0.1.2.dist-info/WHEEL +5 -0
- lectural-0.1.2.dist-info/entry_points.txt +2 -0
- lectural-0.1.2.dist-info/licenses/LICENSE +21 -0
- lectural-0.1.2.dist-info/top_level.txt +1 -0
lectural/__init__.py
ADDED
|
@@ -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"
|
lectural/acquisition.py
ADDED
|
@@ -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
|
lectural/cli.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""`lectural` CLI: turn YouTube lecture URL(s) into complete study notes.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
lectural doctor [--fix] [--json]
|
|
5
|
+
lectural <url> [<url> ...] [--force-stt] [--model medium] [--out ./output]
|
|
6
|
+
|
|
7
|
+
Single URL or a SEQUENTIAL batch (AC-1, AC-2). The per-video pipeline is the
|
|
8
|
+
real (lazy) module stack; orchestration (arg parsing, slugging, batch loop,
|
|
9
|
+
run-state recording) is pure and unit-tested with an injected processor so it
|
|
10
|
+
runs offline without ffmpeg/yt-dlp/models.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import inspect
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
from . import runstate
|
|
22
|
+
from .config import DEFAULT_STT_MODEL
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def slugify(title: str, fallback: str = "video") -> str:
|
|
26
|
+
"""Pure: filesystem-safe directory name from a video title."""
|
|
27
|
+
title = (title or "").strip()
|
|
28
|
+
slug = re.sub(r"[^\w\-가-힣]+", "-", title, flags=re.UNICODE).strip("-")
|
|
29
|
+
slug = re.sub(r"-{2,}", "-", slug)
|
|
30
|
+
return (slug or fallback)[:80]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def output_dir_for(out_root: str, title: str, fallback: str = "video") -> str:
|
|
34
|
+
"""Pure: ./out_root/<slug> path for a video's artifacts (AC-12)."""
|
|
35
|
+
return os.path.join(out_root, slugify(title, fallback))
|
|
36
|
+
|
|
37
|
+
def _frame_link(image_path: str, out_dir: str) -> str:
|
|
38
|
+
"""Pure: relative slide-image path as a POSIX markdown link (Windows-safe)."""
|
|
39
|
+
return os.path.relpath(image_path, out_dir).replace(os.sep, "/")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _run_parser() -> argparse.ArgumentParser:
|
|
43
|
+
p = argparse.ArgumentParser(
|
|
44
|
+
prog="lectural",
|
|
45
|
+
description="YouTube lecture -> complete study notes",
|
|
46
|
+
epilog="Command: lectural doctor [--fix] [--json]",
|
|
47
|
+
)
|
|
48
|
+
p.add_argument("urls", nargs="*", help="One or more YouTube URLs (processed sequentially)")
|
|
49
|
+
p.add_argument("--force-stt", action="store_true", help="Skip captions; always transcribe with STT")
|
|
50
|
+
p.add_argument("--model", default=DEFAULT_STT_MODEL, help="faster-whisper model size (default: medium)")
|
|
51
|
+
p.add_argument("--out", default="./output", help="Output root directory (default: ./output)")
|
|
52
|
+
p.add_argument(
|
|
53
|
+
"--keep-frames",
|
|
54
|
+
action="store_true",
|
|
55
|
+
help="Archive raw sampled frames under frames/raw/ instead of deleting extras",
|
|
56
|
+
)
|
|
57
|
+
return p
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _doctor_parser() -> argparse.ArgumentParser:
|
|
61
|
+
p = argparse.ArgumentParser(prog="lectural doctor", description="Validate LecturAL runtime and plugin distribution")
|
|
62
|
+
p.add_argument("--fix", action="store_true", help="Attempt safe bounded fixes for missing yt-dlp/ffmpeg")
|
|
63
|
+
p.add_argument("--json", action="store_true", help="Print a machine-readable JSON report")
|
|
64
|
+
return p
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
68
|
+
argv = list(argv)
|
|
69
|
+
if argv and argv[0] == "doctor":
|
|
70
|
+
args = _doctor_parser().parse_args(argv[1:])
|
|
71
|
+
args.command = "doctor"
|
|
72
|
+
return args
|
|
73
|
+
|
|
74
|
+
p = _run_parser()
|
|
75
|
+
args = p.parse_args(argv)
|
|
76
|
+
args.command = "run"
|
|
77
|
+
if not args.urls:
|
|
78
|
+
p.error("the following arguments are required: urls (or use `lectural doctor`)")
|
|
79
|
+
return args
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def run(
|
|
83
|
+
urls: list[str],
|
|
84
|
+
out_root: str = "./output",
|
|
85
|
+
force_stt: bool = False,
|
|
86
|
+
model: str = DEFAULT_STT_MODEL,
|
|
87
|
+
processor=None,
|
|
88
|
+
runstate_file: str | None = None,
|
|
89
|
+
keep_frames: bool = False,
|
|
90
|
+
) -> list[dict]:
|
|
91
|
+
"""Sequentially process each URL; pre-register and record EVERY run.
|
|
92
|
+
|
|
93
|
+
`processor(url, out_dir, force_stt, model) -> dict` is injectable; the
|
|
94
|
+
default uses the real pipeline. Each result dict must include output_dir,
|
|
95
|
+
coverage_json, notes_md, transcript_md, and overall_pass.
|
|
96
|
+
|
|
97
|
+
Every URL is pre-registered as `pending` so a failed or unproduced video
|
|
98
|
+
stays visible to the completeness hook (it cannot be hidden by aborting).
|
|
99
|
+
A processor failure is recorded and the batch CONTINUES to the next URL.
|
|
100
|
+
"""
|
|
101
|
+
processor = processor or _default_processor
|
|
102
|
+
def _call_processor(url: str, out_dir: str) -> dict:
|
|
103
|
+
try:
|
|
104
|
+
signature = inspect.signature(processor)
|
|
105
|
+
except (TypeError, ValueError):
|
|
106
|
+
return processor(url, out_dir, force_stt, model)
|
|
107
|
+
|
|
108
|
+
accepts_keep = (
|
|
109
|
+
"keep_frames" in signature.parameters
|
|
110
|
+
or any(p.kind == inspect.Parameter.VAR_KEYWORD for p in signature.parameters.values())
|
|
111
|
+
)
|
|
112
|
+
if accepts_keep:
|
|
113
|
+
return processor(url, out_dir, force_stt, model, keep_frames=keep_frames)
|
|
114
|
+
return processor(url, out_dir, force_stt, model)
|
|
115
|
+
|
|
116
|
+
runstate.start_session(urls, runstate_file)
|
|
117
|
+
results: list[dict] = []
|
|
118
|
+
for i, url in enumerate(urls):
|
|
119
|
+
out_dir = os.path.join(out_root, f"video_{i + 1:02d}") # provisional
|
|
120
|
+
try:
|
|
121
|
+
result = _call_processor(url, out_dir)
|
|
122
|
+
runstate.update_run(
|
|
123
|
+
i,
|
|
124
|
+
status="complete",
|
|
125
|
+
output_dir=result["output_dir"],
|
|
126
|
+
coverage_json=result["coverage_json"],
|
|
127
|
+
notes_md=result["notes_md"],
|
|
128
|
+
path=runstate_file,
|
|
129
|
+
)
|
|
130
|
+
results.append(result)
|
|
131
|
+
except Exception as exc: # noqa: BLE001 - record + continue, do not hide failures
|
|
132
|
+
runstate.update_run(i, status="failed", error=f"{type(exc).__name__}: {exc}", path=runstate_file)
|
|
133
|
+
results.append({"output_dir": out_dir, "url": url, "overall_pass": False,
|
|
134
|
+
"error": f"{type(exc).__name__}: {exc}"})
|
|
135
|
+
return results
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _default_processor(
|
|
139
|
+
url: str,
|
|
140
|
+
out_dir_hint: str,
|
|
141
|
+
force_stt: bool,
|
|
142
|
+
model: str,
|
|
143
|
+
*,
|
|
144
|
+
keep_frames: bool = False,
|
|
145
|
+
) -> dict:
|
|
146
|
+
"""Real pipeline for one video (lazy heavy deps; smoke-tested, not unit)."""
|
|
147
|
+
from .acquisition import acquire_speech, extract_video_id, fetch_video_metadata
|
|
148
|
+
from .coverage import build_coverage, coverage_inputs_from_extraction, write_coverage
|
|
149
|
+
from .deps import assert_acquisition_ready
|
|
150
|
+
from .ocr import ocr_frames
|
|
151
|
+
from .synthesis import (
|
|
152
|
+
build_synthesis_input,
|
|
153
|
+
render_notes_md,
|
|
154
|
+
render_transcript_md,
|
|
155
|
+
write_synthesis_input,
|
|
156
|
+
write_text,
|
|
157
|
+
)
|
|
158
|
+
from .vad import detect_speech_spans
|
|
159
|
+
from .visual import cleanup_raw_frames, dedupe_frames, extract_candidate_frames
|
|
160
|
+
|
|
161
|
+
assert_acquisition_ready()
|
|
162
|
+
out_root = os.path.dirname(out_dir_hint) or "."
|
|
163
|
+
|
|
164
|
+
# 1. Metadata first: it determines the real artifact directory before any
|
|
165
|
+
# captions/STT path can be selected.
|
|
166
|
+
metadata = fetch_video_metadata(url)
|
|
167
|
+
fallback_title = metadata.get("video_id") or extract_video_id(url) or "video"
|
|
168
|
+
title_seed = metadata.get("title") or fallback_title
|
|
169
|
+
out_dir = output_dir_for(out_root, title_seed, fallback=fallback_title)
|
|
170
|
+
frames_dir = os.path.join(out_dir, "frames")
|
|
171
|
+
os.makedirs(frames_dir, exist_ok=True)
|
|
172
|
+
|
|
173
|
+
# 2. Speech track (captions-first, STT fallback) writes into the final
|
|
174
|
+
# title/video-id directory, not the provisional batch slot.
|
|
175
|
+
track = acquire_speech(url, out_dir, force_stt=force_stt)
|
|
176
|
+
track.meta.update({k: v for k, v in metadata.items() if v not in (None, "")})
|
|
177
|
+
title = track.meta.get("title") or fallback_title
|
|
178
|
+
|
|
179
|
+
# 3. Visual track: extract RAW candidate frames, dedupe to slides, OCR.
|
|
180
|
+
video_path = _download_video(url, out_dir)
|
|
181
|
+
raw_frames = extract_candidate_frames(video_path, frames_dir)
|
|
182
|
+
slides = dedupe_frames(raw_frames)
|
|
183
|
+
slide_frames, ocr_engine = ocr_frames(slides)
|
|
184
|
+
|
|
185
|
+
duration = float(track.meta.get("duration") or 0.0)
|
|
186
|
+
audio_path = track.meta.get("audio_path", os.path.join(out_dir, "audio.wav"))
|
|
187
|
+
speech_spans = detect_speech_spans(audio_path, duration) if os.path.isfile(audio_path) else [(0.0, duration)]
|
|
188
|
+
|
|
189
|
+
video = {"title": title, "url": url, "duration_sec": duration,
|
|
190
|
+
"language": track.language, "source": track.source}
|
|
191
|
+
segments = [s.as_dict() for s in track.segments]
|
|
192
|
+
# Frame links are markdown/web paths -> always POSIX separators (so the
|
|
193
|
+
# slide-link check and rendered links work on Windows too).
|
|
194
|
+
slide_dicts = [{"t": f.timestamp,
|
|
195
|
+
"frame": _frame_link(f.image_path, out_dir),
|
|
196
|
+
"ocr_text": f.ocr_text, "is_slide": True} for f in slide_frames]
|
|
197
|
+
|
|
198
|
+
# 4. Synthesis (deterministic, token-zero).
|
|
199
|
+
si = build_synthesis_input(video, segments, slide_dicts)
|
|
200
|
+
transcript_path = os.path.join(out_dir, "transcript.md")
|
|
201
|
+
notes_path = os.path.join(out_dir, "notes.md")
|
|
202
|
+
transcript_md = render_transcript_md(video, segments)
|
|
203
|
+
write_text(transcript_md, transcript_path)
|
|
204
|
+
write_synthesis_input(si, os.path.join(out_dir, "synthesis_input.json"))
|
|
205
|
+
|
|
206
|
+
# 5. Coverage (raw sample times enforce the carry-cap contract). Render
|
|
207
|
+
# notes before the final coverage write so artifact checks judge rendered
|
|
208
|
+
# content, not filesystem write ordering.
|
|
209
|
+
raw_sample_times = [f.timestamp for f in raw_frames]
|
|
210
|
+
|
|
211
|
+
def _cov_inputs(notes_md_text: str | None) -> "object":
|
|
212
|
+
return coverage_inputs_from_extraction(
|
|
213
|
+
video_title=title, duration_sec=duration, speech_spans=speech_spans,
|
|
214
|
+
segment_times=[s["t"] for s in segments],
|
|
215
|
+
raw_sample_times=raw_sample_times,
|
|
216
|
+
slides=slide_dicts, transcript_path=transcript_path, notes_path=notes_path,
|
|
217
|
+
ocr_engine=ocr_engine,
|
|
218
|
+
transcript_text=transcript_md, notes_text=notes_md_text,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
draft_coverage = build_coverage(_cov_inputs(""))
|
|
222
|
+
draft_notes_md = render_notes_md(si, draft_coverage)
|
|
223
|
+
coverage = build_coverage(_cov_inputs(draft_notes_md))
|
|
224
|
+
notes_md = render_notes_md(si, coverage)
|
|
225
|
+
coverage = build_coverage(_cov_inputs(notes_md))
|
|
226
|
+
write_text(notes_md, notes_path)
|
|
227
|
+
coverage_path = write_coverage(coverage, os.path.join(out_dir, "coverage.json"))
|
|
228
|
+
cleanup_raw_frames(raw_frames, slide_frames, keep_frames=keep_frames)
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
"output_dir": out_dir,
|
|
232
|
+
"coverage_json": coverage_path,
|
|
233
|
+
"notes_md": notes_path,
|
|
234
|
+
"transcript_md": transcript_path,
|
|
235
|
+
"overall_pass": coverage["overall_pass"],
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _download_video(url: str, out_dir: str) -> str:
|
|
240
|
+
"""Download the video (for frame extraction) via yt-dlp. Lazy/subprocess."""
|
|
241
|
+
import subprocess
|
|
242
|
+
|
|
243
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
244
|
+
out_template = os.path.join(out_dir, "video.%(ext)s")
|
|
245
|
+
subprocess.run(["yt-dlp", "-f", "bestvideo[height<=720]+bestaudio/best",
|
|
246
|
+
"-o", out_template, url], check=True)
|
|
247
|
+
for name in os.listdir(out_dir):
|
|
248
|
+
if name.startswith("video."):
|
|
249
|
+
return os.path.join(out_dir, name)
|
|
250
|
+
raise RuntimeError("Video download did not produce a video file")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def main(argv: list[str] | None = None) -> int:
|
|
254
|
+
args = parse_args(argv if argv is not None else sys.argv[1:])
|
|
255
|
+
if args.command == "doctor":
|
|
256
|
+
try:
|
|
257
|
+
from . import doctor
|
|
258
|
+
|
|
259
|
+
report = doctor.run(fix=args.fix)
|
|
260
|
+
doctor.print_report(report, json_output=args.json)
|
|
261
|
+
return int(report["exit_code"])
|
|
262
|
+
except Exception as exc: # noqa: BLE001 - surface a clean CLI error
|
|
263
|
+
print(f"lectural doctor: internal failure — {exc}", file=sys.stderr)
|
|
264
|
+
return 1
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
results = run(
|
|
268
|
+
args.urls,
|
|
269
|
+
out_root=args.out,
|
|
270
|
+
force_stt=args.force_stt,
|
|
271
|
+
model=args.model,
|
|
272
|
+
keep_frames=args.keep_frames,
|
|
273
|
+
)
|
|
274
|
+
except Exception as exc: # noqa: BLE001 - surface a clean CLI error
|
|
275
|
+
print(f"lectural: 실패 — {exc}", file=sys.stderr)
|
|
276
|
+
return 1
|
|
277
|
+
ok = all(r.get("overall_pass") for r in results)
|
|
278
|
+
for r in results:
|
|
279
|
+
mark = "OK" if r.get("overall_pass") else "미달"
|
|
280
|
+
print(f"[{mark}] {r['output_dir']}")
|
|
281
|
+
print("완료 게이트는 Stop 훅(scripts/completeness_hook.py)이 최종 검증합니다.")
|
|
282
|
+
return 0 if ok else 2
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
if __name__ == "__main__":
|
|
286
|
+
raise SystemExit(main())
|
lectural/config.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Named configuration constants for the LecturAL pipeline.
|
|
2
|
+
|
|
3
|
+
These are the tunable knobs the consensus plan required to be explicit,
|
|
4
|
+
named constants (not magic numbers scattered through the code).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
# --- Synthesis contract ----------------------------------------------------
|
|
10
|
+
# Bump when the synthesis_input.json shape changes incompatibly.
|
|
11
|
+
SCHEMA_VERSION: int = 1
|
|
12
|
+
|
|
13
|
+
# --- Frame dedup -----------------------------------------------------------
|
|
14
|
+
# Two consecutive frames are considered the SAME slide when their colour
|
|
15
|
+
# histogram correlation is at or above this threshold AND their structural
|
|
16
|
+
# similarity (SSIM) is at or above DEDUP_SSIM_THRESHOLD. Range 0..1.
|
|
17
|
+
DEDUP_HIST_THRESHOLD: float = 0.90
|
|
18
|
+
DEDUP_SSIM_THRESHOLD: float = 0.92
|
|
19
|
+
|
|
20
|
+
# --- Speech-gap coverage ---------------------------------------------------
|
|
21
|
+
# A completeness FAIL occurs when there is a contiguous span of *speech*
|
|
22
|
+
# (per the VAD/silence mask) longer than this many seconds that has no
|
|
23
|
+
# transcript coverage. Silence does not count against coverage.
|
|
24
|
+
MAX_GAP_SEC: float = 60.0
|
|
25
|
+
# A single caption/STT cue is assumed to cover at most this many seconds of
|
|
26
|
+
# speech. Beyond this, the span between cues counts as untranscribed (so a
|
|
27
|
+
# long cue-less stretch during speech is detected as a real gap).
|
|
28
|
+
CUE_MAX_COVER_SEC: float = 30.0
|
|
29
|
+
|
|
30
|
+
# --- Scene coverage --------------------------------------------------------
|
|
31
|
+
# The timeline is divided into this many equal bins. A bin that contains
|
|
32
|
+
# speech must be covered by a keyframe: a keyframe covers its own bin and
|
|
33
|
+
# carries forward to later bins, but only for up to FRAME_CARRY_MAX_SEC (so a
|
|
34
|
+
# static slide passes when fed dense raw samples, while a real extractor stall
|
|
35
|
+
# leaves a keyframe-less stretch uncovered and FAILs).
|
|
36
|
+
SCENE_BINS_N: int = 20
|
|
37
|
+
# Max seconds a keyframe carries forward before its bin coverage expires.
|
|
38
|
+
# scene_coverage expects RAW sampled keyframe times (dense, ~SAMPLE_FPS),
|
|
39
|
+
# pre-dedup; this cap then catches a stalled/missing visual pass.
|
|
40
|
+
FRAME_CARRY_MAX_SEC: float = 120.0
|
|
41
|
+
|
|
42
|
+
# --- Visual extraction -----------------------------------------------------
|
|
43
|
+
# Temporal downsample cap (frames per second) before scene detection.
|
|
44
|
+
SAMPLE_FPS: float = 2.0
|
|
45
|
+
|
|
46
|
+
# --- Speech / STT ----------------------------------------------------------
|
|
47
|
+
# Default faster-whisper model + compute type (CPU-friendly).
|
|
48
|
+
DEFAULT_STT_MODEL: str = "medium"
|
|
49
|
+
STT_COMPUTE_TYPE: str = "int8"
|
|
50
|
+
# Warn (and let the caller decide) when a video to be transcribed by STT is
|
|
51
|
+
# longer than this, because CPU transcription of long videos is slow.
|
|
52
|
+
STT_LONG_VIDEO_WARN_SEC: float = 45 * 60.0
|
|
53
|
+
|
|
54
|
+
# --- OCR -------------------------------------------------------------------
|
|
55
|
+
# A frame is classified as a "slide" (and therefore expected to contain OCR
|
|
56
|
+
# text) when its OCR text has at least this many non-whitespace characters.
|
|
57
|
+
SLIDE_MIN_TEXT_CHARS: int = 12
|
|
58
|
+
|
|
59
|
+
# --- Incremental-slide re-split --------------------------------------------
|
|
60
|
+
# When a frame's OCR text is a superset of the previous frame's text and adds
|
|
61
|
+
# at least this fraction of new characters, it is treated as a NEW
|
|
62
|
+
# incremental slide rather than a duplicate of the previous one.
|
|
63
|
+
INCREMENTAL_SLIDE_MIN_GROWTH: float = 0.15
|