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,298 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from jinja2 import TemplateNotFound
|
|
5
|
+
|
|
6
|
+
from tutor.models import SlideSegment, VisualSpec
|
|
7
|
+
from tutor.visual.slide_renderer import _render_html, render_all_slides
|
|
8
|
+
|
|
9
|
+
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _make_seg(
|
|
13
|
+
unit_index: int = 1,
|
|
14
|
+
segment_index: int = 0,
|
|
15
|
+
visual_type: str = "key_insight",
|
|
16
|
+
title: str = "Test Slide",
|
|
17
|
+
**kwargs: object,
|
|
18
|
+
) -> SlideSegment:
|
|
19
|
+
return SlideSegment(
|
|
20
|
+
unit_index=unit_index,
|
|
21
|
+
segment_index=segment_index,
|
|
22
|
+
lines_start=0,
|
|
23
|
+
lines_end=1,
|
|
24
|
+
visual_type=visual_type,
|
|
25
|
+
title=title,
|
|
26
|
+
body=kwargs.get("body", "Some body text"), # type: ignore[arg-type]
|
|
27
|
+
code=kwargs.get("code"), # type: ignore[arg-type]
|
|
28
|
+
language=kwargs.get("language"), # type: ignore[arg-type]
|
|
29
|
+
mermaid=kwargs.get("mermaid"), # type: ignore[arg-type]
|
|
30
|
+
left=kwargs.get("left"), # type: ignore[arg-type]
|
|
31
|
+
right=kwargs.get("right"), # type: ignore[arg-type]
|
|
32
|
+
rows=kwargs.get("rows"), # type: ignore[arg-type]
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _make_spec(title: str = "Test Tutorial", subtitle: str = "Subtitle") -> VisualSpec:
|
|
37
|
+
return VisualSpec(unit_index=0, slide_type="title_card", title=title, subtitle=subtitle)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _make_segments_by_unit() -> dict[int, list[SlideSegment]]:
|
|
41
|
+
return {
|
|
42
|
+
1: [
|
|
43
|
+
_make_seg(unit_index=1, segment_index=0, visual_type="hook_question"),
|
|
44
|
+
_make_seg(unit_index=1, segment_index=1, visual_type="definition"),
|
|
45
|
+
_make_seg(unit_index=1, segment_index=2, visual_type="key_insight"),
|
|
46
|
+
],
|
|
47
|
+
2: [
|
|
48
|
+
_make_seg(unit_index=2, segment_index=0, visual_type="hook_question"),
|
|
49
|
+
_make_seg(unit_index=2, segment_index=1, visual_type="memory_hook"),
|
|
50
|
+
_make_seg(unit_index=2, segment_index=2, visual_type="key_insight"),
|
|
51
|
+
],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── Non-browser tests ─────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_render_html_returns_string() -> None:
|
|
59
|
+
seg = _make_seg(visual_type="key_insight", body="Remember this!")
|
|
60
|
+
result = _render_html("key_insight", seg=seg, current_dot=1, total_dots=3)
|
|
61
|
+
assert isinstance(result, str)
|
|
62
|
+
assert len(result) > 0
|
|
63
|
+
assert "html" in result.lower()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_template_missing_raises_clearly() -> None:
|
|
67
|
+
seg = _make_seg()
|
|
68
|
+
with pytest.raises(TemplateNotFound):
|
|
69
|
+
_render_html("nonexistent_type", seg=seg)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ── Browser tests (slow) ──────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.slow
|
|
76
|
+
def test_render_all_slides_returns_correct_count(tmp_path: Path) -> None:
|
|
77
|
+
# 2 units × 3 segments + title + outro = 8 paths
|
|
78
|
+
title_spec = _make_spec("My Tutorial")
|
|
79
|
+
outro_spec = _make_spec("Thanks!", "See you next time")
|
|
80
|
+
segs = _make_segments_by_unit()
|
|
81
|
+
paths = render_all_slides(title_spec, outro_spec, segs, tmp_path / "slides", "test")
|
|
82
|
+
assert len(paths) == 8
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.slow
|
|
86
|
+
def test_title_is_first_path(tmp_path: Path) -> None:
|
|
87
|
+
title_spec = _make_spec()
|
|
88
|
+
outro_spec = _make_spec("Outro")
|
|
89
|
+
paths = render_all_slides(
|
|
90
|
+
title_spec, outro_spec, _make_segments_by_unit(), tmp_path / "slides", "test"
|
|
91
|
+
)
|
|
92
|
+
assert paths[0].name.startswith("00_title")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@pytest.mark.slow
|
|
96
|
+
def test_outro_is_last_path(tmp_path: Path) -> None:
|
|
97
|
+
title_spec = _make_spec()
|
|
98
|
+
outro_spec = _make_spec("Outro")
|
|
99
|
+
paths = render_all_slides(
|
|
100
|
+
title_spec, outro_spec, _make_segments_by_unit(), tmp_path / "slides", "test"
|
|
101
|
+
)
|
|
102
|
+
assert paths[-1].name.startswith("99_outro")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@pytest.mark.slow
|
|
106
|
+
def test_png_path_populated_on_segments(tmp_path: Path) -> None:
|
|
107
|
+
title_spec = _make_spec()
|
|
108
|
+
outro_spec = _make_spec("Outro")
|
|
109
|
+
segs_by_unit = _make_segments_by_unit()
|
|
110
|
+
render_all_slides(title_spec, outro_spec, segs_by_unit, tmp_path / "slides", "test")
|
|
111
|
+
for unit_segs in segs_by_unit.values():
|
|
112
|
+
for seg in unit_segs:
|
|
113
|
+
assert seg.png_path != ""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@pytest.mark.slow
|
|
117
|
+
def test_output_files_exist_on_disk(tmp_path: Path) -> None:
|
|
118
|
+
title_spec = _make_spec()
|
|
119
|
+
outro_spec = _make_spec("Outro")
|
|
120
|
+
paths = render_all_slides(
|
|
121
|
+
title_spec, outro_spec, _make_segments_by_unit(), tmp_path / "slides", "test"
|
|
122
|
+
)
|
|
123
|
+
for p in paths:
|
|
124
|
+
assert p.exists(), f"Missing: {p}"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@pytest.mark.slow
|
|
128
|
+
def test_image_dimensions_are_1920x1080(tmp_path: Path) -> None:
|
|
129
|
+
from PIL import Image # noqa: PLC0415
|
|
130
|
+
|
|
131
|
+
title_spec = _make_spec()
|
|
132
|
+
outro_spec = _make_spec("Outro")
|
|
133
|
+
paths = render_all_slides(
|
|
134
|
+
title_spec, outro_spec, _make_segments_by_unit(), tmp_path / "slides", "test"
|
|
135
|
+
)
|
|
136
|
+
for p in paths:
|
|
137
|
+
img = Image.open(p)
|
|
138
|
+
assert img.size == (1920, 1080), f"{p.name}: expected 1920×1080, got {img.size}"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_screenshot_uses_file_url_not_set_content(tmp_path: Path) -> None:
|
|
142
|
+
"""_screenshot must call page.goto with a file:// URL, never page.set_content."""
|
|
143
|
+
from unittest.mock import MagicMock, patch
|
|
144
|
+
|
|
145
|
+
from tutor.visual.slide_renderer import _screenshot
|
|
146
|
+
|
|
147
|
+
mock_page = MagicMock()
|
|
148
|
+
out = tmp_path / "out.png"
|
|
149
|
+
|
|
150
|
+
def fake_screenshot(**kwargs: object) -> None:
|
|
151
|
+
out.write_bytes(b"PNG" + b"\x00" * 6000)
|
|
152
|
+
|
|
153
|
+
mock_page.screenshot.side_effect = fake_screenshot
|
|
154
|
+
|
|
155
|
+
with (
|
|
156
|
+
patch("tutor.visual.slide_renderer.tempfile.NamedTemporaryFile") as mock_ntf,
|
|
157
|
+
patch("tutor.visual.slide_renderer.os.unlink"),
|
|
158
|
+
):
|
|
159
|
+
mock_file = MagicMock()
|
|
160
|
+
mock_file.__enter__ = lambda s: s
|
|
161
|
+
mock_file.__exit__ = MagicMock(return_value=False)
|
|
162
|
+
mock_file.name = str(tmp_path / "tmp.html")
|
|
163
|
+
mock_ntf.return_value = mock_file
|
|
164
|
+
_screenshot(mock_page, "<html></html>", out, False, False)
|
|
165
|
+
|
|
166
|
+
mock_page.goto.assert_called_once()
|
|
167
|
+
call_url = mock_page.goto.call_args[0][0]
|
|
168
|
+
assert call_url.startswith("file:///")
|
|
169
|
+
mock_page.set_content.assert_not_called()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_tmp_file_cleaned_up_after_screenshot(tmp_path: Path) -> None:
|
|
173
|
+
"""Temp HTML file must be deleted even if screenshot succeeds."""
|
|
174
|
+
import os
|
|
175
|
+
from unittest.mock import MagicMock
|
|
176
|
+
|
|
177
|
+
from tutor.visual.slide_renderer import _screenshot
|
|
178
|
+
|
|
179
|
+
recorded: list[str] = []
|
|
180
|
+
|
|
181
|
+
mock_page = MagicMock()
|
|
182
|
+
out = tmp_path / "out.png"
|
|
183
|
+
|
|
184
|
+
def fake_screenshot(**kwargs: object) -> None:
|
|
185
|
+
out.write_bytes(b"PNG" + b"\x00" * 6000)
|
|
186
|
+
|
|
187
|
+
mock_page.screenshot.side_effect = fake_screenshot
|
|
188
|
+
|
|
189
|
+
original_unlink = os.unlink
|
|
190
|
+
|
|
191
|
+
def tracking_unlink(path: str) -> None:
|
|
192
|
+
recorded.append(path)
|
|
193
|
+
try:
|
|
194
|
+
original_unlink(path)
|
|
195
|
+
except OSError:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
import tutor.visual.slide_renderer as sr
|
|
199
|
+
|
|
200
|
+
original = sr.os.unlink
|
|
201
|
+
sr.os.unlink = tracking_unlink # type: ignore[assignment]
|
|
202
|
+
try:
|
|
203
|
+
_screenshot(mock_page, "<html></html>", out, False, False)
|
|
204
|
+
finally:
|
|
205
|
+
sr.os.unlink = original # type: ignore[assignment]
|
|
206
|
+
|
|
207
|
+
assert len(recorded) == 1
|
|
208
|
+
assert recorded[0].endswith(".html")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ── Hardening tests (Day 4) ───────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_fallback_segment_reclassifies_to_key_insight() -> None:
|
|
215
|
+
from tutor.visual.slide_renderer import _fallback_segment
|
|
216
|
+
|
|
217
|
+
seg = _make_seg(visual_type="diagram", mermaid="classDiagram\n A <|-- B")
|
|
218
|
+
result = _fallback_segment(seg)
|
|
219
|
+
assert result.visual_type == "key_insight"
|
|
220
|
+
assert result.mermaid is None
|
|
221
|
+
assert result.body is not None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_fallback_segment_preserves_body_when_present() -> None:
|
|
225
|
+
from tutor.visual.slide_renderer import _fallback_segment
|
|
226
|
+
|
|
227
|
+
seg = _make_seg(body="Animal is a base class")
|
|
228
|
+
result = _fallback_segment(seg)
|
|
229
|
+
assert result.body == "Animal is a base class"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_fallback_segment_uses_title_when_body_is_none() -> None:
|
|
233
|
+
from tutor.visual.slide_renderer import _fallback_segment
|
|
234
|
+
|
|
235
|
+
seg = _make_seg(title="Class hierarchy", body=None)
|
|
236
|
+
result = _fallback_segment(seg)
|
|
237
|
+
assert "Class hierarchy" in result.body # type: ignore[operator]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_min_png_bytes_constant() -> None:
|
|
241
|
+
from tutor.visual.slide_renderer import _MIN_PNG_BYTES
|
|
242
|
+
|
|
243
|
+
assert _MIN_PNG_BYTES == 5_120
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_screenshot_raises_on_small_file(tmp_path: Path) -> None:
|
|
247
|
+
"""_screenshot raises RuntimeError when the output PNG is below the size threshold."""
|
|
248
|
+
from unittest.mock import MagicMock
|
|
249
|
+
|
|
250
|
+
from tutor.visual.slide_renderer import _screenshot
|
|
251
|
+
|
|
252
|
+
output = tmp_path / "test.png"
|
|
253
|
+
output.write_bytes(b"X") # 1 byte — below threshold
|
|
254
|
+
|
|
255
|
+
mock_page = MagicMock()
|
|
256
|
+
|
|
257
|
+
with pytest.raises(RuntimeError, match="too small"):
|
|
258
|
+
_screenshot(
|
|
259
|
+
mock_page, "<html><body>test</body></html>", output, wait_mermaid=False, wait_hljs=False
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_screenshot_goto_retries_once_on_failure(tmp_path: Path) -> None:
|
|
264
|
+
"""page.goto() is retried once before raising on persistent failure."""
|
|
265
|
+
from unittest.mock import MagicMock
|
|
266
|
+
|
|
267
|
+
from tutor.visual.slide_renderer import _screenshot
|
|
268
|
+
|
|
269
|
+
output = tmp_path / "out.png"
|
|
270
|
+
mock_page = MagicMock()
|
|
271
|
+
mock_page.goto.side_effect = [RuntimeError("connection reset"), None]
|
|
272
|
+
|
|
273
|
+
def fake_screenshot(**kwargs: object) -> None:
|
|
274
|
+
output.write_bytes(b"PNG" + b"\x00" * 6000)
|
|
275
|
+
|
|
276
|
+
mock_page.screenshot.side_effect = fake_screenshot
|
|
277
|
+
|
|
278
|
+
_screenshot(
|
|
279
|
+
mock_page, "<html><body>test</body></html>", output, wait_mermaid=False, wait_hljs=False
|
|
280
|
+
)
|
|
281
|
+
assert mock_page.goto.call_count == 2
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@pytest.mark.slow
|
|
285
|
+
def test_invalid_mermaid_does_not_crash(tmp_path: Path) -> None:
|
|
286
|
+
title_spec = _make_spec()
|
|
287
|
+
outro_spec = _make_spec("Outro")
|
|
288
|
+
bad_diagram = _make_seg(
|
|
289
|
+
unit_index=1,
|
|
290
|
+
segment_index=0,
|
|
291
|
+
visual_type="diagram",
|
|
292
|
+
mermaid="this is not valid mermaid @@##!!",
|
|
293
|
+
)
|
|
294
|
+
segs = {1: [bad_diagram]}
|
|
295
|
+
paths = render_all_slides(title_spec, outro_spec, segs, tmp_path / "slides", "test")
|
|
296
|
+
diagram_path = next((p for p in paths if "diagram" in p.name), None)
|
|
297
|
+
assert diagram_path is not None
|
|
298
|
+
assert diagram_path.exists()
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from tutor.models import DialogueLine
|
|
6
|
+
from tutor.visual.subtitle_writer import (
|
|
7
|
+
_format_timestamp,
|
|
8
|
+
_line_duration,
|
|
9
|
+
_wrap_subtitle,
|
|
10
|
+
build_srt,
|
|
11
|
+
get_line_start_offsets,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _line(speaker: str, text: str, unit: int = 1) -> DialogueLine:
|
|
16
|
+
return DialogueLine(speaker=speaker, text=text, unit_number=unit)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ── SRT format ───────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_srt_numbering_sequential():
|
|
23
|
+
lines = [
|
|
24
|
+
_line("ALEX", "Hello world", 1),
|
|
25
|
+
_line("MAYA", "Great point", 1),
|
|
26
|
+
_line("ALEX", "Indeed", 1),
|
|
27
|
+
]
|
|
28
|
+
srt = build_srt(lines, [30.0])
|
|
29
|
+
numbers = re.findall(r"^(\d+)$", srt, re.MULTILINE)
|
|
30
|
+
assert numbers == ["1", "2", "3"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_timestamp_format_correct():
|
|
34
|
+
result = _format_timestamp(83.456)
|
|
35
|
+
assert result == "00:01:23,456"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_timestamp_zero():
|
|
39
|
+
assert _format_timestamp(0.0) == "00:00:00,000"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_timestamp_hours():
|
|
43
|
+
assert _format_timestamp(3661.5) == "01:01:01,500"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── Subtitle wrapping ─────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_line_wrap_at_60_chars():
|
|
50
|
+
short = _wrap_subtitle("ALEX", "Short text.")
|
|
51
|
+
assert len(short) <= 60
|
|
52
|
+
assert "\n" not in short
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_line_wrap_long_text_splits():
|
|
56
|
+
long_text = (
|
|
57
|
+
"This is an extremely long piece of dialogue that definitely exceeds sixty characters"
|
|
58
|
+
)
|
|
59
|
+
result = _wrap_subtitle("MAYA", long_text)
|
|
60
|
+
for segment in result.split("\n"):
|
|
61
|
+
assert len(segment) <= 60
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_wrap_preserves_speaker():
|
|
65
|
+
result = _wrap_subtitle("SAM", "Hello world")
|
|
66
|
+
assert result.startswith("SAM: ")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── Unit duration scaling ─────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_unit_scaling_when_duration_mismatch():
|
|
73
|
+
lines = [
|
|
74
|
+
_line("ALEX", "One two three four five six seven eight nine ten", 1),
|
|
75
|
+
_line("MAYA", "One two three four five six seven eight nine ten", 1),
|
|
76
|
+
]
|
|
77
|
+
estimated = sum(_line_duration(ln.text) for ln in lines)
|
|
78
|
+
# Supply actual = 150% of estimated — should scale up
|
|
79
|
+
actual_s = estimated * 1.5
|
|
80
|
+
_srt = build_srt(lines, [actual_s])
|
|
81
|
+
offsets = get_line_start_offsets(lines, [actual_s])
|
|
82
|
+
# The second line's start should be later than without scaling
|
|
83
|
+
offsets_noscale = get_line_start_offsets(lines, [estimated])
|
|
84
|
+
assert offsets[1] > offsets_noscale[1]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_no_scaling_within_10_percent():
|
|
88
|
+
lines = [_line("ALEX", "Short line", 1)]
|
|
89
|
+
estimated = _line_duration(lines[0].text)
|
|
90
|
+
actual = estimated * 1.08 # 8% difference, below threshold
|
|
91
|
+
offsets_scaled = get_line_start_offsets(lines, [actual])
|
|
92
|
+
offsets_exact = get_line_start_offsets(lines, [estimated])
|
|
93
|
+
assert offsets_scaled[0] == offsets_exact[0]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ── Offset consistency ────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_get_line_start_offsets_matches_srt_timestamps():
|
|
100
|
+
lines = [
|
|
101
|
+
_line("ALEX", "First line here now", 1),
|
|
102
|
+
_line("MAYA", "Second line here now", 1),
|
|
103
|
+
]
|
|
104
|
+
unit_dur = [30.0]
|
|
105
|
+
offsets = get_line_start_offsets(lines, unit_dur)
|
|
106
|
+
srt = build_srt(lines, unit_dur)
|
|
107
|
+
|
|
108
|
+
# Extract first timestamp from SRT
|
|
109
|
+
match = re.search(r"(\d{2}:\d{2}:\d{2},\d{3}) -->", srt)
|
|
110
|
+
assert match
|
|
111
|
+
ts_str = match.group(1)
|
|
112
|
+
# First line always starts at 0.0
|
|
113
|
+
assert offsets[0] == 0.0
|
|
114
|
+
assert ts_str == "00:00:00,000"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── Exact timing tests ────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _make_timing_json(unit_num: int, entries: list[dict]) -> dict:
|
|
121
|
+
return {"version": 1, "units": {str(unit_num): entries}}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_exact_offsets_from_timing_json() -> None:
|
|
125
|
+
|
|
126
|
+
lines = [_line("ALEX", "First line", 1)]
|
|
127
|
+
timing_json = _make_timing_json(1, [{"line_index": 0, "start_ms": 2000, "end_ms": 4000}])
|
|
128
|
+
offsets = get_line_start_offsets(lines, [30.0], timing_json)
|
|
129
|
+
# unit_start[1] = 0 + 2000ms/1000 = 2.0 s
|
|
130
|
+
assert offsets[0] == pytest.approx(2.0, abs=0.01)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_fallback_when_timing_absent() -> None:
|
|
134
|
+
lines = [_line("ALEX", "Hello world", 1), _line("MAYA", "Great", 1)]
|
|
135
|
+
offsets_v2 = get_line_start_offsets(lines, [30.0])
|
|
136
|
+
offsets_v3 = get_line_start_offsets(lines, [30.0], None)
|
|
137
|
+
assert offsets_v2 == offsets_v3
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_inter_unit_silence_included() -> None:
|
|
141
|
+
from tutor.constants import SILENCE_UNIT_MS # noqa: PLC0415
|
|
142
|
+
|
|
143
|
+
unit1_dur = 20.0
|
|
144
|
+
lines = [_line("ALEX", "Unit one", 1), _line("ALEX", "Unit two", 2)]
|
|
145
|
+
timing_json = {
|
|
146
|
+
"version": 1,
|
|
147
|
+
"units": {
|
|
148
|
+
"1": [{"line_index": 0, "start_ms": 0, "end_ms": 1000}],
|
|
149
|
+
"2": [{"line_index": 0, "start_ms": 0, "end_ms": 1000}],
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
offsets = get_line_start_offsets(lines, [unit1_dur, 20.0], timing_json)
|
|
153
|
+
# unit_start[2] = unit1_dur + SILENCE_UNIT_MS/1000
|
|
154
|
+
expected = unit1_dur + SILENCE_UNIT_MS / 1000
|
|
155
|
+
assert offsets[1] == pytest.approx(expected, abs=0.01)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_intro_lines_fall_back_to_estimation() -> None:
|
|
159
|
+
# Lines with unit_number=0 are not in timing_json → WPM estimation
|
|
160
|
+
lines = [_line("ALEX", "Intro text here", 0), _line("ALEX", "Unit one content", 1)]
|
|
161
|
+
timing_json = _make_timing_json(1, [{"line_index": 0, "start_ms": 5000, "end_ms": 6000}])
|
|
162
|
+
offsets_exact = get_line_start_offsets(lines, [30.0], timing_json)
|
|
163
|
+
offsets_wpm = get_line_start_offsets(lines, [30.0], None)
|
|
164
|
+
# Intro line (unit 0) uses WPM: offset should equal the WPM result
|
|
165
|
+
assert offsets_exact[0] == pytest.approx(offsets_wpm[0], abs=0.01)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
import tutor.visual.video_assembler as va
|
|
6
|
+
from tutor.exceptions import VideoError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_concat_script_written_correctly(tmp_path):
|
|
10
|
+
entries = [
|
|
11
|
+
(tmp_path / "01_hook.png", 6.31),
|
|
12
|
+
(tmp_path / "01_concept.png", 38.44),
|
|
13
|
+
(tmp_path / "01_memory.png", 9.25),
|
|
14
|
+
]
|
|
15
|
+
script = tmp_path / "test.concat.txt"
|
|
16
|
+
va._write_concat_script(entries, script)
|
|
17
|
+
|
|
18
|
+
content = script.read_text(encoding="utf-8")
|
|
19
|
+
assert "ffconcat version 1.0" in content
|
|
20
|
+
assert "duration 6.310" in content
|
|
21
|
+
assert "duration 38.440" in content
|
|
22
|
+
# Last file appears twice (no duration on second appearance)
|
|
23
|
+
lines = content.strip().splitlines()
|
|
24
|
+
# Path is normalised to forward slashes in the concat script
|
|
25
|
+
expected_path = str(entries[-1][0].resolve()).replace("\\", "/")
|
|
26
|
+
assert lines[-1] == f"file '{expected_path}'"
|
|
27
|
+
assert "duration" not in lines[-1]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_ffmpeg_called_with_yuv420p(tmp_path, monkeypatch):
|
|
31
|
+
captured = []
|
|
32
|
+
|
|
33
|
+
def mock_run(args, **kwargs):
|
|
34
|
+
captured.append(args)
|
|
35
|
+
return subprocess.CompletedProcess(args, 0, b"", b"")
|
|
36
|
+
|
|
37
|
+
monkeypatch.setattr(subprocess, "run", mock_run)
|
|
38
|
+
|
|
39
|
+
slides_with_dur = [(tmp_path / "01_hook.png", 5.0)]
|
|
40
|
+
mp3 = tmp_path / "unit_01.mp3"
|
|
41
|
+
mp3.touch()
|
|
42
|
+
output = tmp_path / "unit_01.mp4"
|
|
43
|
+
|
|
44
|
+
va._build_unit_video(slides_with_dur, mp3, output)
|
|
45
|
+
|
|
46
|
+
assert captured, "subprocess.run was not called"
|
|
47
|
+
assert "-pix_fmt" in captured[0]
|
|
48
|
+
yuv_idx = captured[0].index("-pix_fmt")
|
|
49
|
+
assert captured[0][yuv_idx + 1] == "yuv420p"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_run_ffmpeg_raises_on_nonzero_exit(monkeypatch):
|
|
53
|
+
def failing_run(args, **kwargs):
|
|
54
|
+
return subprocess.CompletedProcess(args, 1, b"", b"some error")
|
|
55
|
+
|
|
56
|
+
monkeypatch.setattr(subprocess, "run", failing_run)
|
|
57
|
+
|
|
58
|
+
with pytest.raises(VideoError, match="ffmpeg failed"):
|
|
59
|
+
va._run_ffmpeg(["ffmpeg", "-version"])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_run_ffmpeg_no_error_on_success(monkeypatch):
|
|
63
|
+
monkeypatch.setattr(
|
|
64
|
+
subprocess, "run", lambda args, **kw: subprocess.CompletedProcess(args, 0, b"", b"")
|
|
65
|
+
)
|
|
66
|
+
va._run_ffmpeg(["ffmpeg", "-version"]) # should not raise
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_output_paths_in_video_dir(tmp_path):
|
|
70
|
+
audio_dir = tmp_path / "audio" / "week2_3" / "tutorial_units"
|
|
71
|
+
audio_dir.mkdir(parents=True)
|
|
72
|
+
video_dir = tmp_path / "video" / "week2_3"
|
|
73
|
+
video_dir.mkdir(parents=True)
|
|
74
|
+
|
|
75
|
+
# Verify _write_concat_script output is relative to video_dir, not audio_dir
|
|
76
|
+
entries = [(video_dir / "slides" / "01_hook.png", 5.0)]
|
|
77
|
+
script = video_dir / "unit_01.concat.txt"
|
|
78
|
+
va._write_concat_script(entries, script)
|
|
79
|
+
|
|
80
|
+
content = script.read_text()
|
|
81
|
+
# No reference to audio dir in concat script
|
|
82
|
+
assert str(audio_dir) not in content
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_concat_unit_videos_re_encodes_audio(tmp_path):
|
|
86
|
+
"""_concat_unit_videos must NOT use bare -c copy — audio must be re-encoded."""
|
|
87
|
+
import inspect
|
|
88
|
+
|
|
89
|
+
from tutor.visual.video_assembler import _concat_unit_videos
|
|
90
|
+
|
|
91
|
+
src = inspect.getsource(_concat_unit_videos)
|
|
92
|
+
assert '"-c", "copy"' not in src, (
|
|
93
|
+
"_concat_unit_videos must re-encode audio (use -c:v copy + -c:a aac), "
|
|
94
|
+
"not bare -c copy, to fix timestamp discontinuities after concat"
|
|
95
|
+
)
|
|
96
|
+
assert '"-c:a", "aac"' in src
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_concat_script_single_entry(tmp_path):
|
|
100
|
+
entries = [(tmp_path / "only_slide.png", 4.0)]
|
|
101
|
+
script = tmp_path / "single.concat.txt"
|
|
102
|
+
va._write_concat_script(entries, script)
|
|
103
|
+
|
|
104
|
+
content = script.read_text()
|
|
105
|
+
lines = [ln for ln in content.strip().splitlines() if ln.startswith("file")]
|
|
106
|
+
# Single entry repeated twice
|
|
107
|
+
assert len(lines) == 2
|
|
108
|
+
assert lines[0] == lines[1]
|