Skip to content

Commit ccbe9d5

Browse files
authored
feat(lib): allow to insert external videos as slides (#526)
* feat(lib): allow to insert external videos as slides See #520 * chore(lib): lint and changelog entry * chore: fix PR # fix * fix: docs
1 parent a2bd1ff commit ccbe9d5

File tree

6 files changed

+102
-22
lines changed

6 files changed

+102
-22
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515

1616
- Added `max_duration_before_split_reverse` and `num_processes` class variables.
1717
[#439](https://github.com/jeertmans/manim-slides/pull/439)
18+
- Added `src = ...` filepath argument to allow inserting external
19+
videos as slides.
20+
[#526](https://github.com/jeertmans/manim-slides/pull/526)
1821

1922
(unreleased-changed)=
2023
### Changed

manim_slides/config.py

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ class BaseSlideConfig(BaseModel): # type: ignore
161161
notes: str = ""
162162
dedent_notes: bool = True
163163
skip_animations: bool = False
164+
src: Optional[FilePath] = None
164165

165166
@classmethod
166167
def wrapper(cls, arg_name: str) -> Callable[..., Any]:
@@ -205,14 +206,13 @@ def __wrapper__(*args: Any, **kwargs: Any) -> Any: # noqa: N807
205206
return _wrapper_
206207

207208
@model_validator(mode="after")
208-
@classmethod
209209
def apply_dedent_notes(
210-
cls, base_slide_config: "BaseSlideConfig"
210+
self,
211211
) -> "BaseSlideConfig":
212-
if base_slide_config.dedent_notes:
213-
base_slide_config.notes = dedent(base_slide_config.notes)
212+
if self.dedent_notes:
213+
self.notes = dedent(self.notes)
214214

215-
return base_slide_config
215+
return self
216216

217217

218218
class PreSlideConfig(BaseSlideConfig):
@@ -242,25 +242,33 @@ def index_is_posint(cls, v: int) -> int:
242242
return v
243243

244244
@model_validator(mode="after")
245-
@classmethod
246245
def start_animation_is_before_end(
247-
cls, pre_slide_config: "PreSlideConfig"
246+
self,
248247
) -> "PreSlideConfig":
249-
if pre_slide_config.start_animation >= pre_slide_config.end_animation:
250-
if pre_slide_config.start_animation == pre_slide_config.end_animation == 0:
251-
raise ValueError(
252-
"You have to play at least one animation (e.g., `self.wait()`) "
253-
"before pausing. If you want to start paused, use the appropriate "
254-
"command-line option when presenting. "
255-
"IMPORTANT: when using ManimGL, `self.wait()` is not considered "
256-
"to be an animation, so prefer to directly use `self.play(...)`."
257-
)
258-
248+
if self.start_animation > self.end_animation:
259249
raise ValueError(
260250
"Start animation index must be strictly lower than end animation index"
261251
)
252+
return self
253+
254+
@model_validator(mode="after")
255+
def has_src_or_more_than_zero_animations(
256+
self,
257+
) -> "PreSlideConfig":
258+
if self.src is not None and self.start_animation != self.end_animation:
259+
raise ValueError(
260+
"A slide cannot have 'src=...' and more than zero animations at the same time."
261+
)
262+
elif self.src is None and self.start_animation == self.end_animation:
263+
raise ValueError(
264+
"You have to play at least one animation (e.g., 'self.wait()') "
265+
"before pausing. If you want to start paused, use the appropriate "
266+
"command-line option when presenting. "
267+
"IMPORTANT: when using ManimGL, 'self.wait()' is not considered "
268+
"to be an animation, so prefer to directly use 'self.play(...)'."
269+
)
262270

263-
return pre_slide_config
271+
return self
264272

265273
@property
266274
def slides_slice(self) -> slice:

manim_slides/slide/base.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ def next_slide(
305305
:param skip_animations:
306306
Exclude the next slide from the output.
307307
308-
If `manim` is used, this is also passed to `:meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`,
308+
If `manim` is used, this is also passed to :meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`,
309309
which will avoid rendering the corresponding animations.
310310
311311
.. seealso::
@@ -348,6 +348,11 @@ def next_slide(
348348
``manim-slides convert --to=pptx``.
349349
:param dedent_notes:
350350
If set, apply :func:`textwrap.dedent` to notes.
351+
:param pathlib.Path src:
352+
An optional path to a video file to include as next slide.
353+
354+
The video will be copied into the output folder, but no rescaling
355+
is applied.
351356
:param kwargs:
352357
Keyword arguments passed to
353358
:meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`,
@@ -471,6 +476,18 @@ def construct(self):
471476

472477
self._current_slide += 1
473478

479+
if base_slide_config.src is not None:
480+
self._slides.append(
481+
PreSlideConfig.from_base_slide_config_and_animation_indices(
482+
base_slide_config,
483+
self._current_animation,
484+
self._current_animation,
485+
)
486+
)
487+
488+
base_slide_config = BaseSlideConfig() # default
489+
self._current_slide += 1
490+
474491
if self._skip_animations:
475492
base_slide_config.skip_animations = True
476493

@@ -493,7 +510,7 @@ def _add_last_slide(self) -> None:
493510
)
494511
)
495512

496-
def _save_slides(
513+
def _save_slides( # noqa: C901
497514
self,
498515
use_cache: bool = True,
499516
flush_cache: bool = False,
@@ -540,7 +557,10 @@ def _save_slides(
540557
):
541558
if pre_slide_config.skip_animations:
542559
continue
543-
slide_files = files[pre_slide_config.slides_slice]
560+
if pre_slide_config.src:
561+
slide_files = [pre_slide_config.src]
562+
else:
563+
slide_files = files[pre_slide_config.slides_slice]
544564

545565
try:
546566
file = merge_basenames(slide_files)

manim_slides/slide/manim.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class Slide(BaseSlide, Scene): # type: ignore[misc]
1515
for slides rendering.
1616
1717
:param args: Positional arguments passed to scene object.
18-
:param output_folder: Where the slide animation files should be written.
18+
:param pathlib.Path output_folder: Where the slide animation files should be written.
1919
:param kwargs: Keyword arguments passed to scene object.
2020
:cvar bool disable_caching: :data:`False`: Whether to disable the use of
2121
cached animation files.

manim_slides/utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import hashlib
22
import os
3+
import shutil
34
import tempfile
45
from collections.abc import Iterator
56
from multiprocessing import Pool
@@ -14,6 +15,9 @@
1415

1516
def concatenate_video_files(files: list[Path], dest: Path) -> None:
1617
"""Concatenate multiple video files into one."""
18+
if len(files) == 1:
19+
shutil.copy(files[0], dest)
20+
return
1721

1822
def _filter(files: list[Path]) -> Iterator[Path]:
1923
"""Patch possibly empty video files."""

tests/test_slide.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,51 @@ def construct(self) -> None:
589589

590590
assert len(config.slides) == 1
591591

592+
def test_next_slide_include_video(self) -> None:
593+
class Foo(CESlide):
594+
def construct(self) -> None:
595+
circle = Circle(color=BLUE)
596+
self.play(GrowFromCenter(circle))
597+
self.next_slide()
598+
square = Square(color=BLUE)
599+
self.play(GrowFromCenter(square))
600+
self.next_slide()
601+
self.wait(2)
602+
603+
with tmp_cwd() as tmp_dir:
604+
init_slide(Foo).render()
605+
606+
slides_folder = Path(tmp_dir) / "slides"
607+
608+
assert slides_folder.exists()
609+
610+
slide_file = slides_folder / "Foo.json"
611+
612+
config = PresentationConfig.from_file(slide_file)
613+
614+
assert len(config.slides) == 3
615+
616+
class Bar(CESlide):
617+
def construct(self) -> None:
618+
self.next_slide(src=config.slides[0].file)
619+
self.wait(2)
620+
self.next_slide()
621+
self.wait(2)
622+
self.next_slide() # Dummy
623+
self.next_slide(src=config.slides[1].file, loop=True)
624+
self.next_slide() # Dummy
625+
self.wait(2)
626+
self.next_slide(src=config.slides[2].file)
627+
628+
init_slide(Bar).render()
629+
630+
slide_file = slides_folder / "Bar.json"
631+
632+
config = PresentationConfig.from_file(slide_file)
633+
634+
assert len(config.slides) == 6
635+
assert config.slides[-3].loop
636+
592637
def test_canvas(self) -> None:
593638
@assert_constructs
594639
class _(CESlide):

0 commit comments

Comments
 (0)