mirror of
https://github.com/jeertmans/manim-slides.git
synced 2025-06-05 10:20:38 +08:00
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:
3
.github/workflows/tests.yml
vendored
3
.github/workflows/tests.yml
vendored
@ -1,4 +1,7 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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]
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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."""
|
||||
|
@ -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 -%}
|
||||
|
@ -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
BIN
tests/data/video.mp4
Normal file
Binary file not shown.
1
tests/data/video_data_uri.txt
Normal file
1
tests/data/video_data_uri.txt
Normal file
File diff suppressed because one or more lines are too long
@ -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",),
|
||||
[
|
||||
|
@ -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:
|
||||
|
Reference in New Issue
Block a user