Skip to content

Commit 6c52906

Browse files
authored
feat(lib): add auto_next option (#304)
* feat(lib): add `auto_next` option As suggested in #302, you can now automatically skip a slide. It works both with `present` and `convert --to=html`! Closes #302 * chore(ci): add trigger on push on main
1 parent 2853ed0 commit 6c52906

File tree

14 files changed

+149
-9
lines changed

14 files changed

+149
-9
lines changed

.github/workflows/tests.yml

+3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
on:
2+
push:
3+
branches:
4+
- main
25
pull_request:
36
workflow_dispatch:
47

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ In an effort to better document changes, this CHANGELOG document is now created.
4848
[#295](https://github.com/jeertmans/manim-slides/pull/295)
4949
- Added `--playback-rate` option to `manim-slides present` for testing purposes.
5050
[#300](https://github.com/jeertmans/manim-slides/pull/300)
51+
- Added `auto_next` option to `Slide`'s `next_slide` method to automatically
52+
play the next slide upon terminating. Supported by `present` and
53+
`convert --to=html` commands.
54+
[#304](https://github.com/jeertmans/manim-slides/pull/304)
5155

5256
(v5-changed)=
5357
### Changed

example.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -185,12 +185,11 @@ def construct(self):
185185

186186
self.play(Transform(step, step_5))
187187
self.play(Transform(code, code_step_5))
188-
self.next_slide()
188+
self.next_slide(auto_next=True)
189189

190190
self.play(Transform(step, step_6))
191191
self.play(Transform(code, code_step_6))
192192
self.play(code.animate.shift(UP), FadeIn(code_step_7), FadeIn(or_text))
193-
self.next_slide()
194193

195194
watch_text = Text("Watch result on next slides!").shift(2 * DOWN).scale(0.5)
196195

manim_slides/config.py

+23-1
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ class PreSlideConfig(BaseModel): # type: ignore
139139
start_animation: int
140140
end_animation: int
141141
loop: bool = False
142+
auto_next: bool = False
142143

143144
@field_validator("start_animation", "end_animation")
144145
@classmethod
@@ -164,6 +165,21 @@ def start_animation_is_before_end(
164165

165166
return pre_slide_config
166167

168+
@model_validator(mode="after")
169+
@classmethod
170+
def loop_and_auto_next_disallowed(
171+
cls, pre_slide_config: "PreSlideConfig"
172+
) -> "PreSlideConfig":
173+
if pre_slide_config.loop and pre_slide_config.auto_next:
174+
raise ValueError(
175+
"You cannot have both `loop=True` and `auto_next=True`, "
176+
"because a looping slide has no ending. "
177+
"This may be supported in the future if "
178+
"https://github.com/jeertmans/manim-slides/pull/299 gets merged."
179+
)
180+
181+
return pre_slide_config
182+
167183
@property
168184
def slides_slice(self) -> slice:
169185
return slice(self.start_animation, self.end_animation)
@@ -173,12 +189,18 @@ class SlideConfig(BaseModel): # type: ignore[misc]
173189
file: FilePath
174190
rev_file: FilePath
175191
loop: bool = False
192+
auto_next: bool = False
176193

177194
@classmethod
178195
def from_pre_slide_config_and_files(
179196
cls, pre_slide_config: PreSlideConfig, file: Path, rev_file: Path
180197
) -> "SlideConfig":
181-
return cls(file=file, rev_file=rev_file, loop=pre_slide_config.loop)
198+
return cls(
199+
file=file,
200+
rev_file=rev_file,
201+
loop=pre_slide_config.loop,
202+
auto_next=pre_slide_config.auto_next,
203+
)
182204

183205

184206
class PresentationConfig(BaseModel): # type: ignore[misc]

manim_slides/convert.py

+15-2
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ def validate_config_option(
6161
config[key] = value
6262
except ValueError:
6363
raise click.BadParameter(
64-
f"Configuration options `{c_option}` could not be parsed into a proper (key, value) pair. Please use an `=` sign to separate key from value."
64+
f"Configuration options `{c_option}` could not be parsed into "
65+
"a proper (key, value) pair. "
66+
"Please use an `=` sign to separate key from value."
6567
) from None
6668

6769
return config
@@ -75,6 +77,15 @@ def file_to_data_uri(file: Path) -> str:
7577
return f"data:{mime_type};base64,{b64}"
7678

7779

80+
def get_duration_ms(file: Path) -> float:
81+
"""Read a video and return its duration in milliseconds."""
82+
cap = cv2.VideoCapture(str(file))
83+
fps: int = cap.get(cv2.CAP_PROP_FPS)
84+
frame_count: int = cap.get(cv2.CAP_PROP_FRAME_COUNT)
85+
86+
return 1000 * frame_count / fps
87+
88+
7889
class Converter(BaseModel): # type: ignore
7990
presentation_configs: conlist(PresentationConfig, min_length=1) # type: ignore[valid-type]
8091
assets_dir: str = "{basename}_assets"
@@ -396,7 +407,9 @@ def convert_to(self, dest: Path) -> None:
396407
options["assets_dir"] = assets_dir
397408

398409
content = revealjs_template.render(
399-
file_to_data_uri=file_to_data_uri, **options
410+
file_to_data_uri=file_to_data_uri,
411+
get_duration_ms=get_duration_ms,
412+
**options,
400413
)
401414

402415
f.write(content)

manim_slides/present/player.py

+11
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,17 @@ def media_status_changed(status: QMediaPlayer.MediaStatus) -> None:
137137

138138
self.media_player.mediaStatusChanged.connect(media_status_changed)
139139

140+
else:
141+
142+
def media_status_changed(status: QMediaPlayer.MediaStatus) -> None:
143+
if (
144+
status == QMediaPlayer.EndOfMedia
145+
and self.current_slide_config.auto_next
146+
):
147+
self.load_next_slide()
148+
149+
self.media_player.mediaStatusChanged.connect(media_status_changed)
150+
140151
if self.current_slide_config.loop:
141152
self.media_player.setLoops(-1)
142153

manim_slides/slide/base.py

+32-2
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,9 @@ def play(self, *args: Any, **kwargs: Any) -> None:
252252
super().play(*args, **kwargs) # type: ignore[misc]
253253
self._current_animation += 1
254254

255-
def next_slide(self, *, loop: bool = False, **kwargs: Any) -> None:
255+
def next_slide(
256+
self, *, loop: bool = False, auto_next: bool = False, **kwargs: Any
257+
) -> None:
256258
"""
257259
Create a new slide with previous animations, and setup options
258260
for the next slide.
@@ -266,6 +268,12 @@ def next_slide(self, *, loop: bool = False, **kwargs: Any) -> None:
266268
or ignored if `manimlib` API is used.
267269
:param loop:
268270
If set, next slide will be looping.
271+
:param auto_next:
272+
If set, next slide will play immediately play the next slide
273+
upon terminating.
274+
275+
Note that this is only supported by ``manim-slides present``
276+
and ``manim-slides convert --to=html``.
269277
:param kwargs:
270278
Keyword arguments to be passed to
271279
:meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`,
@@ -328,6 +336,28 @@ def construct(self):
328336
self.next_slide()
329337
330338
self.play(FadeOut(dot))
339+
340+
The following contains one slide that triggers the next slide
341+
upon terminating.
342+
343+
.. manim-slides:: AutoNextExample
344+
345+
from manim import *
346+
from manim_slides import Slide
347+
348+
class AutoNextExample(Slide):
349+
def construct(self):
350+
square = Square(color=RED, side_length=2)
351+
352+
self.play(GrowFromCenter(square))
353+
354+
self.next_slide(auto_next=True)
355+
356+
self.play(Wiggle(square))
357+
358+
self.next_slide()
359+
360+
self.wipe(square)
331361
"""
332362
if self._current_animation > self._start_animation:
333363
if self.wait_time_between_slides > 0.0:
@@ -343,7 +373,7 @@ def construct(self):
343373

344374
self._current_slide += 1
345375

346-
self._pre_slide_config_kwargs = dict(loop=loop)
376+
self._pre_slide_config_kwargs = dict(loop=loop, auto_next=auto_next)
347377
self._start_animation = self._current_animation
348378

349379
def _add_last_slide(self) -> None:

manim_slides/slide/manim.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,11 @@ def next_section(self, *args: Any, **kwargs: Any) -> None:
7979
"""
8080
self.next_slide(*args, **kwargs)
8181

82-
def next_slide(self, *args: Any, loop: bool = False, **kwargs: Any) -> None:
82+
def next_slide(
83+
self, *args: Any, loop: bool = False, auto_next: bool = False, **kwargs: Any
84+
) -> None:
8385
Scene.next_section(self, *args, **kwargs)
84-
BaseSlide.next_slide(self, loop=loop)
86+
BaseSlide.next_slide(self, loop=loop, auto_next=auto_next)
8587

8688
def render(self, *args: Any, **kwargs: Any) -> None:
8789
"""MANIM render."""

manim_slides/templates/revealjs.html

+3
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@
3636
{%- endif -%}
3737
{% if slide_config.loop -%}
3838
data-background-video-loop
39+
{%- endif -%}
40+
{% if slide_config.auto_next -%}
41+
data-autoslide="{{ get_duration_ms(slide_config.file) }}"
3942
{%- endif -%}>
4043
</section>
4144
{%- endfor -%}

tests/conftest.py

+10
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ def manimgl_config(project_folder: Path) -> Iterator[Path]:
3838
yield (project_folder / "custom_config.yml").resolve(strict=True)
3939

4040

41+
@pytest.fixture(scope="session")
42+
def video_file(data_folder: Path) -> Iterator[Path]:
43+
yield (data_folder / "video.mp4").resolve(strict=True)
44+
45+
46+
@pytest.fixture(scope="session")
47+
def video_data_uri_file(data_folder: Path) -> Iterator[Path]:
48+
yield (data_folder / "video_data_uri.txt").resolve(strict=True)
49+
50+
4151
def random_path(
4252
length: int = 20,
4353
dirname: Path = Path("./media/videos/example"),

tests/data/video.mp4

29.8 KB
Binary file not shown.

tests/data/video_data_uri.txt

+1
Large diffs are not rendered by default.

tests/test_convert.py

+10
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,19 @@
3131
SlideNumber,
3232
Transition,
3333
TransitionSpeed,
34+
file_to_data_uri,
35+
get_duration_ms,
3436
)
3537

3638

39+
def test_get_duration_ms(video_file: Path) -> None:
40+
assert get_duration_ms(video_file) == 2000.0
41+
42+
43+
def test_file_to_data_uri(video_file: Path, video_data_uri_file: Path) -> None:
44+
assert file_to_data_uri(video_file) == video_data_uri_file.read_text().strip()
45+
46+
3747
@pytest.mark.parametrize(
3848
("enum_type",),
3949
[

tests/test_slide.py

+32
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
Text,
2222
)
2323
from manim.__main__ import main as manim_cli
24+
from pydantic import ValidationError
2425

2526
from manim_slides.config import PresentationConfig
2627
from manim_slides.defaults import FOLDER_PATH
@@ -165,6 +166,37 @@ def construct(self) -> None:
165166

166167
assert not self._pre_slide_config_kwargs["loop"]
167168

169+
@assert_constructs
170+
class TestAutoNext(Slide):
171+
def construct(self) -> None:
172+
text = Text("Some text")
173+
174+
self.add(text)
175+
176+
assert "auto_next" not in self._pre_slide_config_kwargs
177+
178+
self.next_slide(auto_next=True)
179+
self.play(text.animate.scale(2))
180+
181+
assert self._pre_slide_config_kwargs["auto_next"]
182+
183+
self.next_slide(auto_next=False)
184+
185+
assert not self._pre_slide_config_kwargs["auto_next"]
186+
187+
@assert_constructs
188+
class TestLoopAndAutoNextFails(Slide):
189+
def construct(self) -> None:
190+
text = Text("Some text")
191+
192+
self.add(text)
193+
194+
self.next_slide(loop=True, auto_next=True)
195+
self.play(text.animate.scale(2))
196+
197+
with pytest.raises(ValidationError):
198+
self.next_slide()
199+
168200
@assert_constructs
169201
class TestWipe(Slide):
170202
def construct(self) -> None:

0 commit comments

Comments
 (0)