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.
Files changed (131) hide show
  1. learnx_cli-0.3.0.dist-info/METADATA +240 -0
  2. learnx_cli-0.3.0.dist-info/RECORD +131 -0
  3. learnx_cli-0.3.0.dist-info/WHEEL +4 -0
  4. learnx_cli-0.3.0.dist-info/entry_points.txt +2 -0
  5. tutor/.env copy.example +4 -0
  6. tutor/__init__.py +0 -0
  7. tutor/__main__.py +4 -0
  8. tutor/assets/__init__.py +5 -0
  9. tutor/assets/html/fonts/Inter-Bold.woff2 +0 -0
  10. tutor/assets/html/fonts/Inter-Regular.woff2 +0 -0
  11. tutor/assets/html/fonts/Inter-SemiBold.woff2 +0 -0
  12. tutor/assets/html/fonts/JetBrainsMono-Regular.woff2 +0 -0
  13. tutor/assets/html/highlight-java.min.js +2 -0
  14. tutor/assets/html/highlight-javascript.min.js +2 -0
  15. tutor/assets/html/highlight-python.min.js +2 -0
  16. tutor/assets/html/highlight.min.js +17 -0
  17. tutor/assets/html/mermaid.min.js +31 -0
  18. tutor/assets/html/slide_base.css +464 -0
  19. tutor/assets/html/theme-learnx-dark.css +12 -0
  20. tutor/audio/__init__.py +0 -0
  21. tutor/audio/audio_builder.py +143 -0
  22. tutor/audio/sanitizer.py +9 -0
  23. tutor/audio/tts_renderer.py +54 -0
  24. tutor/cli/__init__.py +0 -0
  25. tutor/cli/commands.py +391 -0
  26. tutor/cli/logo.py +21 -0
  27. tutor/cli/playback_commands.py +239 -0
  28. tutor/cli/shell.py +91 -0
  29. tutor/cli/shell_context.py +18 -0
  30. tutor/cli/theme.py +39 -0
  31. tutor/cli/video_commands.py +123 -0
  32. tutor/config.py +122 -0
  33. tutor/conftest.py +5 -0
  34. tutor/constants.py +82 -0
  35. tutor/exceptions.py +26 -0
  36. tutor/generation/__init__.py +0 -0
  37. tutor/generation/assembler.py +81 -0
  38. tutor/generation/curriculum.py +97 -0
  39. tutor/generation/dialogue.py +172 -0
  40. tutor/generation/narrator.py +122 -0
  41. tutor/generation/segment_parser.py +223 -0
  42. tutor/generation/segment_planner.py +200 -0
  43. tutor/generation/visual_planner.py +205 -0
  44. tutor/infra/__init__.py +0 -0
  45. tutor/infra/llm.py +152 -0
  46. tutor/ingestion/__init__.py +0 -0
  47. tutor/ingestion/chunker.py +171 -0
  48. tutor/ingestion/doc_analyzer.py +41 -0
  49. tutor/ingestion/parse_content.py +19 -0
  50. tutor/ingestion/summarizer.py +51 -0
  51. tutor/inspector.py +117 -0
  52. tutor/llm_config.toml +58 -0
  53. tutor/models.py +147 -0
  54. tutor/player/__init__.py +0 -0
  55. tutor/player/input_handler.py +45 -0
  56. tutor/player/player.py +308 -0
  57. tutor/player/player_display.py +117 -0
  58. tutor/prompts/curriculum.txt +67 -0
  59. tutor/prompts/dialogue.txt +62 -0
  60. tutor/prompts/narrate.txt +34 -0
  61. tutor/prompts/qa.txt +17 -0
  62. tutor/prompts/summarize.txt +9 -0
  63. tutor/prompts/visual.txt +60 -0
  64. tutor/prompts/visual_v3.txt +91 -0
  65. tutor/qa/__init__.py +0 -0
  66. tutor/qa/qa.py +105 -0
  67. tutor/requirements-dev.txt +2 -0
  68. tutor/requirements.txt +12 -0
  69. tutor/sample_docs/headingless_large.md +1 -0
  70. tutor/sample_docs/headingless_test.md +1 -0
  71. tutor/sample_docs/java-basics.md +78 -0
  72. tutor/tests/__init__.py +0 -0
  73. tutor/tests/audio/__init__.py +0 -0
  74. tutor/tests/audio/test_audio_builder.py +106 -0
  75. tutor/tests/audio/test_sanitizer.py +41 -0
  76. tutor/tests/cli/__init__.py +0 -0
  77. tutor/tests/cli/test_commands.py +67 -0
  78. tutor/tests/cli/test_video_commands.py +190 -0
  79. tutor/tests/e2e/README.md +61 -0
  80. tutor/tests/e2e/__init__.py +0 -0
  81. tutor/tests/e2e/conftest.py +117 -0
  82. tutor/tests/e2e/fixtures/README.md +17 -0
  83. tutor/tests/e2e/fixtures/sample.md +13 -0
  84. tutor/tests/e2e/test_audio_quality.py +40 -0
  85. tutor/tests/e2e/test_av_sync.py +56 -0
  86. tutor/tests/e2e/test_pipeline_smoke.py +37 -0
  87. tutor/tests/e2e/test_slide_render.py +72 -0
  88. tutor/tests/e2e/test_video_streams.py +104 -0
  89. tutor/tests/generation/__init__.py +0 -0
  90. tutor/tests/generation/conftest.py +134 -0
  91. tutor/tests/generation/test_assembler.py +64 -0
  92. tutor/tests/generation/test_curriculum.py +107 -0
  93. tutor/tests/generation/test_narrator.py +165 -0
  94. tutor/tests/generation/test_segment_edge_cases.py +280 -0
  95. tutor/tests/generation/test_segment_planner.py +324 -0
  96. tutor/tests/generation/test_visual_planner.py +319 -0
  97. tutor/tests/ingestion/__init__.py +0 -0
  98. tutor/tests/ingestion/test_chunker.py +94 -0
  99. tutor/tests/ingestion/test_doc_analyzer.py +51 -0
  100. tutor/tests/player/__init__.py +0 -0
  101. tutor/tests/player/test_player_states.py +88 -0
  102. tutor/tests/test_assets.py +39 -0
  103. tutor/tests/test_models_visual.py +180 -0
  104. tutor/tests/visual/__init__.py +0 -0
  105. tutor/tests/visual/test_beat_timer.py +321 -0
  106. tutor/tests/visual/test_pipeline_integration.py +178 -0
  107. tutor/tests/visual/test_slide_renderer.py +298 -0
  108. tutor/tests/visual/test_subtitle_writer.py +165 -0
  109. tutor/tests/visual/test_video_assembler.py +108 -0
  110. tutor/tests/visual/test_visual_pipeline.py +270 -0
  111. tutor/tutor.py +365 -0
  112. tutor/visual/__init__.py +213 -0
  113. tutor/visual/beat_timer.py +222 -0
  114. tutor/visual/slide_renderer.py +236 -0
  115. tutor/visual/subtitle_writer.py +187 -0
  116. tutor/visual/templates/_base.html.j2 +40 -0
  117. tutor/visual/templates/analogy.html.j2 +21 -0
  118. tutor/visual/templates/callout.html.j2 +10 -0
  119. tutor/visual/templates/code_example.html.j2 +12 -0
  120. tutor/visual/templates/comparison.html.j2 +28 -0
  121. tutor/visual/templates/decision_guide.html.j2 +37 -0
  122. tutor/visual/templates/definition.html.j2 +13 -0
  123. tutor/visual/templates/diagram.html.j2 +11 -0
  124. tutor/visual/templates/hook_question.html.j2 +17 -0
  125. tutor/visual/templates/key_insight.html.j2 +9 -0
  126. tutor/visual/templates/memory_hook.html.j2 +7 -0
  127. tutor/visual/templates/outro.html.j2 +16 -0
  128. tutor/visual/templates/question_prompt.html.j2 +13 -0
  129. tutor/visual/templates/step_sequence.html.j2 +14 -0
  130. tutor/visual/templates/title_card.html.j2 +12 -0
  131. tutor/visual/video_assembler.py +299 -0
@@ -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")