learnx-cli 0.3.0__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.
- learnx_cli-0.3.0.dist-info/METADATA +240 -0
- learnx_cli-0.3.0.dist-info/RECORD +131 -0
- learnx_cli-0.3.0.dist-info/WHEEL +4 -0
- learnx_cli-0.3.0.dist-info/entry_points.txt +2 -0
- tutor/.env copy.example +4 -0
- tutor/__init__.py +0 -0
- tutor/__main__.py +4 -0
- tutor/assets/__init__.py +5 -0
- tutor/assets/html/fonts/Inter-Bold.woff2 +0 -0
- tutor/assets/html/fonts/Inter-Regular.woff2 +0 -0
- tutor/assets/html/fonts/Inter-SemiBold.woff2 +0 -0
- tutor/assets/html/fonts/JetBrainsMono-Regular.woff2 +0 -0
- tutor/assets/html/highlight-java.min.js +2 -0
- tutor/assets/html/highlight-javascript.min.js +2 -0
- tutor/assets/html/highlight-python.min.js +2 -0
- tutor/assets/html/highlight.min.js +17 -0
- tutor/assets/html/mermaid.min.js +31 -0
- tutor/assets/html/slide_base.css +464 -0
- tutor/assets/html/theme-learnx-dark.css +12 -0
- tutor/audio/__init__.py +0 -0
- tutor/audio/audio_builder.py +143 -0
- tutor/audio/sanitizer.py +9 -0
- tutor/audio/tts_renderer.py +54 -0
- tutor/cli/__init__.py +0 -0
- tutor/cli/commands.py +391 -0
- tutor/cli/logo.py +21 -0
- tutor/cli/playback_commands.py +239 -0
- tutor/cli/shell.py +91 -0
- tutor/cli/shell_context.py +18 -0
- tutor/cli/theme.py +39 -0
- tutor/cli/video_commands.py +123 -0
- tutor/config.py +122 -0
- tutor/conftest.py +5 -0
- tutor/constants.py +82 -0
- tutor/exceptions.py +26 -0
- tutor/generation/__init__.py +0 -0
- tutor/generation/assembler.py +81 -0
- tutor/generation/curriculum.py +97 -0
- tutor/generation/dialogue.py +172 -0
- tutor/generation/narrator.py +122 -0
- tutor/generation/segment_parser.py +223 -0
- tutor/generation/segment_planner.py +200 -0
- tutor/generation/visual_planner.py +205 -0
- tutor/infra/__init__.py +0 -0
- tutor/infra/llm.py +152 -0
- tutor/ingestion/__init__.py +0 -0
- tutor/ingestion/chunker.py +171 -0
- tutor/ingestion/doc_analyzer.py +41 -0
- tutor/ingestion/parse_content.py +19 -0
- tutor/ingestion/summarizer.py +51 -0
- tutor/inspector.py +117 -0
- tutor/llm_config.toml +58 -0
- tutor/models.py +147 -0
- tutor/player/__init__.py +0 -0
- tutor/player/input_handler.py +45 -0
- tutor/player/player.py +308 -0
- tutor/player/player_display.py +117 -0
- tutor/prompts/curriculum.txt +67 -0
- tutor/prompts/dialogue.txt +62 -0
- tutor/prompts/narrate.txt +34 -0
- tutor/prompts/qa.txt +17 -0
- tutor/prompts/summarize.txt +9 -0
- tutor/prompts/visual.txt +60 -0
- tutor/prompts/visual_v3.txt +91 -0
- tutor/qa/__init__.py +0 -0
- tutor/qa/qa.py +105 -0
- tutor/requirements-dev.txt +2 -0
- tutor/requirements.txt +12 -0
- tutor/sample_docs/headingless_large.md +1 -0
- tutor/sample_docs/headingless_test.md +1 -0
- tutor/sample_docs/java-basics.md +78 -0
- tutor/tests/__init__.py +0 -0
- tutor/tests/audio/__init__.py +0 -0
- tutor/tests/audio/test_audio_builder.py +106 -0
- tutor/tests/audio/test_sanitizer.py +41 -0
- tutor/tests/cli/__init__.py +0 -0
- tutor/tests/cli/test_commands.py +67 -0
- tutor/tests/cli/test_video_commands.py +190 -0
- tutor/tests/e2e/README.md +61 -0
- tutor/tests/e2e/__init__.py +0 -0
- tutor/tests/e2e/conftest.py +117 -0
- tutor/tests/e2e/fixtures/README.md +17 -0
- tutor/tests/e2e/fixtures/sample.md +13 -0
- tutor/tests/e2e/test_audio_quality.py +40 -0
- tutor/tests/e2e/test_av_sync.py +56 -0
- tutor/tests/e2e/test_pipeline_smoke.py +37 -0
- tutor/tests/e2e/test_slide_render.py +72 -0
- tutor/tests/e2e/test_video_streams.py +104 -0
- tutor/tests/generation/__init__.py +0 -0
- tutor/tests/generation/conftest.py +134 -0
- tutor/tests/generation/test_assembler.py +64 -0
- tutor/tests/generation/test_curriculum.py +107 -0
- tutor/tests/generation/test_narrator.py +165 -0
- tutor/tests/generation/test_segment_edge_cases.py +280 -0
- tutor/tests/generation/test_segment_planner.py +324 -0
- tutor/tests/generation/test_visual_planner.py +319 -0
- tutor/tests/ingestion/__init__.py +0 -0
- tutor/tests/ingestion/test_chunker.py +94 -0
- tutor/tests/ingestion/test_doc_analyzer.py +51 -0
- tutor/tests/player/__init__.py +0 -0
- tutor/tests/player/test_player_states.py +88 -0
- tutor/tests/test_assets.py +39 -0
- tutor/tests/test_models_visual.py +180 -0
- tutor/tests/visual/__init__.py +0 -0
- tutor/tests/visual/test_beat_timer.py +321 -0
- tutor/tests/visual/test_pipeline_integration.py +178 -0
- tutor/tests/visual/test_slide_renderer.py +298 -0
- tutor/tests/visual/test_subtitle_writer.py +165 -0
- tutor/tests/visual/test_video_assembler.py +108 -0
- tutor/tests/visual/test_visual_pipeline.py +270 -0
- tutor/tutor.py +365 -0
- tutor/visual/__init__.py +213 -0
- tutor/visual/beat_timer.py +222 -0
- tutor/visual/slide_renderer.py +236 -0
- tutor/visual/subtitle_writer.py +187 -0
- tutor/visual/templates/_base.html.j2 +40 -0
- tutor/visual/templates/analogy.html.j2 +21 -0
- tutor/visual/templates/callout.html.j2 +10 -0
- tutor/visual/templates/code_example.html.j2 +12 -0
- tutor/visual/templates/comparison.html.j2 +28 -0
- tutor/visual/templates/decision_guide.html.j2 +37 -0
- tutor/visual/templates/definition.html.j2 +13 -0
- tutor/visual/templates/diagram.html.j2 +11 -0
- tutor/visual/templates/hook_question.html.j2 +17 -0
- tutor/visual/templates/key_insight.html.j2 +9 -0
- tutor/visual/templates/memory_hook.html.j2 +7 -0
- tutor/visual/templates/outro.html.j2 +16 -0
- tutor/visual/templates/question_prompt.html.j2 +13 -0
- tutor/visual/templates/step_sequence.html.j2 +14 -0
- tutor/visual/templates/title_card.html.j2 +12 -0
- tutor/visual/video_assembler.py +299 -0
tutor/visual/__init__.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Entry point for the v3 visual pipeline.
|
|
3
|
+
Reads from audio/<session>/, writes to video/<session>/.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from tutor.models import DialogueLine
|
|
14
|
+
|
|
15
|
+
_UNIT_MP3_RE = re.compile(r"^unit_\d+$") # matches unit_01, unit_02 — not unit_00_intro
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_visual_pipeline(
|
|
21
|
+
session: str,
|
|
22
|
+
audio_dir: Path,
|
|
23
|
+
video_dir: Path,
|
|
24
|
+
llm_fn: Callable,
|
|
25
|
+
difficulty: str = "beginner",
|
|
26
|
+
no_cache: bool = False,
|
|
27
|
+
) -> Path:
|
|
28
|
+
"""
|
|
29
|
+
Full v3 pipeline for one session.
|
|
30
|
+
Reads from audio_dir, writes to video_dir.
|
|
31
|
+
Returns path to full_session.mp4.
|
|
32
|
+
"""
|
|
33
|
+
from tutor.generation.segment_planner import plan_segments
|
|
34
|
+
from tutor.generation.visual_planner import plan_visuals
|
|
35
|
+
from tutor.visual.beat_timer import compute_slide_timings_v3
|
|
36
|
+
from tutor.visual.slide_renderer import render_all_slides
|
|
37
|
+
from tutor.visual.subtitle_writer import build_srt
|
|
38
|
+
from tutor.visual.video_assembler import assemble_session
|
|
39
|
+
|
|
40
|
+
units_json = audio_dir / "tutorial.units.json"
|
|
41
|
+
doc_title = _doc_title_from_units(units_json)
|
|
42
|
+
unit_mp3s = _get_unit_mp3s(audio_dir)
|
|
43
|
+
unit_durations = [_mp3_duration(mp3) for mp3 in unit_mp3s]
|
|
44
|
+
slides_dir = video_dir / "slides"
|
|
45
|
+
slides_dir.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
|
|
47
|
+
print("\n [1/6] Generating visual specs (title card + outro)...")
|
|
48
|
+
visuals = plan_visuals(units_json, doc_title, session, llm_fn, difficulty, video_dir, no_cache)
|
|
49
|
+
|
|
50
|
+
print(" [2/6] Planning dialogue segments...")
|
|
51
|
+
segments_by_unit = plan_segments(units_json, video_dir, llm_fn, no_cache)
|
|
52
|
+
|
|
53
|
+
print(" [3/6] Rendering slides...")
|
|
54
|
+
title_spec = next(v for v in visuals if v.slide_type == "title_card")
|
|
55
|
+
outro_spec = next(v for v in visuals if v.slide_type == "outro")
|
|
56
|
+
slide_paths = render_all_slides(title_spec, outro_spec, segments_by_unit, slides_dir, session)
|
|
57
|
+
|
|
58
|
+
print(" [4/6] Building SRT subtitles...")
|
|
59
|
+
timing_json = _load_timing_json(audio_dir)
|
|
60
|
+
all_lines = _load_all_lines(units_json)
|
|
61
|
+
srt_text = build_srt(all_lines, unit_durations, timing_json)
|
|
62
|
+
srt_path = video_dir / "subtitles.srt"
|
|
63
|
+
srt_path.write_text(srt_text, encoding="utf-8")
|
|
64
|
+
|
|
65
|
+
print(" [5/6] Computing slide timings...")
|
|
66
|
+
title_path = slide_paths[0]
|
|
67
|
+
outro_path = slide_paths[-1]
|
|
68
|
+
slide_timings = compute_slide_timings_v3(
|
|
69
|
+
title_path, outro_path, segments_by_unit, timing_json, unit_durations
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
print(" [6/6] Assembling video...")
|
|
73
|
+
result = assemble_session(
|
|
74
|
+
video_dir, audio_dir / "tutorial_units", slide_timings, unit_mp3s, srt_path
|
|
75
|
+
)
|
|
76
|
+
total_s = sum(dur for _, dur in slide_timings)
|
|
77
|
+
m, s = divmod(int(total_s), 60)
|
|
78
|
+
print(f"\n ✓ {result} ({m}:{s:02d})")
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _load_timing_json(audio_dir: Path) -> dict | None:
|
|
83
|
+
"""
|
|
84
|
+
Load tutorial.timing.json. Returns None if absent, unreadable, or version != 1.
|
|
85
|
+
Logs a warning on parse failure; does not raise.
|
|
86
|
+
"""
|
|
87
|
+
path = audio_dir / "tutorial.timing.json"
|
|
88
|
+
if not path.exists():
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
92
|
+
return data if data.get("version") == 1 else None
|
|
93
|
+
except Exception:
|
|
94
|
+
log.warning("Could not parse tutorial.timing.json — using estimated timing")
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _get_unit_mp3s(audio_dir: Path) -> list[Path]:
|
|
99
|
+
"""Return unit MP3s matching unit_NN.mp3 pattern (teaching units, sorted)."""
|
|
100
|
+
return sorted(
|
|
101
|
+
p for p in (audio_dir / "tutorial_units").glob("unit_*.mp3") if _UNIT_MP3_RE.match(p.stem)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _doc_title_from_units(units_json: Path) -> str:
|
|
106
|
+
"""
|
|
107
|
+
Priority: H1 from source markdown → source filename stem → first unit concept.
|
|
108
|
+
Reads tutorial.meta.json (written by /generate) for the source file path.
|
|
109
|
+
"""
|
|
110
|
+
import re as _re
|
|
111
|
+
|
|
112
|
+
meta_path = units_json.parent / "tutorial.meta.json"
|
|
113
|
+
if meta_path.exists():
|
|
114
|
+
try:
|
|
115
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
116
|
+
src = Path(meta.get("source_file", ""))
|
|
117
|
+
if src.exists():
|
|
118
|
+
text = src.read_text(encoding="utf-8", errors="replace")
|
|
119
|
+
_SKIP = {"learning objectives", "introduction", "overview", "contents"}
|
|
120
|
+
for pat in (r"^#\s+(.+)$", r"^##\s+(.+)$"):
|
|
121
|
+
for m in _re.finditer(pat, text, _re.MULTILINE):
|
|
122
|
+
raw = m.group(1).strip()
|
|
123
|
+
raw = _re.sub(r"^\d+[.)]\s*", "", raw)
|
|
124
|
+
candidate = _re.sub(r"[^\w\s\-&]", "", raw).strip()
|
|
125
|
+
if candidate.lower() not in _SKIP and len(candidate) > 3:
|
|
126
|
+
return candidate
|
|
127
|
+
if src.stem:
|
|
128
|
+
stem = src.stem.replace("_", " ").replace("-", " ")
|
|
129
|
+
parent = src.parent.name.replace("_", " ").replace("-", " ")
|
|
130
|
+
if stem.isdigit() and parent:
|
|
131
|
+
return f"{parent.title()} - Part {stem}"
|
|
132
|
+
return stem.title()
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
try:
|
|
136
|
+
units = json.loads(units_json.read_text(encoding="utf-8"))
|
|
137
|
+
if units:
|
|
138
|
+
return str(units[0].get("concept", "Tutorial"))
|
|
139
|
+
except Exception:
|
|
140
|
+
pass
|
|
141
|
+
return "Tutorial"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _load_all_lines(units_json: Path) -> list[DialogueLine]:
|
|
145
|
+
"""
|
|
146
|
+
Load dialogue lines. Tries units JSON `lines` field first;
|
|
147
|
+
falls back to parsing tutorial.script.txt in the same directory.
|
|
148
|
+
"""
|
|
149
|
+
import re as _re
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
units = json.loads(units_json.read_text(encoding="utf-8"))
|
|
153
|
+
lines: list[DialogueLine] = []
|
|
154
|
+
for u in units:
|
|
155
|
+
for raw in u.get("lines", []):
|
|
156
|
+
lines.append(DialogueLine(**raw))
|
|
157
|
+
if lines:
|
|
158
|
+
return lines
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
script_path = units_json.parent / "tutorial.script.txt"
|
|
163
|
+
if not script_path.exists():
|
|
164
|
+
log.warning("No dialogue lines source found — subtitles will be empty")
|
|
165
|
+
return []
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
n_units = len(json.loads(units_json.read_text(encoding="utf-8")))
|
|
169
|
+
except Exception:
|
|
170
|
+
n_units = 1
|
|
171
|
+
|
|
172
|
+
raw_lines = [
|
|
173
|
+
ln.strip() for ln in script_path.read_text(encoding="utf-8").splitlines() if ln.strip()
|
|
174
|
+
]
|
|
175
|
+
speaker_re = _re.compile(r"^(ALEX|MAYA|SAM):\s*(.+)$")
|
|
176
|
+
valid = [(m.group(1), m.group(2)) for ln in raw_lines if (m := speaker_re.match(ln))]
|
|
177
|
+
|
|
178
|
+
if not valid:
|
|
179
|
+
return []
|
|
180
|
+
|
|
181
|
+
per_unit = max(1, len(valid) // max(n_units, 1))
|
|
182
|
+
result: list[DialogueLine] = []
|
|
183
|
+
for i, (speaker, text) in enumerate(valid):
|
|
184
|
+
unit_num = min(i // per_unit + 1, n_units)
|
|
185
|
+
result.append(DialogueLine(speaker=speaker, text=text, unit_number=unit_num))
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _mp3_duration(path: Path) -> float:
|
|
190
|
+
"""Return duration in seconds via ffprobe. Falls back to 0.0 on error."""
|
|
191
|
+
try:
|
|
192
|
+
result = subprocess.run(
|
|
193
|
+
[
|
|
194
|
+
"ffprobe",
|
|
195
|
+
"-v",
|
|
196
|
+
"error",
|
|
197
|
+
"-show_entries",
|
|
198
|
+
"format=duration",
|
|
199
|
+
"-of",
|
|
200
|
+
"default=noprint_wrappers=1:nokey=1",
|
|
201
|
+
str(path),
|
|
202
|
+
],
|
|
203
|
+
capture_output=True,
|
|
204
|
+
timeout=10,
|
|
205
|
+
)
|
|
206
|
+
return float(result.stdout.strip())
|
|
207
|
+
except Exception:
|
|
208
|
+
return 0.0
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _format_duration(seconds: float) -> str:
|
|
212
|
+
m, s = divmod(int(seconds), 60)
|
|
213
|
+
return f"{m}:{s:02d}"
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Map slide PNGs to playback durations based on dialogue beat points.
|
|
3
|
+
No ffmpeg, no Pillow, no LLM here.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from tutor.constants import SILENCE_TURN_MS
|
|
9
|
+
from tutor.models import DialogueLine, SlideSegment, VisualSpec
|
|
10
|
+
|
|
11
|
+
MIN_SLIDE_DURATION = 3.0 # seconds
|
|
12
|
+
MAX_HOOK_DURATION = 30.0 # cap hook slide — ALEX can monologue for a long time before MAYA
|
|
13
|
+
TITLE_CARD_DURATION = 4.0
|
|
14
|
+
OUTRO_CARD_DURATION = 6.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def compute_slide_timings_v3(
|
|
18
|
+
title_path: Path,
|
|
19
|
+
outro_path: Path,
|
|
20
|
+
segments_by_unit: dict[int, list[SlideSegment]],
|
|
21
|
+
timing_json: dict | None,
|
|
22
|
+
unit_durations_s: list[float],
|
|
23
|
+
) -> list[tuple[Path, float]]:
|
|
24
|
+
"""
|
|
25
|
+
Return [(png_path, duration_seconds), ...] in video order, ready for the
|
|
26
|
+
ffmpeg concat script. Prepends title card (4.0 s) and appends outro (6.0 s).
|
|
27
|
+
|
|
28
|
+
Duration source per segment:
|
|
29
|
+
timing_json present → _exact_duration()
|
|
30
|
+
timing_json absent → _proportional_duration()
|
|
31
|
+
|
|
32
|
+
Minimum per-segment: MIN_SLIDE_DURATION (3.0 s).
|
|
33
|
+
"""
|
|
34
|
+
result: list[tuple[Path, float]] = [(title_path, TITLE_CARD_DURATION)]
|
|
35
|
+
|
|
36
|
+
units_timing = timing_json.get("units", {}) if timing_json else {}
|
|
37
|
+
|
|
38
|
+
for unit_num in sorted(segments_by_unit.keys()):
|
|
39
|
+
segs = segments_by_unit[unit_num]
|
|
40
|
+
unit_dur = unit_durations_s[unit_num - 1] if unit_num - 1 < len(unit_durations_s) else 30.0
|
|
41
|
+
total_lines = max(s.lines_end for s in segs) + 1 if segs else 1
|
|
42
|
+
unit_timing: list[dict] = units_timing.get(str(unit_num), [])
|
|
43
|
+
|
|
44
|
+
for seg in segs:
|
|
45
|
+
path = Path(seg.png_path)
|
|
46
|
+
if timing_json and unit_timing:
|
|
47
|
+
dur = max(_exact_duration(seg, unit_timing), MIN_SLIDE_DURATION)
|
|
48
|
+
else:
|
|
49
|
+
dur = _proportional_duration(seg, unit_dur, total_lines)
|
|
50
|
+
result.append((path, dur))
|
|
51
|
+
|
|
52
|
+
result.append((outro_path, OUTRO_CARD_DURATION))
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _exact_duration(seg: SlideSegment, unit_timing: list[dict]) -> float:
|
|
57
|
+
"""
|
|
58
|
+
Look up timing entries for lines_start and lines_end in unit_timing.
|
|
59
|
+
Adds one trailing SILENCE_TURN_MS (inter-line silences are already in the raw span).
|
|
60
|
+
Falls back to proportional if either index is out of range.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
start_ms = unit_timing[seg.lines_start]["start_ms"]
|
|
64
|
+
end_ms = unit_timing[seg.lines_end]["end_ms"]
|
|
65
|
+
adjusted_ms = (end_ms - start_ms) + SILENCE_TURN_MS
|
|
66
|
+
return adjusted_ms / 1000.0
|
|
67
|
+
except (IndexError, KeyError, TypeError):
|
|
68
|
+
total_lines = len(unit_timing) if unit_timing else 1
|
|
69
|
+
return _proportional_duration(seg, 30.0, total_lines)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _proportional_duration(seg: SlideSegment, unit_duration_s: float, total_lines: int) -> float:
|
|
73
|
+
"""
|
|
74
|
+
Segment covers (lines_end - lines_start + 1) / total_lines of unit duration.
|
|
75
|
+
Return max(computed, MIN_SLIDE_DURATION).
|
|
76
|
+
"""
|
|
77
|
+
lines_covered = seg.lines_end - seg.lines_start + 1
|
|
78
|
+
denom = max(total_lines, 1)
|
|
79
|
+
computed = lines_covered / denom * unit_duration_s
|
|
80
|
+
return max(computed, MIN_SLIDE_DURATION)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _compute_slide_timings_v2(
|
|
84
|
+
slides: list[Path],
|
|
85
|
+
script_lines: list[DialogueLine],
|
|
86
|
+
line_start_offsets: list[float],
|
|
87
|
+
visuals: list[VisualSpec],
|
|
88
|
+
unit_durations_s: list[float],
|
|
89
|
+
) -> list[tuple[Path, float]]:
|
|
90
|
+
"""
|
|
91
|
+
Return [(slide_path, duration_seconds), ...] ordered for the ffmpeg concat script.
|
|
92
|
+
Title = 4 s fixed; outro = 6 s fixed; unit slides derived from dialogue beats.
|
|
93
|
+
"""
|
|
94
|
+
result: list[tuple[Path, float]] = []
|
|
95
|
+
|
|
96
|
+
slide_map = _build_slide_map(slides)
|
|
97
|
+
beat_map = _build_beat_map(script_lines, line_start_offsets, visuals, unit_durations_s)
|
|
98
|
+
|
|
99
|
+
# Pre-compute actual cumulative audio end for each unit (no silence inflation)
|
|
100
|
+
unit_audio_end: dict[int, float] = {}
|
|
101
|
+
_cursor = 0.0
|
|
102
|
+
for _ui, _dur in enumerate(unit_durations_s, start=1):
|
|
103
|
+
_cursor += _dur
|
|
104
|
+
unit_audio_end[_ui] = _cursor
|
|
105
|
+
total_audio_end = _cursor
|
|
106
|
+
|
|
107
|
+
# Title card
|
|
108
|
+
title_slide = slide_map.get("title")
|
|
109
|
+
if title_slide:
|
|
110
|
+
result.append((title_slide, TITLE_CARD_DURATION))
|
|
111
|
+
|
|
112
|
+
# Per-unit slides
|
|
113
|
+
for unit_idx in sorted(beat_map.keys()):
|
|
114
|
+
beats = beat_map[unit_idx] # {"hook": t, "concept": t, "memory": t}
|
|
115
|
+
|
|
116
|
+
hook_t = beats.get("hook", 0.0)
|
|
117
|
+
concept_t = beats.get("concept", hook_t + MIN_SLIDE_DURATION)
|
|
118
|
+
memory_t = beats.get("memory", concept_t + MIN_SLIDE_DURATION)
|
|
119
|
+
|
|
120
|
+
# Use actual MP3 boundary (not inflated line offsets) so slides match audio
|
|
121
|
+
unit_end = unit_audio_end.get(unit_idx, total_audio_end)
|
|
122
|
+
|
|
123
|
+
raw_hook = concept_t - hook_t
|
|
124
|
+
hook_dur = _clamp(min(raw_hook, MAX_HOOK_DURATION))
|
|
125
|
+
concept_dur = _clamp(memory_t - concept_t + max(0.0, raw_hook - MAX_HOOK_DURATION))
|
|
126
|
+
memory_dur = _clamp(unit_end - memory_t)
|
|
127
|
+
|
|
128
|
+
hook_slide = slide_map.get(f"{unit_idx:02d}_hook")
|
|
129
|
+
concept_slide = slide_map.get(f"{unit_idx:02d}_concept")
|
|
130
|
+
memory_slide = slide_map.get(f"{unit_idx:02d}_memory")
|
|
131
|
+
|
|
132
|
+
if hook_slide:
|
|
133
|
+
result.append((hook_slide, hook_dur))
|
|
134
|
+
if concept_slide:
|
|
135
|
+
result.append((concept_slide, concept_dur))
|
|
136
|
+
if memory_slide:
|
|
137
|
+
result.append((memory_slide, memory_dur))
|
|
138
|
+
|
|
139
|
+
# Outro card
|
|
140
|
+
outro_slide = slide_map.get("outro")
|
|
141
|
+
if outro_slide:
|
|
142
|
+
result.append((outro_slide, OUTRO_CARD_DURATION))
|
|
143
|
+
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# Backward-compat alias — callers that used compute_slide_timings still work
|
|
148
|
+
compute_slide_timings = _compute_slide_timings_v2
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _clamp(duration: float) -> float:
|
|
155
|
+
return max(duration, MIN_SLIDE_DURATION)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _build_slide_map(slides: list[Path]) -> dict[str, Path]:
|
|
159
|
+
"""Map stem identifiers to slide paths."""
|
|
160
|
+
m: dict[str, Path] = {}
|
|
161
|
+
for s in slides:
|
|
162
|
+
stem = s.stem # e.g. "00_title", "01_hook", "99_outro"
|
|
163
|
+
if "_title" in stem:
|
|
164
|
+
m["title"] = s
|
|
165
|
+
elif "_outro" in stem:
|
|
166
|
+
m["outro"] = s
|
|
167
|
+
elif "_hook" in stem:
|
|
168
|
+
m[f"{stem[:2]}_hook"] = s
|
|
169
|
+
elif "_concept" in stem:
|
|
170
|
+
m[f"{stem[:2]}_concept"] = s
|
|
171
|
+
elif "_memory" in stem:
|
|
172
|
+
m[f"{stem[:2]}_memory"] = s
|
|
173
|
+
return m
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _build_beat_map(
|
|
177
|
+
script_lines: list[DialogueLine],
|
|
178
|
+
line_start_offsets: list[float],
|
|
179
|
+
visuals: list[VisualSpec],
|
|
180
|
+
unit_durations_s: list[float],
|
|
181
|
+
) -> dict[int, dict[str, float]]:
|
|
182
|
+
"""Return {unit_idx: {hook, concept, memory} in seconds}."""
|
|
183
|
+
unit_specs = {v.unit_index: v for v in visuals if v.slide_type == "unit"}
|
|
184
|
+
beats: dict[int, dict[str, float]] = {i: {} for i in unit_specs}
|
|
185
|
+
|
|
186
|
+
# Map unit number → (start offset into audio timeline)
|
|
187
|
+
unit_audio_starts: dict[int, float] = {}
|
|
188
|
+
cursor = 0.0
|
|
189
|
+
for ui, dur in enumerate(unit_durations_s, start=1):
|
|
190
|
+
unit_audio_starts[ui] = cursor
|
|
191
|
+
cursor += dur
|
|
192
|
+
|
|
193
|
+
for _i, (ln, offset) in enumerate(zip(script_lines, line_start_offsets, strict=False)):
|
|
194
|
+
u = ln.unit_number
|
|
195
|
+
if u not in beats:
|
|
196
|
+
continue
|
|
197
|
+
b = beats[u]
|
|
198
|
+
|
|
199
|
+
if "hook" not in b and ln.speaker == "ALEX":
|
|
200
|
+
b["hook"] = offset
|
|
201
|
+
|
|
202
|
+
if "concept" not in b and ln.speaker == "MAYA":
|
|
203
|
+
b["concept"] = offset
|
|
204
|
+
|
|
205
|
+
# Memory = last ALEX line of the unit
|
|
206
|
+
for i in range(len(script_lines) - 1, -1, -1):
|
|
207
|
+
ln, offset = script_lines[i], line_start_offsets[i]
|
|
208
|
+
u = ln.unit_number
|
|
209
|
+
if u in beats and "memory" not in beats[u] and ln.speaker == "ALEX":
|
|
210
|
+
beats[u]["memory"] = offset
|
|
211
|
+
|
|
212
|
+
# Fill missing beats with audio-based fallbacks
|
|
213
|
+
for u, b in beats.items():
|
|
214
|
+
if "hook" not in b:
|
|
215
|
+
b["hook"] = unit_audio_starts.get(u, 0.0)
|
|
216
|
+
if "concept" not in b:
|
|
217
|
+
b["concept"] = b["hook"] + MIN_SLIDE_DURATION
|
|
218
|
+
if "memory" not in b:
|
|
219
|
+
unit_dur = unit_durations_s[u - 1] if u - 1 < len(unit_durations_s) else 30.0
|
|
220
|
+
b["memory"] = b["hook"] + unit_dur * 0.8
|
|
221
|
+
|
|
222
|
+
return beats
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
import time as _time
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from jinja2 import Environment, FileSystemLoader
|
|
9
|
+
|
|
10
|
+
from tutor.models import SlideSegment, VisualSpec
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
TEMPLATE_DIR = Path(__file__).parent / "templates"
|
|
15
|
+
ASSET_DIR = Path(__file__).parent.parent / "assets" / "html"
|
|
16
|
+
_ENV = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)), autoescape=True)
|
|
17
|
+
|
|
18
|
+
_FALLBACK_VISUAL_TYPE = "key_insight"
|
|
19
|
+
_MIN_PNG_BYTES = 5_120 # 5 KB — any PNG smaller than this is a failed render
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _fallback_segment(seg: SlideSegment) -> SlideSegment:
|
|
23
|
+
"""Return a copy of seg reclassified as key_insight for render fallback."""
|
|
24
|
+
return replace(
|
|
25
|
+
seg,
|
|
26
|
+
visual_type=_FALLBACK_VISUAL_TYPE,
|
|
27
|
+
body=seg.body or f"[diagram: {seg.title}]",
|
|
28
|
+
mermaid=None,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def render_all_slides(
|
|
33
|
+
title_spec: VisualSpec,
|
|
34
|
+
outro_spec: VisualSpec,
|
|
35
|
+
segments_by_unit: dict[int, list[SlideSegment]],
|
|
36
|
+
output_dir: Path,
|
|
37
|
+
session_label: str,
|
|
38
|
+
) -> list[Path]:
|
|
39
|
+
"""
|
|
40
|
+
Render all slides in video order:
|
|
41
|
+
title_card, unit_1_segs..., unit_N_segs..., outro
|
|
42
|
+
|
|
43
|
+
Populates seg.png_path on every SlideSegment in segments_by_unit.
|
|
44
|
+
Returns ordered list of PNG paths for the beat timer.
|
|
45
|
+
One Playwright browser context is opened and reused for all slides.
|
|
46
|
+
"""
|
|
47
|
+
_prime_msvcp() # ensure MSVCP140.dll is on the DLL search path before playwright loads
|
|
48
|
+
from playwright.sync_api import sync_playwright # lazy: avoid DLL load at import time
|
|
49
|
+
|
|
50
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
paths: list[Path] = []
|
|
52
|
+
|
|
53
|
+
with sync_playwright() as pw:
|
|
54
|
+
browser = pw.chromium.launch()
|
|
55
|
+
page = browser.new_page()
|
|
56
|
+
page.set_viewport_size({"width": 1920, "height": 1080})
|
|
57
|
+
|
|
58
|
+
title_path = output_dir / "00_title.png"
|
|
59
|
+
_screenshot(
|
|
60
|
+
page,
|
|
61
|
+
_render_html("title_card", spec=title_spec),
|
|
62
|
+
title_path,
|
|
63
|
+
wait_mermaid=False,
|
|
64
|
+
wait_hljs=False,
|
|
65
|
+
)
|
|
66
|
+
paths.append(title_path)
|
|
67
|
+
|
|
68
|
+
for unit_num in sorted(segments_by_unit.keys()):
|
|
69
|
+
segs = segments_by_unit[unit_num]
|
|
70
|
+
total = len(segs)
|
|
71
|
+
for seg in segs:
|
|
72
|
+
filename = f"{unit_num:02d}_{seg.segment_index:02d}_{seg.visual_type}.png"
|
|
73
|
+
out = output_dir / filename
|
|
74
|
+
html = _render_html(
|
|
75
|
+
seg.visual_type,
|
|
76
|
+
seg=seg,
|
|
77
|
+
current_dot=seg.segment_index + 1,
|
|
78
|
+
total_dots=total,
|
|
79
|
+
asset_dir=ASSET_DIR.as_uri(),
|
|
80
|
+
)
|
|
81
|
+
try:
|
|
82
|
+
_screenshot(
|
|
83
|
+
page,
|
|
84
|
+
html,
|
|
85
|
+
out,
|
|
86
|
+
wait_mermaid=(seg.visual_type == "diagram"),
|
|
87
|
+
wait_hljs=(seg.code is not None),
|
|
88
|
+
)
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
log.warning(
|
|
91
|
+
"Slide render failed for %s (%s): %s — retrying as %s",
|
|
92
|
+
out.name,
|
|
93
|
+
seg.visual_type,
|
|
94
|
+
exc,
|
|
95
|
+
_FALLBACK_VISUAL_TYPE,
|
|
96
|
+
)
|
|
97
|
+
fallback_seg = _fallback_segment(seg)
|
|
98
|
+
fallback_html = _render_html(
|
|
99
|
+
fallback_seg.visual_type,
|
|
100
|
+
seg=fallback_seg,
|
|
101
|
+
current_dot=seg.segment_index + 1,
|
|
102
|
+
total_dots=total,
|
|
103
|
+
asset_dir=ASSET_DIR.as_uri(),
|
|
104
|
+
)
|
|
105
|
+
_screenshot(
|
|
106
|
+
page,
|
|
107
|
+
fallback_html,
|
|
108
|
+
out,
|
|
109
|
+
wait_mermaid=False,
|
|
110
|
+
wait_hljs=False,
|
|
111
|
+
)
|
|
112
|
+
seg.visual_type = _FALLBACK_VISUAL_TYPE
|
|
113
|
+
|
|
114
|
+
seg.png_path = str(out)
|
|
115
|
+
paths.append(out)
|
|
116
|
+
|
|
117
|
+
outro_path = output_dir / "99_outro.png"
|
|
118
|
+
_screenshot(
|
|
119
|
+
page,
|
|
120
|
+
_render_html("outro", spec=outro_spec),
|
|
121
|
+
outro_path,
|
|
122
|
+
wait_mermaid=False,
|
|
123
|
+
wait_hljs=False,
|
|
124
|
+
)
|
|
125
|
+
paths.append(outro_path)
|
|
126
|
+
|
|
127
|
+
browser.close()
|
|
128
|
+
|
|
129
|
+
return paths
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _render_html(template_name: str, **context: object) -> str:
|
|
133
|
+
context["asset_dir"] = ASSET_DIR.as_uri()
|
|
134
|
+
return _ENV.get_template(f"{template_name}.html.j2").render(**context)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _screenshot(
|
|
138
|
+
page: object,
|
|
139
|
+
html: str,
|
|
140
|
+
output: Path,
|
|
141
|
+
wait_mermaid: bool,
|
|
142
|
+
wait_hljs: bool,
|
|
143
|
+
) -> None:
|
|
144
|
+
# Write to a temp file so the page gets a file:// origin and can load
|
|
145
|
+
# CSS/JS/font assets via file:// URLs (set_content() gives null origin,
|
|
146
|
+
# which Chromium blocks).
|
|
147
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8") as f:
|
|
148
|
+
f.write(html)
|
|
149
|
+
tmp_path = f.name
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
url = "file:///" + tmp_path.replace(os.sep, "/")
|
|
153
|
+
|
|
154
|
+
# Navigate with one retry for transient file:// timing issues.
|
|
155
|
+
for attempt in range(2):
|
|
156
|
+
try:
|
|
157
|
+
page.goto(url, wait_until="domcontentloaded") # type: ignore[union-attr]
|
|
158
|
+
break
|
|
159
|
+
except Exception:
|
|
160
|
+
if attempt == 0:
|
|
161
|
+
_time.sleep(0.2)
|
|
162
|
+
else:
|
|
163
|
+
raise
|
|
164
|
+
|
|
165
|
+
if wait_mermaid:
|
|
166
|
+
try:
|
|
167
|
+
page.wait_for_function( # type: ignore[union-attr]
|
|
168
|
+
"() => document.querySelector('.mermaid svg') !== null",
|
|
169
|
+
timeout=10_000,
|
|
170
|
+
)
|
|
171
|
+
except Exception:
|
|
172
|
+
log.warning(
|
|
173
|
+
"Mermaid diagram did not render within 10 s for %s — slide will use fallback",
|
|
174
|
+
output.name,
|
|
175
|
+
)
|
|
176
|
+
raise # re-raise so render_all_slides can apply fallback
|
|
177
|
+
|
|
178
|
+
if wait_hljs:
|
|
179
|
+
try:
|
|
180
|
+
page.wait_for_function( # type: ignore[union-attr]
|
|
181
|
+
"() => document.querySelector('pre code.hljs') !== null",
|
|
182
|
+
timeout=5_000,
|
|
183
|
+
)
|
|
184
|
+
except Exception:
|
|
185
|
+
log.warning(
|
|
186
|
+
"highlight.js did not render within 5 s for %s — "
|
|
187
|
+
"screenshot may show un-highlighted code",
|
|
188
|
+
output.name,
|
|
189
|
+
)
|
|
190
|
+
# Do not re-raise for hljs; un-highlighted code is acceptable.
|
|
191
|
+
|
|
192
|
+
page.screenshot(path=str(output), full_page=False) # type: ignore[union-attr]
|
|
193
|
+
|
|
194
|
+
# Validate the output is a real PNG.
|
|
195
|
+
if not output.exists() or output.stat().st_size < _MIN_PNG_BYTES:
|
|
196
|
+
raise RuntimeError(
|
|
197
|
+
f"Screenshot for {output.name} is missing or too small "
|
|
198
|
+
f"({output.stat().st_size if output.exists() else 0} bytes); "
|
|
199
|
+
f"expected ≥ {_MIN_PNG_BYTES} bytes"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
finally:
|
|
203
|
+
try:
|
|
204
|
+
os.unlink(tmp_path)
|
|
205
|
+
except OSError:
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _prime_msvcp() -> None:
|
|
210
|
+
"""Add a directory containing MSVCP140.dll to the DLL search path on Windows.
|
|
211
|
+
|
|
212
|
+
Python ships vcruntime140.dll but not msvcp140.dll; greenlet (used by
|
|
213
|
+
playwright's sync API) links against it. The DLL exists on the system
|
|
214
|
+
in non-standard locations so we find it dynamically.
|
|
215
|
+
"""
|
|
216
|
+
import sys
|
|
217
|
+
|
|
218
|
+
if sys.platform != "win32":
|
|
219
|
+
return
|
|
220
|
+
import glob
|
|
221
|
+
|
|
222
|
+
candidates = [
|
|
223
|
+
r"C:\Windows\System32\HealthAttestationClient",
|
|
224
|
+
r"C:\Windows\System32\Microsoft-Edge-WebView",
|
|
225
|
+
*glob.glob(r"C:\Windows\WinSxS\amd64_microsoft-edge-webview_*"),
|
|
226
|
+
]
|
|
227
|
+
for directory in candidates:
|
|
228
|
+
msvcp = os.path.join(directory, "MSVCP140.dll")
|
|
229
|
+
if os.path.exists(msvcp):
|
|
230
|
+
try:
|
|
231
|
+
os.add_dll_directory(directory)
|
|
232
|
+
log.debug("Added DLL directory for MSVCP140: %s", directory)
|
|
233
|
+
return
|
|
234
|
+
except OSError:
|
|
235
|
+
continue
|
|
236
|
+
log.debug("MSVCP140.dll not found in known locations — playwright may fail")
|