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,299 @@
1
+ """
2
+ ffmpeg wrappers for assembling slides + audio into MP4 videos.
3
+ No Pillow, no LLM, no audio processing here — only subprocess calls.
4
+ """
5
+
6
+ import logging
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ from tutor.exceptions import VideoError
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+ ENCODE_PRESET = "medium"
15
+ ENCODE_CRF = "23"
16
+ AUDIO_BITRATE = "128k"
17
+ TITLE_DURATION = "4"
18
+ OUTRO_DURATION = "6"
19
+ SCALE_FILTER = (
20
+ "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2"
21
+ )
22
+
23
+
24
+ def assemble_session(
25
+ session_dir: Path,
26
+ audio_dir: Path,
27
+ slide_timings: list[tuple[Path, float]],
28
+ unit_mp3s: list[Path],
29
+ srt_path: Path,
30
+ ) -> Path:
31
+ """
32
+ Full pipeline: renders per-unit MP4s then concatenates into full_session.mp4.
33
+ Returns path to full_session.mp4.
34
+ """
35
+ # Collect special slides
36
+ title_entry = next(((p, d) for p, d in slide_timings if "_title" in p.stem), None)
37
+ outro_entry = next(((p, d) for p, d in slide_timings if "_outro" in p.stem), None)
38
+
39
+ # Group remaining slides by unit
40
+ unit_entries: dict[int, list[tuple[Path, float]]] = {}
41
+ for p, d in slide_timings:
42
+ stem = p.stem
43
+ if "_title" in stem or "_outro" in stem:
44
+ continue
45
+ try:
46
+ unit_idx = int(stem[:2])
47
+ except ValueError:
48
+ continue
49
+ unit_entries.setdefault(unit_idx, []).append((p, d))
50
+
51
+ unit_mp4s: list[Path] = []
52
+ total_steps = 2 + len(unit_mp3s) + 2 # title, N units, concat, subtitles
53
+ step = 0
54
+
55
+ # Title card
56
+ step += 1
57
+ print(f" [{step}/{total_steps}] Generating title card video...")
58
+ if title_entry:
59
+ title_mp4 = _build_title_video(title_entry[0], session_dir / "unit_00_title.mp4")
60
+ unit_mp4s.append(title_mp4)
61
+
62
+ # Per-unit videos
63
+ for mp3_idx, mp3 in enumerate(unit_mp3s, start=1):
64
+ step += 1
65
+ entries = unit_entries.get(mp3_idx, [])
66
+ unit_name = mp3.stem
67
+ print(
68
+ f" [{step}/{total_steps}] Rendering unit {mp3_idx}/{len(unit_mp3s)} — {unit_name}..."
69
+ )
70
+ out = session_dir / f"unit_{mp3_idx:02d}.mp4"
71
+ _build_unit_video(entries, mp3, out)
72
+ unit_mp4s.append(out)
73
+
74
+ # Outro card
75
+ if outro_entry:
76
+ outro_mp4 = _build_outro_video(outro_entry[0], session_dir / "unit_99_outro.mp4")
77
+ unit_mp4s.append(outro_mp4)
78
+
79
+ # Concatenate
80
+ step += 1
81
+ print(f" [{step}/{total_steps}] Concatenating full session...")
82
+ nosub = session_dir / "full_session_nosub.mp4"
83
+ _concat_unit_videos(unit_mp4s, nosub)
84
+
85
+ # Embed subtitles (skip if SRT is empty)
86
+ step += 1
87
+ print(f" [{step}/{total_steps}] Embedding subtitles...")
88
+ final = session_dir / "full_session.mp4"
89
+ if srt_path.exists() and srt_path.stat().st_size > 20:
90
+ _embed_subtitles(nosub, srt_path, final)
91
+ else:
92
+ log.warning("SRT is empty — copying video without subtitle track")
93
+ nosub.rename(final)
94
+
95
+ size_mb = final.stat().st_size / 1_048_576
96
+ print(f" OK {final} ({size_mb:.0f} MB)")
97
+ return final
98
+
99
+
100
+ def _build_title_video(title_slide: Path, output: Path) -> Path:
101
+ _run_ffmpeg(
102
+ [
103
+ "ffmpeg",
104
+ "-y",
105
+ "-loop",
106
+ "1",
107
+ "-i",
108
+ str(title_slide),
109
+ "-t",
110
+ TITLE_DURATION,
111
+ "-f",
112
+ "lavfi",
113
+ "-i",
114
+ "anullsrc=channel_layout=stereo:sample_rate=44100",
115
+ "-c:v",
116
+ "libx264",
117
+ "-preset",
118
+ ENCODE_PRESET,
119
+ "-crf",
120
+ ENCODE_CRF,
121
+ "-c:a",
122
+ "aac",
123
+ "-b:a",
124
+ AUDIO_BITRATE,
125
+ "-pix_fmt",
126
+ "yuv420p",
127
+ "-vf",
128
+ SCALE_FILTER,
129
+ "-shortest",
130
+ str(output),
131
+ ]
132
+ )
133
+ return output
134
+
135
+
136
+ def _build_unit_video(
137
+ slides_with_dur: list[tuple[Path, float]],
138
+ mp3: Path,
139
+ output: Path,
140
+ ) -> Path:
141
+ if not slides_with_dur:
142
+ log.warning("No slides for %s — skipping", mp3.name)
143
+ return output
144
+
145
+ script_path = output.with_suffix(".concat.txt")
146
+ _write_concat_script(slides_with_dur, script_path)
147
+
148
+ _run_ffmpeg(
149
+ [
150
+ "ffmpeg",
151
+ "-y",
152
+ "-f",
153
+ "concat",
154
+ "-safe",
155
+ "0",
156
+ "-i",
157
+ str(script_path),
158
+ "-i",
159
+ str(mp3),
160
+ "-map",
161
+ "0:v:0",
162
+ "-map",
163
+ "1:a:0",
164
+ "-c:v",
165
+ "libx264",
166
+ "-preset",
167
+ ENCODE_PRESET,
168
+ "-crf",
169
+ ENCODE_CRF,
170
+ "-c:a",
171
+ "aac",
172
+ "-b:a",
173
+ AUDIO_BITRATE,
174
+ "-ar",
175
+ "44100",
176
+ "-ac",
177
+ "2",
178
+ "-af",
179
+ "volume=5dB",
180
+ "-pix_fmt",
181
+ "yuv420p",
182
+ "-vf",
183
+ SCALE_FILTER,
184
+ "-shortest",
185
+ str(output),
186
+ ]
187
+ )
188
+ return output
189
+
190
+
191
+ def _build_outro_video(outro_slide: Path, output: Path) -> Path:
192
+ _run_ffmpeg(
193
+ [
194
+ "ffmpeg",
195
+ "-y",
196
+ "-loop",
197
+ "1",
198
+ "-i",
199
+ str(outro_slide),
200
+ "-t",
201
+ OUTRO_DURATION,
202
+ "-f",
203
+ "lavfi",
204
+ "-i",
205
+ "anullsrc=channel_layout=stereo:sample_rate=44100",
206
+ "-c:v",
207
+ "libx264",
208
+ "-preset",
209
+ ENCODE_PRESET,
210
+ "-crf",
211
+ ENCODE_CRF,
212
+ "-c:a",
213
+ "aac",
214
+ "-b:a",
215
+ AUDIO_BITRATE,
216
+ "-pix_fmt",
217
+ "yuv420p",
218
+ "-vf",
219
+ SCALE_FILTER,
220
+ "-shortest",
221
+ str(output),
222
+ ]
223
+ )
224
+ return output
225
+
226
+
227
+ def _concat_unit_videos(unit_mp4s: list[Path], output: Path) -> Path:
228
+ list_path = output.parent / "unit_list.txt"
229
+ lines = ["ffconcat version 1.0"] + [f"file '{p.name}'" for p in unit_mp4s]
230
+ list_path.write_text("\n".join(lines), encoding="utf-8")
231
+
232
+ args = [
233
+ "ffmpeg",
234
+ "-y",
235
+ "-f",
236
+ "concat",
237
+ "-safe",
238
+ "0",
239
+ "-i",
240
+ str(list_path),
241
+ "-c:v",
242
+ "copy",
243
+ ]
244
+ args.extend(["-c:a", "aac"])
245
+ args.extend(
246
+ [
247
+ "-b:a",
248
+ AUDIO_BITRATE,
249
+ "-ar",
250
+ "44100",
251
+ "-ac",
252
+ "2",
253
+ str(output),
254
+ ]
255
+ )
256
+ _run_ffmpeg(args)
257
+ return output
258
+
259
+
260
+ def _embed_subtitles(video: Path, srt: Path, output: Path) -> Path:
261
+ _run_ffmpeg(
262
+ [
263
+ "ffmpeg",
264
+ "-y",
265
+ "-i",
266
+ str(video),
267
+ "-i",
268
+ str(srt),
269
+ "-c",
270
+ "copy",
271
+ "-c:s",
272
+ "mov_text",
273
+ "-metadata:s:s:0",
274
+ "language=eng",
275
+ str(output),
276
+ ]
277
+ )
278
+ return output
279
+
280
+
281
+ def _write_concat_script(entries: list[tuple[Path, float]], script_path: Path) -> None:
282
+ lines = ["ffconcat version 1.0"]
283
+ for path, dur in entries:
284
+ abs_path = str(path.resolve()).replace("\\", "/")
285
+ lines.append(f"file '{abs_path}'")
286
+ lines.append(f"duration {dur:.3f}")
287
+ # Repeat last file without duration (ffmpeg concat requirement)
288
+ if entries:
289
+ abs_last = str(entries[-1][0].resolve()).replace("\\", "/")
290
+ lines.append(f"file '{abs_last}'")
291
+ script_path.write_text("\n".join(lines), encoding="utf-8")
292
+
293
+
294
+ def _run_ffmpeg(args: list[str]) -> None:
295
+ log.debug("Running: %s", " ".join(args))
296
+ result = subprocess.run(args, capture_output=True, timeout=600)
297
+ if result.returncode != 0:
298
+ err = result.stderr.decode("utf-8", errors="replace")[-500:]
299
+ raise VideoError(f"ffmpeg failed (exit {result.returncode}):\n{err}")