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 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"
@@ -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