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
This commit is contained in:
Jérome Eertmans
2025-01-29 18:17:45 +00:00
committed by GitHub
parent a2bd1ffb67
commit ccbe9d558c
6 changed files with 102 additions and 22 deletions

View File

@ -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. - Added `max_duration_before_split_reverse` and `num_processes` class variables.
[#439](https://github.com/jeertmans/manim-slides/pull/439) [#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)= (unreleased-changed)=
### Changed ### Changed

View File

@ -161,6 +161,7 @@ class BaseSlideConfig(BaseModel): # type: ignore
notes: str = "" notes: str = ""
dedent_notes: bool = True dedent_notes: bool = True
skip_animations: bool = False skip_animations: bool = False
src: Optional[FilePath] = None
@classmethod @classmethod
def wrapper(cls, arg_name: str) -> Callable[..., Any]: def wrapper(cls, arg_name: str) -> Callable[..., Any]:
@ -205,14 +206,13 @@ class BaseSlideConfig(BaseModel): # type: ignore
return _wrapper_ return _wrapper_
@model_validator(mode="after") @model_validator(mode="after")
@classmethod
def apply_dedent_notes( def apply_dedent_notes(
cls, base_slide_config: "BaseSlideConfig" self,
) -> "BaseSlideConfig": ) -> "BaseSlideConfig":
if base_slide_config.dedent_notes: if self.dedent_notes:
base_slide_config.notes = dedent(base_slide_config.notes) self.notes = dedent(self.notes)
return base_slide_config return self
class PreSlideConfig(BaseSlideConfig): class PreSlideConfig(BaseSlideConfig):
@ -242,25 +242,33 @@ class PreSlideConfig(BaseSlideConfig):
return v return v
@model_validator(mode="after") @model_validator(mode="after")
@classmethod
def start_animation_is_before_end( def start_animation_is_before_end(
cls, pre_slide_config: "PreSlideConfig" self,
) -> "PreSlideConfig": ) -> "PreSlideConfig":
if pre_slide_config.start_animation >= pre_slide_config.end_animation: if self.start_animation > self.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(...)`."
)
raise ValueError( raise ValueError(
"Start animation index must be strictly lower than end animation index" "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 @property
def slides_slice(self) -> slice: def slides_slice(self) -> slice:

View File

@ -305,7 +305,7 @@ class BaseSlide:
:param skip_animations: :param skip_animations:
Exclude the next slide from the output. Exclude the next slide from the output.
If `manim` is used, this is also passed to `:meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`, If `manim` is used, this is also passed to :meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`,
which will avoid rendering the corresponding animations. which will avoid rendering the corresponding animations.
.. seealso:: .. seealso::
@ -348,6 +348,11 @@ class BaseSlide:
``manim-slides convert --to=pptx``. ``manim-slides convert --to=pptx``.
:param dedent_notes: :param dedent_notes:
If set, apply :func:`textwrap.dedent` to 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: :param kwargs:
Keyword arguments passed to Keyword arguments passed to
:meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`, :meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`,
@ -471,6 +476,18 @@ class BaseSlide:
self._current_slide += 1 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: if self._skip_animations:
base_slide_config.skip_animations = True base_slide_config.skip_animations = True
@ -493,7 +510,7 @@ class BaseSlide:
) )
) )
def _save_slides( def _save_slides( # noqa: C901
self, self,
use_cache: bool = True, use_cache: bool = True,
flush_cache: bool = False, flush_cache: bool = False,
@ -540,7 +557,10 @@ class BaseSlide:
): ):
if pre_slide_config.skip_animations: if pre_slide_config.skip_animations:
continue 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: try:
file = merge_basenames(slide_files) file = merge_basenames(slide_files)

View File

@ -15,7 +15,7 @@ class Slide(BaseSlide, Scene): # type: ignore[misc]
for slides rendering. for slides rendering.
:param args: Positional arguments passed to scene object. :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. :param kwargs: Keyword arguments passed to scene object.
:cvar bool disable_caching: :data:`False`: Whether to disable the use of :cvar bool disable_caching: :data:`False`: Whether to disable the use of
cached animation files. cached animation files.

View File

@ -1,5 +1,6 @@
import hashlib import hashlib
import os import os
import shutil
import tempfile import tempfile
from collections.abc import Iterator from collections.abc import Iterator
from multiprocessing import Pool from multiprocessing import Pool
@ -14,6 +15,9 @@ from .logger import logger
def concatenate_video_files(files: list[Path], dest: Path) -> None: def concatenate_video_files(files: list[Path], dest: Path) -> None:
"""Concatenate multiple video files into one.""" """Concatenate multiple video files into one."""
if len(files) == 1:
shutil.copy(files[0], dest)
return
def _filter(files: list[Path]) -> Iterator[Path]: def _filter(files: list[Path]) -> Iterator[Path]:
"""Patch possibly empty video files.""" """Patch possibly empty video files."""

View File

@ -589,6 +589,51 @@ class TestSlide:
assert len(config.slides) == 1 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: def test_canvas(self) -> None:
@assert_constructs @assert_constructs
class _(CESlide): class _(CESlide):