mirror of
https://github.com/jeertmans/manim-slides.git
synced 2025-06-18 02:38:08 +08:00
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:
@ -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
|
||||||
|
@ -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:
|
||||||
|
@ -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)
|
||||||
|
@ -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.
|
||||||
|
@ -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."""
|
||||||
|
@ -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):
|
||||||
|
Reference in New Issue
Block a user