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,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))
|