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
This commit is contained in:
Jérome Eertmans
2023-10-30 10:12:59 +01:00
committed by GitHub
parent 2853ed08e1
commit 6c52906037
14 changed files with 149 additions and 9 deletions

View File

@ -1,4 +1,7 @@
on:
push:
branches:
- main
pull_request:
workflow_dispatch:

View File

@ -48,6 +48,10 @@ In an effort to better document changes, this CHANGELOG document is now created.
[#295](https://github.com/jeertmans/manim-slides/pull/295)
- Added `--playback-rate` option to `manim-slides present` for testing purposes.
[#300](https://github.com/jeertmans/manim-slides/pull/300)
- Added `auto_next` option to `Slide`'s `next_slide` method to automatically
play the next slide upon terminating. Supported by `present` and
`convert --to=html` commands.
[#304](https://github.com/jeertmans/manim-slides/pull/304)
(v5-changed)=
### Changed

View File

@ -185,12 +185,11 @@ class Example(Slide):
self.play(Transform(step, step_5))
self.play(Transform(code, code_step_5))
self.next_slide()
self.next_slide(auto_next=True)
self.play(Transform(step, step_6))
self.play(Transform(code, code_step_6))
self.play(code.animate.shift(UP), FadeIn(code_step_7), FadeIn(or_text))
self.next_slide()
watch_text = Text("Watch result on next slides!").shift(2 * DOWN).scale(0.5)

View File

@ -139,6 +139,7 @@ class PreSlideConfig(BaseModel): # type: ignore
start_animation: int
end_animation: int
loop: bool = False
auto_next: bool = False
@field_validator("start_animation", "end_animation")
@classmethod
@ -164,6 +165,21 @@ class PreSlideConfig(BaseModel): # type: ignore
return pre_slide_config
@model_validator(mode="after")
@classmethod
def loop_and_auto_next_disallowed(
cls, pre_slide_config: "PreSlideConfig"
) -> "PreSlideConfig":
if pre_slide_config.loop and pre_slide_config.auto_next:
raise ValueError(
"You cannot have both `loop=True` and `auto_next=True`, "
"because a looping slide has no ending. "
"This may be supported in the future if "
"https://github.com/jeertmans/manim-slides/pull/299 gets merged."
)
return pre_slide_config
@property
def slides_slice(self) -> slice:
return slice(self.start_animation, self.end_animation)
@ -173,12 +189,18 @@ class SlideConfig(BaseModel): # type: ignore[misc]
file: FilePath
rev_file: FilePath
loop: bool = False
auto_next: bool = False
@classmethod
def from_pre_slide_config_and_files(
cls, pre_slide_config: PreSlideConfig, file: Path, rev_file: Path
) -> "SlideConfig":
return cls(file=file, rev_file=rev_file, loop=pre_slide_config.loop)
return cls(
file=file,
rev_file=rev_file,
loop=pre_slide_config.loop,
auto_next=pre_slide_config.auto_next,
)
class PresentationConfig(BaseModel): # type: ignore[misc]

View File

@ -61,7 +61,9 @@ def validate_config_option(
config[key] = value
except ValueError:
raise click.BadParameter(
f"Configuration options `{c_option}` could not be parsed into a proper (key, value) pair. Please use an `=` sign to separate key from value."
f"Configuration options `{c_option}` could not be parsed into "
"a proper (key, value) pair. "
"Please use an `=` sign to separate key from value."
) from None
return config
@ -75,6 +77,15 @@ def file_to_data_uri(file: Path) -> str:
return f"data:{mime_type};base64,{b64}"
def get_duration_ms(file: Path) -> float:
"""Read a video and return its duration in milliseconds."""
cap = cv2.VideoCapture(str(file))
fps: int = cap.get(cv2.CAP_PROP_FPS)
frame_count: int = cap.get(cv2.CAP_PROP_FRAME_COUNT)
return 1000 * frame_count / fps
class Converter(BaseModel): # type: ignore
presentation_configs: conlist(PresentationConfig, min_length=1) # type: ignore[valid-type]
assets_dir: str = "{basename}_assets"
@ -396,7 +407,9 @@ class RevealJS(Converter):
options["assets_dir"] = assets_dir
content = revealjs_template.render(
file_to_data_uri=file_to_data_uri, **options
file_to_data_uri=file_to_data_uri,
get_duration_ms=get_duration_ms,
**options,
)
f.write(content)

View File

@ -137,6 +137,17 @@ class Player(QMainWindow): # type: ignore[misc]
self.media_player.mediaStatusChanged.connect(media_status_changed)
else:
def media_status_changed(status: QMediaPlayer.MediaStatus) -> None:
if (
status == QMediaPlayer.EndOfMedia
and self.current_slide_config.auto_next
):
self.load_next_slide()
self.media_player.mediaStatusChanged.connect(media_status_changed)
if self.current_slide_config.loop:
self.media_player.setLoops(-1)

View File

@ -252,7 +252,9 @@ class BaseSlide:
super().play(*args, **kwargs) # type: ignore[misc]
self._current_animation += 1
def next_slide(self, *, loop: bool = False, **kwargs: Any) -> None:
def next_slide(
self, *, loop: bool = False, auto_next: bool = False, **kwargs: Any
) -> None:
"""
Create a new slide with previous animations, and setup options
for the next slide.
@ -266,6 +268,12 @@ class BaseSlide:
or ignored if `manimlib` API is used.
:param loop:
If set, next slide will be looping.
:param auto_next:
If set, next slide will play immediately play the next slide
upon terminating.
Note that this is only supported by ``manim-slides present``
and ``manim-slides convert --to=html``.
:param kwargs:
Keyword arguments to be passed to
:meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`,
@ -328,6 +336,28 @@ class BaseSlide:
self.next_slide()
self.play(FadeOut(dot))
The following contains one slide that triggers the next slide
upon terminating.
.. manim-slides:: AutoNextExample
from manim import *
from manim_slides import Slide
class AutoNextExample(Slide):
def construct(self):
square = Square(color=RED, side_length=2)
self.play(GrowFromCenter(square))
self.next_slide(auto_next=True)
self.play(Wiggle(square))
self.next_slide()
self.wipe(square)
"""
if self._current_animation > self._start_animation:
if self.wait_time_between_slides > 0.0:
@ -343,7 +373,7 @@ class BaseSlide:
self._current_slide += 1
self._pre_slide_config_kwargs = dict(loop=loop)
self._pre_slide_config_kwargs = dict(loop=loop, auto_next=auto_next)
self._start_animation = self._current_animation
def _add_last_slide(self) -> None:

View File

@ -79,9 +79,11 @@ class Slide(BaseSlide, Scene): # type: ignore[misc]
"""
self.next_slide(*args, **kwargs)
def next_slide(self, *args: Any, loop: bool = False, **kwargs: Any) -> None:
def next_slide(
self, *args: Any, loop: bool = False, auto_next: bool = False, **kwargs: Any
) -> None:
Scene.next_section(self, *args, **kwargs)
BaseSlide.next_slide(self, loop=loop)
BaseSlide.next_slide(self, loop=loop, auto_next=auto_next)
def render(self, *args: Any, **kwargs: Any) -> None:
"""MANIM render."""

View File

@ -36,6 +36,9 @@
{%- endif -%}
{% if slide_config.loop -%}
data-background-video-loop
{%- endif -%}
{% if slide_config.auto_next -%}
data-autoslide="{{ get_duration_ms(slide_config.file) }}"
{%- endif -%}>
</section>
{%- endfor -%}

View File

@ -38,6 +38,16 @@ def manimgl_config(project_folder: Path) -> Iterator[Path]:
yield (project_folder / "custom_config.yml").resolve(strict=True)
@pytest.fixture(scope="session")
def video_file(data_folder: Path) -> Iterator[Path]:
yield (data_folder / "video.mp4").resolve(strict=True)
@pytest.fixture(scope="session")
def video_data_uri_file(data_folder: Path) -> Iterator[Path]:
yield (data_folder / "video_data_uri.txt").resolve(strict=True)
def random_path(
length: int = 20,
dirname: Path = Path("./media/videos/example"),

BIN
tests/data/video.mp4 Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -31,9 +31,19 @@ from manim_slides.convert import (
SlideNumber,
Transition,
TransitionSpeed,
file_to_data_uri,
get_duration_ms,
)
def test_get_duration_ms(video_file: Path) -> None:
assert get_duration_ms(video_file) == 2000.0
def test_file_to_data_uri(video_file: Path, video_data_uri_file: Path) -> None:
assert file_to_data_uri(video_file) == video_data_uri_file.read_text().strip()
@pytest.mark.parametrize(
("enum_type",),
[

View File

@ -21,6 +21,7 @@ from manim import (
Text,
)
from manim.__main__ import main as manim_cli
from pydantic import ValidationError
from manim_slides.config import PresentationConfig
from manim_slides.defaults import FOLDER_PATH
@ -165,6 +166,37 @@ class TestSlide:
assert not self._pre_slide_config_kwargs["loop"]
@assert_constructs
class TestAutoNext(Slide):
def construct(self) -> None:
text = Text("Some text")
self.add(text)
assert "auto_next" not in self._pre_slide_config_kwargs
self.next_slide(auto_next=True)
self.play(text.animate.scale(2))
assert self._pre_slide_config_kwargs["auto_next"]
self.next_slide(auto_next=False)
assert not self._pre_slide_config_kwargs["auto_next"]
@assert_constructs
class TestLoopAndAutoNextFails(Slide):
def construct(self) -> None:
text = Text("Some text")
self.add(text)
self.next_slide(loop=True, auto_next=True)
self.play(text.animate.scale(2))
with pytest.raises(ValidationError):
self.next_slide()
@assert_constructs
class TestWipe(Slide):
def construct(self) -> None: