Skip to content

Commit 8471fc6

Browse files
committed
feat(lib): allow to insert external videos as slides
See #520
1 parent a2bd1ff commit 8471fc6

File tree

4 files changed

+94
-19
lines changed

4 files changed

+94
-19
lines 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: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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 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

@@ -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/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: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,49 @@ 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(src=config.slides[1].file, loop=True)
623+
self.wait(2)
624+
self.next_slide(src=config.slides[2].file)
625+
626+
init_slide(Bar).render()
627+
628+
slide_file = slides_folder / "Bar.json"
629+
630+
config = PresentationConfig.from_file(slide_file)
631+
632+
assert len(config.slides) == 6
633+
assert config.slides[-3].loop
634+
592635
def test_canvas(self) -> None:
593636
@assert_constructs
594637
class _(CESlide):

0 commit comments

Comments
 (0)