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,321 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from tutor.models import DialogueLine, SlideSegment, VisualSpec
6
+ from tutor.visual.beat_timer import (
7
+ MIN_SLIDE_DURATION,
8
+ OUTRO_CARD_DURATION,
9
+ TITLE_CARD_DURATION,
10
+ _compute_slide_timings_v2,
11
+ compute_slide_timings,
12
+ compute_slide_timings_v3,
13
+ )
14
+
15
+
16
+ def _line(speaker: str, unit: int) -> DialogueLine:
17
+ return DialogueLine(speaker=speaker, text="Some text here", unit_number=unit)
18
+
19
+
20
+ def _unit_spec(idx: int) -> VisualSpec:
21
+ return VisualSpec(
22
+ unit_index=idx,
23
+ slide_type="unit",
24
+ concept=f"Concept {idx}",
25
+ memory_hook="remember this",
26
+ )
27
+
28
+
29
+ def _slides(n_units: int) -> list[Path]:
30
+ paths = [Path("slides/00_title.png")]
31
+ for i in range(1, n_units + 1):
32
+ paths += [
33
+ Path(f"slides/{i:02d}_hook.png"),
34
+ Path(f"slides/{i:02d}_concept.png"),
35
+ Path(f"slides/{i:02d}_memory.png"),
36
+ ]
37
+ paths.append(Path("slides/99_outro.png"))
38
+ return paths
39
+
40
+
41
+ def test_title_card_fixed_4_seconds():
42
+ script = [_line("ALEX", 1), _line("MAYA", 1)]
43
+ offsets = [0.0, 5.0]
44
+ visuals = [
45
+ VisualSpec(unit_index=0, slide_type="title_card"),
46
+ _unit_spec(1),
47
+ VisualSpec(unit_index=2, slide_type="outro"),
48
+ ]
49
+ slides = _slides(1)
50
+ timings = compute_slide_timings(slides, script, offsets, visuals, [30.0])
51
+
52
+ title_timing = next((d for p, d in timings if "_title" in p.stem), None)
53
+ assert title_timing == TITLE_CARD_DURATION
54
+
55
+
56
+ def test_outro_card_fixed_6_seconds():
57
+ script = [_line("ALEX", 1), _line("MAYA", 1)]
58
+ offsets = [0.0, 5.0]
59
+ visuals = [
60
+ VisualSpec(unit_index=0, slide_type="title_card"),
61
+ _unit_spec(1),
62
+ VisualSpec(unit_index=2, slide_type="outro"),
63
+ ]
64
+ slides = _slides(1)
65
+ timings = compute_slide_timings(slides, script, offsets, visuals, [30.0])
66
+
67
+ outro_timing = next((d for p, d in timings if "_outro" in p.stem), None)
68
+ assert outro_timing == OUTRO_CARD_DURATION
69
+
70
+
71
+ def test_hook_slide_assigned_to_first_alex_line():
72
+ script = [_line("ALEX", 1), _line("MAYA", 1), _line("ALEX", 1)]
73
+ offsets = [2.0, 10.0, 20.0]
74
+ visuals = [
75
+ VisualSpec(unit_index=0, slide_type="title_card"),
76
+ _unit_spec(1),
77
+ VisualSpec(unit_index=2, slide_type="outro"),
78
+ ]
79
+ slides = _slides(1)
80
+ timings = compute_slide_timings(slides, script, offsets, visuals, [30.0])
81
+
82
+ hook_dur = next((d for p, d in timings if "_hook" in p.stem), None)
83
+ # Hook starts at 2.0 (first ALEX), concept at 10.0 → duration = 8.0
84
+ assert hook_dur == pytest.approx(8.0, abs=0.01)
85
+
86
+
87
+ def test_concept_slide_assigned_to_first_maya_line():
88
+ script = [_line("ALEX", 1), _line("MAYA", 1), _line("ALEX", 1)]
89
+ offsets = [2.0, 10.0, 20.0]
90
+ visuals = [
91
+ VisualSpec(unit_index=0, slide_type="title_card"),
92
+ _unit_spec(1),
93
+ VisualSpec(unit_index=2, slide_type="outro"),
94
+ ]
95
+ slides = _slides(1)
96
+ timings = compute_slide_timings(slides, script, offsets, visuals, [30.0])
97
+
98
+ # concept starts at 10.0, memory starts at 20.0 (last ALEX) → dur = 10.0
99
+ concept_dur = next((d for p, d in timings if "_concept" in p.stem), None)
100
+ assert concept_dur == pytest.approx(10.0, abs=0.01)
101
+
102
+
103
+ def test_minimum_slide_duration_enforced():
104
+ # All lines packed into 1 second — every slide should be clamped to MIN
105
+ script = [_line("ALEX", 1), _line("MAYA", 1), _line("ALEX", 1)]
106
+ offsets = [0.0, 0.1, 0.2]
107
+ visuals = [
108
+ VisualSpec(unit_index=0, slide_type="title_card"),
109
+ _unit_spec(1),
110
+ VisualSpec(unit_index=2, slide_type="outro"),
111
+ ]
112
+ slides = _slides(1)
113
+ timings = compute_slide_timings(slides, script, offsets, visuals, [0.3])
114
+
115
+ for path, dur in timings:
116
+ if "_title" in path.stem or "_outro" in path.stem:
117
+ continue
118
+ assert dur >= MIN_SLIDE_DURATION
119
+
120
+
121
+ # ── v3 beat timer tests ───────────────────────────────────────────────────────
122
+
123
+
124
+ def _make_seg(
125
+ unit_index: int = 1,
126
+ segment_index: int = 0,
127
+ lines_start: int = 0,
128
+ lines_end: int = 1,
129
+ visual_type: str = "key_insight",
130
+ png_path: str = "slides/01_00_key_insight.png",
131
+ ) -> SlideSegment:
132
+ return SlideSegment(
133
+ unit_index=unit_index,
134
+ segment_index=segment_index,
135
+ lines_start=lines_start,
136
+ lines_end=lines_end,
137
+ visual_type=visual_type,
138
+ title="Test",
139
+ body=None,
140
+ code=None,
141
+ language=None,
142
+ mermaid=None,
143
+ left=None,
144
+ right=None,
145
+ rows=None,
146
+ png_path=png_path,
147
+ )
148
+
149
+
150
+ def _timing_json(unit_timings: dict) -> dict:
151
+ return {"version": 1, "units": unit_timings}
152
+
153
+
154
+ def test_exact_duration_from_timing_json() -> None:
155
+ seg = _make_seg(lines_start=0, lines_end=0)
156
+ unit_timing = [{"line_index": 0, "start_ms": 0, "end_ms": 3240}]
157
+ tj = _timing_json({"1": unit_timing})
158
+ timings = compute_slide_timings_v3(
159
+ Path("slides/00_title.png"),
160
+ Path("slides/99_outro.png"),
161
+ {1: [seg]},
162
+ tj,
163
+ [30.0],
164
+ )
165
+ # title, seg, outro
166
+ # raw = 3240ms, +1 line * SILENCE_TURN_MS(500ms) = 3740ms = 3.74 s
167
+ seg_dur = timings[1][1]
168
+ assert seg_dur == pytest.approx(3.74, abs=0.01)
169
+
170
+
171
+ def test_exact_duration_uses_lines_start_and_end() -> None:
172
+ seg = _make_seg(lines_start=2, lines_end=4)
173
+ unit_timing = [
174
+ {"line_index": 0, "start_ms": 0, "end_ms": 1000},
175
+ {"line_index": 1, "start_ms": 1000, "end_ms": 2000},
176
+ {"line_index": 2, "start_ms": 2000, "end_ms": 3000},
177
+ {"line_index": 3, "start_ms": 3000, "end_ms": 4000},
178
+ {"line_index": 4, "start_ms": 4000, "end_ms": 9000},
179
+ ]
180
+ tj = _timing_json({"1": unit_timing})
181
+ timings = compute_slide_timings_v3(
182
+ Path("slides/00_title.png"),
183
+ Path("slides/99_outro.png"),
184
+ {1: [seg]},
185
+ tj,
186
+ [30.0],
187
+ )
188
+ # raw = 9000-2000 = 7000ms, +1 trailing SILENCE_TURN_MS(500ms) = 7500ms = 7.5 s
189
+ assert timings[1][1] == pytest.approx(7.5, abs=0.01)
190
+
191
+
192
+ def test_proportional_fallback_when_timing_absent() -> None:
193
+ seg = _make_seg(lines_start=0, lines_end=4)
194
+ timings = compute_slide_timings_v3(
195
+ Path("slides/00_title.png"),
196
+ Path("slides/99_outro.png"),
197
+ {1: [seg]},
198
+ None,
199
+ [30.0],
200
+ )
201
+ # proportional: 5/5 lines of 30 s = 30.0 s
202
+ assert timings[1][1] == pytest.approx(30.0, abs=0.01)
203
+
204
+
205
+ def test_min_slide_duration_enforced() -> None:
206
+ # Very short segment (0.5 s) should be clamped to MIN_SLIDE_DURATION
207
+ seg = _make_seg(lines_start=0, lines_end=0)
208
+ unit_timing = [{"line_index": 0, "start_ms": 0, "end_ms": 500}]
209
+ tj = _timing_json({"1": unit_timing})
210
+ timings = compute_slide_timings_v3(
211
+ Path("slides/00_title.png"),
212
+ Path("slides/99_outro.png"),
213
+ {1: [seg]},
214
+ tj,
215
+ [30.0],
216
+ )
217
+ assert timings[1][1] >= MIN_SLIDE_DURATION
218
+
219
+
220
+ def test_title_duration_is_4_seconds() -> None:
221
+ title_path = Path("slides/00_title.png")
222
+ seg = _make_seg()
223
+ timings = compute_slide_timings_v3(
224
+ title_path, Path("slides/99_outro.png"), {1: [seg]}, None, [30.0]
225
+ )
226
+ assert timings[0] == (title_path, TITLE_CARD_DURATION)
227
+
228
+
229
+ def test_outro_duration_is_6_seconds() -> None:
230
+ outro_path = Path("slides/99_outro.png")
231
+ seg = _make_seg()
232
+ timings = compute_slide_timings_v3(
233
+ Path("slides/00_title.png"), outro_path, {1: [seg]}, None, [30.0]
234
+ )
235
+ assert timings[-1] == (outro_path, OUTRO_CARD_DURATION)
236
+
237
+
238
+ def test_all_segments_present_in_output() -> None:
239
+ segs_u1 = [_make_seg(unit_index=1, segment_index=i) for i in range(3)]
240
+ segs_u2 = [_make_seg(unit_index=2, segment_index=i) for i in range(2)]
241
+ timings = compute_slide_timings_v3(
242
+ Path("slides/00_title.png"),
243
+ Path("slides/99_outro.png"),
244
+ {1: segs_u1, 2: segs_u2},
245
+ None,
246
+ [30.0, 30.0],
247
+ )
248
+ # title + 3 + 2 + outro = 7
249
+ assert len(timings) == 7
250
+
251
+
252
+ def test_timing_gap_accounted_in_exact_duration() -> None:
253
+ from tutor.constants import SILENCE_TURN_MS
254
+ from tutor.visual.beat_timer import _exact_duration
255
+
256
+ seg = _make_seg(lines_start=0, lines_end=2) # 3 lines
257
+ unit_timing = [
258
+ {"line_index": 0, "start_ms": 0, "end_ms": 1000},
259
+ {"line_index": 1, "start_ms": 1500, "end_ms": 2500},
260
+ {"line_index": 2, "start_ms": 3000, "end_ms": 4000},
261
+ ]
262
+ raw_ms = 4000 - 0 # end_ms of last - start_ms of first
263
+ expected_s = (raw_ms + SILENCE_TURN_MS) / 1000.0
264
+ assert _exact_duration(seg, unit_timing) == pytest.approx(expected_s, abs=0.01)
265
+
266
+
267
+ def test_single_line_segment_includes_one_gap() -> None:
268
+ from tutor.constants import SILENCE_TURN_MS
269
+ from tutor.visual.beat_timer import MIN_SLIDE_DURATION, _exact_duration
270
+
271
+ seg = _make_seg(lines_start=1, lines_end=1) # 1 line
272
+ unit_timing = [
273
+ {"line_index": 0, "start_ms": 0, "end_ms": 800},
274
+ {"line_index": 1, "start_ms": 1300, "end_ms": 5500}, # long enough to exceed MIN
275
+ ]
276
+ raw_ms = 5500 - 1300
277
+ expected_s = max((raw_ms + 1 * SILENCE_TURN_MS) / 1000.0, MIN_SLIDE_DURATION)
278
+ assert _exact_duration(seg, unit_timing) == pytest.approx(expected_s, abs=0.01)
279
+
280
+
281
+ def test_exact_duration_single_line_adds_one_turn_silence() -> None:
282
+ """Single line: duration = (end - start) + 1 × SILENCE_TURN_MS."""
283
+ from tutor.constants import SILENCE_TURN_MS
284
+ from tutor.visual.beat_timer import _exact_duration
285
+
286
+ seg = _make_seg(lines_start=0, lines_end=0)
287
+ unit_timing = [{"start_ms": 0, "end_ms": 2000}]
288
+ dur = _exact_duration(seg, unit_timing)
289
+ expected_ms = (2000 - 0) + SILENCE_TURN_MS
290
+ assert abs(dur - expected_ms / 1000.0) < 0.001
291
+
292
+
293
+ def test_exact_duration_multi_line_still_adds_one_turn_silence() -> None:
294
+ """5-line segment: only 1 trailing silence added, not 5."""
295
+ from tutor.constants import SILENCE_TURN_MS
296
+ from tutor.visual.beat_timer import _exact_duration
297
+
298
+ seg = _make_seg(lines_start=0, lines_end=4)
299
+ unit_timing = [
300
+ {"start_ms": 0, "end_ms": 1000},
301
+ {"start_ms": 1500, "end_ms": 2500},
302
+ {"start_ms": 3000, "end_ms": 4000},
303
+ {"start_ms": 4500, "end_ms": 5500},
304
+ {"start_ms": 6000, "end_ms": 7000},
305
+ ]
306
+ dur = _exact_duration(seg, unit_timing)
307
+ expected_ms = (7000 - 0) + SILENCE_TURN_MS # raw span + 1 trailing silence
308
+ assert abs(dur - expected_ms / 1000.0) < 0.001
309
+
310
+
311
+ def test_v2_function_still_callable() -> None:
312
+ script = [_line("ALEX", 1), _line("MAYA", 1)]
313
+ offsets = [0.0, 5.0]
314
+ visuals = [
315
+ VisualSpec(unit_index=0, slide_type="title_card"),
316
+ _unit_spec(1),
317
+ VisualSpec(unit_index=2, slide_type="outro"),
318
+ ]
319
+ slides = _slides(1)
320
+ result = _compute_slide_timings_v2(slides, script, offsets, visuals, [30.0])
321
+ assert isinstance(result, list)
@@ -0,0 +1,178 @@
1
+ """
2
+ Integration tests for tutor/visual/__init__.py — run_visual_pipeline and helpers.
3
+ Heavy operations (LLM, Playwright, ffmpeg) are mocked.
4
+ """
5
+
6
+ import json
7
+ from pathlib import Path
8
+ from unittest.mock import MagicMock, patch
9
+
10
+ import pytest
11
+
12
+ from tutor.visual import _load_timing_json
13
+
14
+ # ── _load_timing_json tests ───────────────────────────────────────────────────
15
+
16
+
17
+ def test_load_timing_json_returns_none_for_absent_file(tmp_path: Path) -> None:
18
+ assert _load_timing_json(tmp_path) is None
19
+
20
+
21
+ def test_load_timing_json_returns_none_for_wrong_version(tmp_path: Path) -> None:
22
+ (tmp_path / "tutorial.timing.json").write_text(
23
+ json.dumps({"version": 2, "units": {}}), encoding="utf-8"
24
+ )
25
+ assert _load_timing_json(tmp_path) is None
26
+
27
+
28
+ def test_load_timing_json_returns_none_for_corrupt_json(tmp_path: Path) -> None:
29
+ (tmp_path / "tutorial.timing.json").write_text("not json {{{{", encoding="utf-8")
30
+ assert _load_timing_json(tmp_path) is None
31
+
32
+
33
+ # ── run_visual_pipeline tests ─────────────────────────────────────────────────
34
+
35
+
36
+ def _write_units_json(audio_dir: Path, n_units: int = 1) -> None:
37
+ units = []
38
+ for i in range(1, n_units + 1):
39
+ units.append(
40
+ {
41
+ "unit": i,
42
+ "concept": f"Concept {i}",
43
+ "lines": [
44
+ {"speaker": "ALEX", "text": "Hello", "unit_number": i},
45
+ {"speaker": "MAYA", "text": "Great", "unit_number": i},
46
+ ],
47
+ }
48
+ )
49
+ (audio_dir / "tutorial.units.json").write_text(json.dumps(units), encoding="utf-8")
50
+
51
+
52
+ def _make_mock_pipeline(tmp_path: Path, video_dir: Path) -> tuple:
53
+ """Return (mock_plan_visuals, mock_plan_segments, mock_render_all, mock_build_srt,
54
+ mock_compute_v3, mock_assemble) all configured with sensible return values."""
55
+ from tutor.models import SlideSegment, VisualSpec
56
+
57
+ title_spec = VisualSpec(unit_index=0, slide_type="title_card", title="Test")
58
+ outro_spec = VisualSpec(unit_index=99, slide_type="outro", title="Outro")
59
+
60
+ title_path = video_dir / "slides" / "00_title.png"
61
+ outro_path = video_dir / "slides" / "99_outro.png"
62
+ title_path.parent.mkdir(parents=True, exist_ok=True)
63
+ title_path.write_bytes(b"PNG")
64
+ outro_path.write_bytes(b"PNG")
65
+
66
+ seg = SlideSegment(
67
+ unit_index=1,
68
+ segment_index=0,
69
+ lines_start=0,
70
+ lines_end=1,
71
+ visual_type="key_insight",
72
+ title="T",
73
+ body=None,
74
+ code=None,
75
+ language=None,
76
+ mermaid=None,
77
+ left=None,
78
+ right=None,
79
+ rows=None,
80
+ png_path=str(video_dir / "slides" / "01_00_key_insight.png"),
81
+ )
82
+
83
+ mock_visuals = MagicMock(return_value=[title_spec, outro_spec])
84
+ mock_segments = MagicMock(return_value={1: [seg]})
85
+ mock_render = MagicMock(
86
+ return_value=[title_path, video_dir / "slides" / "01_00_key_insight.png", outro_path]
87
+ )
88
+ mock_srt = MagicMock(return_value="1\n00:00:00,000 --> 00:00:01,000\nALEX: Hello\n")
89
+ mock_compute = MagicMock(return_value=[(title_path, 4.0), (outro_path, 6.0)])
90
+ result_mp4 = video_dir / "full_session.mp4"
91
+ result_mp4.write_bytes(b"fake")
92
+ mock_assemble = MagicMock(return_value=result_mp4)
93
+
94
+ return mock_visuals, mock_segments, mock_render, mock_srt, mock_compute, mock_assemble
95
+
96
+
97
+ def test_run_visual_pipeline_six_steps_printed(
98
+ tmp_path: Path, capsys: pytest.CaptureFixture
99
+ ) -> None:
100
+ audio_dir = tmp_path / "audio" / "s1"
101
+ video_dir = tmp_path / "video" / "s1"
102
+ audio_dir.mkdir(parents=True)
103
+ video_dir.mkdir(parents=True)
104
+ (audio_dir / "tutorial_units").mkdir()
105
+ _write_units_json(audio_dir)
106
+
107
+ mv, ms, mr, msrt, mc, ma = _make_mock_pipeline(tmp_path, video_dir)
108
+
109
+ with (
110
+ patch("tutor.generation.visual_planner.plan_visuals", mv),
111
+ patch("tutor.generation.segment_planner.plan_segments", ms),
112
+ patch("tutor.visual.slide_renderer.render_all_slides", mr),
113
+ patch("tutor.visual.subtitle_writer.build_srt", msrt),
114
+ patch("tutor.visual.beat_timer.compute_slide_timings_v3", mc),
115
+ patch("tutor.visual.video_assembler.assemble_session", ma),
116
+ patch("tutor.visual._mp3_duration", return_value=30.0),
117
+ ):
118
+ from tutor.visual import run_visual_pipeline
119
+
120
+ run_visual_pipeline("s1", audio_dir, video_dir, MagicMock())
121
+
122
+ captured = capsys.readouterr()
123
+ for i in range(1, 7):
124
+ assert f"[{i}/6]" in captured.out
125
+
126
+
127
+ def test_run_visual_pipeline_no_timing_json(tmp_path: Path) -> None:
128
+ audio_dir = tmp_path / "audio" / "s1"
129
+ video_dir = tmp_path / "video" / "s1"
130
+ audio_dir.mkdir(parents=True)
131
+ video_dir.mkdir(parents=True)
132
+ (audio_dir / "tutorial_units").mkdir()
133
+ _write_units_json(audio_dir)
134
+ # Deliberately no tutorial.timing.json
135
+
136
+ mv, ms, mr, msrt, mc, ma = _make_mock_pipeline(tmp_path, video_dir)
137
+
138
+ with (
139
+ patch("tutor.generation.visual_planner.plan_visuals", mv),
140
+ patch("tutor.generation.segment_planner.plan_segments", ms),
141
+ patch("tutor.visual.slide_renderer.render_all_slides", mr),
142
+ patch("tutor.visual.subtitle_writer.build_srt", msrt),
143
+ patch("tutor.visual.beat_timer.compute_slide_timings_v3", mc),
144
+ patch("tutor.visual.video_assembler.assemble_session", ma),
145
+ patch("tutor.visual._mp3_duration", return_value=30.0),
146
+ ):
147
+ from tutor.visual import run_visual_pipeline
148
+
149
+ result = run_visual_pipeline("s1", audio_dir, video_dir, MagicMock())
150
+
151
+ assert result is not None
152
+
153
+
154
+ @pytest.mark.slow
155
+ def test_output_path_is_under_video_dir(tmp_path: Path) -> None:
156
+ audio_dir = tmp_path / "audio" / "s1"
157
+ video_dir = tmp_path / "video" / "s1"
158
+ audio_dir.mkdir(parents=True)
159
+ video_dir.mkdir(parents=True)
160
+ (audio_dir / "tutorial_units").mkdir()
161
+ _write_units_json(audio_dir)
162
+
163
+ mv, ms, mr, msrt, mc, ma = _make_mock_pipeline(tmp_path, video_dir)
164
+
165
+ with (
166
+ patch("tutor.generation.visual_planner.plan_visuals", mv),
167
+ patch("tutor.generation.segment_planner.plan_segments", ms),
168
+ patch("tutor.visual.slide_renderer.render_all_slides", mr),
169
+ patch("tutor.visual.subtitle_writer.build_srt", msrt),
170
+ patch("tutor.visual.beat_timer.compute_slide_timings_v3", mc),
171
+ patch("tutor.visual.video_assembler.assemble_session", ma),
172
+ patch("tutor.visual._mp3_duration", return_value=30.0),
173
+ ):
174
+ from tutor.visual import run_visual_pipeline
175
+
176
+ result = run_visual_pipeline("s1", audio_dir, video_dir, MagicMock())
177
+
178
+ assert str(result).startswith(str(video_dir))