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