From ccbe9d558cc14394869e1db4b23f2807b31b1f73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Eertmans?= Date: Wed, 29 Jan 2025 18:17:45 +0000 Subject: [PATCH] feat(lib): allow to insert external videos as slides (#526) * feat(lib): allow to insert external videos as slides See https://github.com/jeertmans/manim-slides/discussions/520 * chore(lib): lint and changelog entry * chore: fix PR # fix * fix: docs --- CHANGELOG.md | 3 +++ manim_slides/config.py | 44 +++++++++++++++++++++--------------- manim_slides/slide/base.py | 26 ++++++++++++++++++--- manim_slides/slide/manim.py | 2 +- manim_slides/utils.py | 4 ++++ tests/test_slide.py | 45 +++++++++++++++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eb70f2..89d354b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `max_duration_before_split_reverse` and `num_processes` class variables. [#439](https://github.com/jeertmans/manim-slides/pull/439) +- Added `src = ...` filepath argument to allow inserting external + videos as slides. + [#526](https://github.com/jeertmans/manim-slides/pull/526) (unreleased-changed)= ### Changed diff --git a/manim_slides/config.py b/manim_slides/config.py index 6f6a8d2..dfe889e 100644 --- a/manim_slides/config.py +++ b/manim_slides/config.py @@ -161,6 +161,7 @@ class BaseSlideConfig(BaseModel): # type: ignore notes: str = "" dedent_notes: bool = True skip_animations: bool = False + src: Optional[FilePath] = None @classmethod def wrapper(cls, arg_name: str) -> Callable[..., Any]: @@ -205,14 +206,13 @@ class BaseSlideConfig(BaseModel): # type: ignore return _wrapper_ @model_validator(mode="after") - @classmethod def apply_dedent_notes( - cls, base_slide_config: "BaseSlideConfig" + self, ) -> "BaseSlideConfig": - if base_slide_config.dedent_notes: - base_slide_config.notes = dedent(base_slide_config.notes) + if self.dedent_notes: + self.notes = dedent(self.notes) - return base_slide_config + return self class PreSlideConfig(BaseSlideConfig): @@ -242,25 +242,33 @@ class PreSlideConfig(BaseSlideConfig): return v @model_validator(mode="after") - @classmethod def start_animation_is_before_end( - cls, pre_slide_config: "PreSlideConfig" + self, ) -> "PreSlideConfig": - if pre_slide_config.start_animation >= pre_slide_config.end_animation: - if pre_slide_config.start_animation == pre_slide_config.end_animation == 0: - raise ValueError( - "You have to play at least one animation (e.g., `self.wait()`) " - "before pausing. If you want to start paused, use the appropriate " - "command-line option when presenting. " - "IMPORTANT: when using ManimGL, `self.wait()` is not considered " - "to be an animation, so prefer to directly use `self.play(...)`." - ) - + if self.start_animation > self.end_animation: raise ValueError( "Start animation index must be strictly lower than end animation index" ) + return self - return pre_slide_config + @model_validator(mode="after") + def has_src_or_more_than_zero_animations( + self, + ) -> "PreSlideConfig": + if self.src is not None and self.start_animation != self.end_animation: + raise ValueError( + "A slide cannot have 'src=...' and more than zero animations at the same time." + ) + elif self.src is None and self.start_animation == self.end_animation: + raise ValueError( + "You have to play at least one animation (e.g., 'self.wait()') " + "before pausing. If you want to start paused, use the appropriate " + "command-line option when presenting. " + "IMPORTANT: when using ManimGL, 'self.wait()' is not considered " + "to be an animation, so prefer to directly use 'self.play(...)'." + ) + + return self @property def slides_slice(self) -> slice: diff --git a/manim_slides/slide/base.py b/manim_slides/slide/base.py index a276c6f..72383e3 100644 --- a/manim_slides/slide/base.py +++ b/manim_slides/slide/base.py @@ -305,7 +305,7 @@ class BaseSlide: :param skip_animations: Exclude the next slide from the output. - If `manim` is used, this is also passed to `:meth:`Scene.next_section`, + If `manim` is used, this is also passed to :meth:`Scene.next_section`, which will avoid rendering the corresponding animations. .. seealso:: @@ -348,6 +348,11 @@ class BaseSlide: ``manim-slides convert --to=pptx``. :param dedent_notes: If set, apply :func:`textwrap.dedent` to notes. + :param pathlib.Path src: + An optional path to a video file to include as next slide. + + The video will be copied into the output folder, but no rescaling + is applied. :param kwargs: Keyword arguments passed to :meth:`Scene.next_section`, @@ -471,6 +476,18 @@ class BaseSlide: self._current_slide += 1 + if base_slide_config.src is not None: + self._slides.append( + PreSlideConfig.from_base_slide_config_and_animation_indices( + base_slide_config, + self._current_animation, + self._current_animation, + ) + ) + + base_slide_config = BaseSlideConfig() # default + self._current_slide += 1 + if self._skip_animations: base_slide_config.skip_animations = True @@ -493,7 +510,7 @@ class BaseSlide: ) ) - def _save_slides( + def _save_slides( # noqa: C901 self, use_cache: bool = True, flush_cache: bool = False, @@ -540,7 +557,10 @@ class BaseSlide: ): if pre_slide_config.skip_animations: continue - slide_files = files[pre_slide_config.slides_slice] + if pre_slide_config.src: + slide_files = [pre_slide_config.src] + else: + slide_files = files[pre_slide_config.slides_slice] try: file = merge_basenames(slide_files) diff --git a/manim_slides/slide/manim.py b/manim_slides/slide/manim.py index fb8a7a2..a5287c9 100644 --- a/manim_slides/slide/manim.py +++ b/manim_slides/slide/manim.py @@ -15,7 +15,7 @@ class Slide(BaseSlide, Scene): # type: ignore[misc] for slides rendering. :param args: Positional arguments passed to scene object. - :param output_folder: Where the slide animation files should be written. + :param pathlib.Path output_folder: Where the slide animation files should be written. :param kwargs: Keyword arguments passed to scene object. :cvar bool disable_caching: :data:`False`: Whether to disable the use of cached animation files. diff --git a/manim_slides/utils.py b/manim_slides/utils.py index 0703bf1..1cdee63 100644 --- a/manim_slides/utils.py +++ b/manim_slides/utils.py @@ -1,5 +1,6 @@ import hashlib import os +import shutil import tempfile from collections.abc import Iterator from multiprocessing import Pool @@ -14,6 +15,9 @@ from .logger import logger def concatenate_video_files(files: list[Path], dest: Path) -> None: """Concatenate multiple video files into one.""" + if len(files) == 1: + shutil.copy(files[0], dest) + return def _filter(files: list[Path]) -> Iterator[Path]: """Patch possibly empty video files.""" diff --git a/tests/test_slide.py b/tests/test_slide.py index 5c03874..1724953 100644 --- a/tests/test_slide.py +++ b/tests/test_slide.py @@ -589,6 +589,51 @@ class TestSlide: assert len(config.slides) == 1 + def test_next_slide_include_video(self) -> None: + class Foo(CESlide): + def construct(self) -> None: + circle = Circle(color=BLUE) + self.play(GrowFromCenter(circle)) + self.next_slide() + square = Square(color=BLUE) + self.play(GrowFromCenter(square)) + self.next_slide() + self.wait(2) + + with tmp_cwd() as tmp_dir: + init_slide(Foo).render() + + slides_folder = Path(tmp_dir) / "slides" + + assert slides_folder.exists() + + slide_file = slides_folder / "Foo.json" + + config = PresentationConfig.from_file(slide_file) + + assert len(config.slides) == 3 + + class Bar(CESlide): + def construct(self) -> None: + self.next_slide(src=config.slides[0].file) + self.wait(2) + self.next_slide() + self.wait(2) + self.next_slide() # Dummy + self.next_slide(src=config.slides[1].file, loop=True) + self.next_slide() # Dummy + self.wait(2) + self.next_slide(src=config.slides[2].file) + + init_slide(Bar).render() + + slide_file = slides_folder / "Bar.json" + + config = PresentationConfig.from_file(slide_file) + + assert len(config.slides) == 6 + assert config.slides[-3].loop + def test_canvas(self) -> None: @assert_constructs class _(CESlide):