chore(lib/cli): one video per slide (#242)

* chore(lib/cli): one video per slide

As titled, this PR changes how Manim Slides used to work by only storing one video file per slide.

Previously, a slide would store all animations that occur during the given slide. Up to now, the only "advantage" of this was that it would allow the user to know which animation is played.
But, at the cost of a very complex logic in present, just especially for reversed slides.

On top of top, all converter actually need to concatenate the animations from each slide into one, so it is now performed at rendering time.

To migrate from previous Manim Slides versions, the best is the render the slides again, using `manim render` or `manimgl render`.

Currently, it is not possible to start at a given animation anymore. However, if wanted, I may re-implement this, but this would require to change the config file again.

* fix(ci): trying to fix tests

* chore(test): renaming files

* chore(docs): remove old line from changelog

* fix(docs): typo

* fix(ci): manimgl and smarter comparison

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Jérome Eertmans
2023-08-20 19:40:39 +02:00
committed by GitHub
parent 7363281ff0
commit b321161717
30 changed files with 420 additions and 452 deletions

2
.gitignore vendored
View File

@ -17,7 +17,7 @@ videos/
.manim-slides.toml
slides/
!tests/slides/
!tests/data/slides/
slides_assets/

26
CHANGELOG.md Normal file
View File

@ -0,0 +1,26 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [v5 (Unreleased)](https://github.com/jeertmans/languagetool-rust/compare/v4.16.0...HEAD)
Prior to v5, there was no real CHANGELOG other than the GitHub releases,
with most of the content automatically generated by GitHub from merged
pull requests.
In an effort to better document changes, this CHANGELOG document is now created.
### Chore
- Automatically concatenate all animations from a slide into one.
This is a **breaking change** because the config file format is
different from the previous one. For migration help, see associated PR.
[#242](https://github.com/jeertmans/manim-slides/pull/242)
### Removed
- Removed `--start-at-animation-number` option from `manim-slides present`.
[#242](https://github.com/jeertmans/manim-slides/pull/242)

View File

@ -2,16 +2,14 @@
# type: ignore
import sys
if "manim" in sys.modules:
from manim import *
MANIMGL = False
elif "manimlib" in sys.modules:
if "manimlib" in sys.modules:
from manimlib import *
MANIMGL = True
else:
raise ImportError("This script must be run with either `manim` or `manimgl`")
from manim import *
MANIMGL = False
from manim_slides import Slide, ThreeDSlide

View File

@ -1,8 +1,5 @@
import hashlib
import json
import shutil
import subprocess
import tempfile
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple, Union
@ -19,30 +16,9 @@ from pydantic import (
from pydantic_extra_types.color import Color
from PySide6.QtCore import Qt
from .defaults import FFMPEG_BIN
from .logger import logger
def merge_basenames(files: List[FilePath]) -> Path:
"""
Merge multiple filenames by concatenating basenames.
"""
logger.info(f"Generating a new filename for animations: {files}")
dirname: Path = files[0].parent
ext = files[0].suffix
basenames = (file.stem for file in files)
basenames_str = ",".join(f"{len(b)}:{b}" for b in basenames)
# We use hashes to prevent too-long filenames, see issue #123:
# https://github.com/jeertmans/manim-slides/issues/123
basename = hashlib.sha256(basenames_str.encode()).hexdigest()
return dirname.joinpath(basename + ext)
class Key(BaseModel): # type: ignore
"""Represents a list of key codes, with optionally a name."""
@ -124,12 +100,10 @@ class SlideType(str, Enum):
last = "last"
class SlideConfig(BaseModel): # type: ignore
class PreSlideConfig(BaseModel): # type: ignore
type: SlideType
start_animation: int
end_animation: int
number: int
terminated: bool = Field(False, exclude=True)
@field_validator("start_animation", "end_animation")
@classmethod
@ -138,13 +112,6 @@ class SlideConfig(BaseModel): # type: ignore
raise ValueError("Animation index (start or end) cannot be negative")
return v
@field_validator("number")
@classmethod
def number_is_strictly_posint(cls, v: int) -> int:
if v <= 0:
raise ValueError("Slide number cannot be negative or zero")
return v
@model_validator(mode="before")
def start_animation_is_before_end(
cls, values: Dict[str, Union[SlideType, int, bool]]
@ -161,6 +128,23 @@ class SlideConfig(BaseModel): # type: ignore
return values
@property
def slides_slice(self) -> slice:
return slice(self.start_animation, self.end_animation)
class SlideConfig(BaseModel): # type: ignore
type: SlideType
file: FilePath
rev_file: FilePath
terminated: bool = Field(False, exclude=True)
@classmethod
def from_pre_slide_config_and_files(
cls, pre_slide_config: PreSlideConfig, file: Path, rev_file: Path
) -> "SlideConfig":
return cls(type=pre_slide_config.type, file=file, rev_file=rev_file)
def is_slide(self) -> bool:
return self.type == SlideType.slide
@ -170,14 +154,9 @@ class SlideConfig(BaseModel): # type: ignore
def is_last(self) -> bool:
return self.type == SlideType.last
@property
def slides_slice(self) -> slice:
return slice(self.start_animation, self.end_animation)
class PresentationConfig(BaseModel): # type: ignore
slides: List[SlideConfig] = Field(min_length=1)
files: List[FilePath]
resolution: Tuple[PositiveInt, PositiveInt] = (1920, 1080)
background_color: Color = "black"
@ -187,12 +166,15 @@ class PresentationConfig(BaseModel): # type: ignore
with open(path, "r") as f:
obj = json.load(f)
if files := obj.get("files", None):
# First parent is ../slides
# so we take the parent of this parent
parent = Path(path).parents[1]
for i in range(len(files)):
files[i] = parent / files[i]
slides = obj.setdefault("slides", [])
parent = path.parent.parent # Never fails, but parents[1] can fail
for slide in slides:
if file := slide.get("file", None):
slide["file"] = parent / file
if rev_file := slide.get("rev_file", None):
slide["rev_file"] = parent / rev_file
return cls.model_validate(obj) # type: ignore
@ -201,104 +183,25 @@ class PresentationConfig(BaseModel): # type: ignore
with open(path, "w") as f:
f.write(self.model_dump_json(indent=2))
@model_validator(mode="after")
def animation_indices_match_files(
cls, config: "PresentationConfig"
) -> "PresentationConfig":
files = config.files
slides = config.slides
n_files = len(files)
for slide in slides:
if slide.end_animation > n_files:
raise ValueError(
f"The following slide's contains animations not listed in files {files}: {slide}"
)
return config
def copy_to(self, dest: Path, use_cached: bool = True) -> "PresentationConfig":
def copy_to(self, folder: Path, use_cached: bool = True) -> "PresentationConfig":
"""
Copy the files to a given directory.
"""
n = len(self.files)
for i in range(n):
file = self.files[i]
dest_path = dest / self.files[i].name
self.files[i] = dest_path
if use_cached and dest_path.exists():
logger.debug(f"Skipping copy of {file}, using cached copy")
continue
logger.debug(f"Copying {file} to {dest_path}")
shutil.copy(file, dest_path)
for slide_config in self.slides:
file = slide_config.file
rev_file = slide_config.rev_file
return self
dest = folder / file.name
rev_dest = folder / rev_file.name
def concat_animations(
self, dest: Optional[Path] = None, use_cached: bool = True
) -> "PresentationConfig":
"""
Concatenate animations such that each slide contains one animation.
"""
slide_config.file = dest
slide_config.rev_file = rev_dest
dest_paths = []
if not use_cached or not dest.exists():
shutil.copy(file, dest)
for i, slide_config in enumerate(self.slides):
files = self.files[slide_config.slides_slice]
slide_config.start_animation = i
slide_config.end_animation = i + 1
if len(files) > 1:
dest_path = merge_basenames(files)
dest_paths.append(dest_path)
if use_cached and dest_path.exists():
logger.debug(f"Concatenated animations already exist for slide {i}")
continue
f = tempfile.NamedTemporaryFile(mode="w", delete=False)
f.writelines(f"file '{path.absolute()}'\n" for path in files)
f.close()
command: List[str] = [
str(FFMPEG_BIN),
"-f",
"concat",
"-safe",
"0",
"-i",
f.name,
"-c",
"copy",
str(dest_path),
"-y",
]
logger.debug(" ".join(command))
process = subprocess.Popen(
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
output, error = process.communicate()
if output:
logger.debug(output.decode())
if error:
logger.debug(error.decode())
if not dest_path.exists():
raise ValueError(
"could not properly concatenate animations, use `-v INFO` for more details"
)
else:
dest_paths.append(files[0])
self.files = dest_paths
if dest:
return self.copy_to(dest)
if not use_cached or not rev_dest.exists():
shutil.copy(rev_file, rev_dest)
return self

View File

@ -362,7 +362,7 @@ class RevealJS(Converter):
"""Generates a sequence of sections, one per slide, that will be included into the html template."""
for presentation_config in self.presentation_configs:
for slide_config in presentation_config.slides:
file = presentation_config.files[slide_config.start_animation]
file = slide_config.file
logger.debug(f"Writing video section with file {file}")
@ -399,9 +399,6 @@ class RevealJS(Converter):
"""Converts this configuration into a RevealJS HTML presentation, saved to DEST."""
if self.data_uri:
assets_dir = Path("") # Actually we won't care.
for presentation_config in self.presentation_configs:
presentation_config.concat_animations()
else:
dirname = dest.parent
basename = dest.stem
@ -417,7 +414,7 @@ class RevealJS(Converter):
full_assets_dir.mkdir(parents=True, exist_ok=True)
for presentation_config in self.presentation_configs:
presentation_config.concat_animations().copy_to(full_assets_dir)
presentation_config.copy_to(full_assets_dir)
with open(dest, "w") as f:
sections = "".join(self.get_sections_iter(assets_dir))
@ -470,15 +467,14 @@ class PDF(Converter):
images = []
for i, presentation_config in enumerate(self.presentation_configs):
presentation_config.concat_animations()
for slide_config in tqdm(
presentation_config.slides,
desc=f"Generating video slides for config {i + 1}",
leave=False,
):
file = presentation_config.files[slide_config.start_animation]
images.append(read_image_from_video_file(file, self.frame_index))
images.append(
read_image_from_video_file(slide_config.file, self.frame_index)
)
images[0].save(
dest,
@ -531,7 +527,7 @@ class PowerPoint(Converter):
return etree.ElementBase.xpath(el, query, namespaces=nsmap)
def save_first_image_from_video_file(file: Path) -> Optional[str]:
cap = cv2.VideoCapture(str(file))
cap = cv2.VideoCapture(file.as_posix())
ret, frame = cap.read()
if ret:
@ -543,13 +539,12 @@ class PowerPoint(Converter):
return None
for i, presentation_config in enumerate(self.presentation_configs):
presentation_config.concat_animations()
for slide_config in tqdm(
presentation_config.slides,
desc=f"Generating video slides for config {i + 1}",
leave=False,
):
file = presentation_config.files[slide_config.start_animation]
file = slide_config.file
mime_type = mimetypes.guess_type(file)[0]

View File

@ -88,14 +88,13 @@ class Presentation:
self.config = config
self.__current_slide_index: int = 0
self.current_animation: int = self.current_slide.start_animation
self.current_file: Path = Path("")
self.current_file: Path = self.current_slide.file
self.loaded_animation_cap: int = -1
self.loaded_slide_cap: int = -1
self.cap = None # cap = cv2.VideoCapture
self.reverse: bool = False
self.reversed_animation: int = -1
self.reversed_slide: int = -1
self.lastframe: Optional[np.ndarray] = None
@ -109,11 +108,6 @@ class Presentation:
"""Returns the list of slides."""
return self.config.slides
@property
def files(self) -> List[Path]:
"""Returns the list of animation files."""
return self.config.files
@property
def resolution(self) -> Tuple[int, int]:
"""Returns the resolution."""
@ -133,39 +127,12 @@ class Presentation:
if value is not None:
if -len(self) <= value < len(self):
self.__current_slide_index = value
self.current_animation = self.current_slide.start_animation
logger.debug(f"Set current slide index to {value}")
else:
logger.error(
f"Could not load slide number {value}, playing first slide instead."
)
def set_current_animation_and_update_slide_number(
self, value: Optional[int]
) -> None:
if value is not None:
n_files = len(self.files)
if -n_files <= value < n_files:
if value < 0:
value += n_files
for i, slide in enumerate(self.slides):
if value < slide.end_animation:
self.current_slide_index = i
self.current_animation = value
logger.debug(f"Playing animation {value}, at slide index {i}")
return
assert (
False
), f"An error occurred when setting the current animation to {value}, please create an issue on GitHub!"
else:
logger.error(
f"Could not load animation number {value}, playing first animation instead."
)
@property
def current_slide(self) -> SlideConfig:
"""Returns currently playing slide."""
@ -186,54 +153,47 @@ class Presentation:
if self.cap is not None:
self.cap.release()
self.loaded_animation_cap = -1
self.loaded_slide_cap = -1
def load_animation_cap(self, animation: int) -> None:
"""Loads video file of given animation."""
def load_slide_cap(self, slide: int) -> None:
"""Loads video file of given slide."""
# We must load a new VideoCapture file if:
if (self.loaded_animation_cap != animation) or (
self.reverse and self.reversed_animation != animation
if (self.loaded_slide_cap != slide) or (
self.reverse and self.reversed_slide != slide
): # cap already loaded
logger.debug(f"Loading new cap for animation #{animation}")
logger.debug(f"Loading new cap for slide #{slide}")
self.release_cap()
file: Path = self.files[animation]
if self.reverse:
file = file.parent / f"{file.stem}_reversed{file.suffix}"
self.reversed_animation = animation
file = self.current_slide.rev_file
self.reversed_slide = slide
else:
file = self.current_slide.file
self.current_file = file
self.cap = cv2.VideoCapture(str(file))
self.loaded_animation_cap = animation
self.cap = cv2.VideoCapture(file.as_posix())
self.loaded_slide_cap = slide
@property
def current_cap(self) -> cv2.VideoCapture:
"""Returns current VideoCapture object."""
self.load_animation_cap(self.current_animation)
self.load_slide_cap(self.current_slide_index)
return self.cap
def rewind_current_slide(self) -> None:
"""Rewinds current slide to first frame."""
logger.debug("Rewinding current slide")
self.current_slide.terminated = False
if self.reverse:
self.current_animation = self.current_slide.end_animation - 1
else:
self.current_animation = self.current_slide.start_animation
cap = self.current_cap
cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
def cancel_reverse(self) -> None:
"""Cancels any effet produced by a reversed slide."""
if self.reverse:
logger.debug("Cancelling effects from previous 'reverse' action'")
self.reverse = False
self.reversed_animation = -1
self.reversed_slide = -1
self.release_cap()
def reverse_current_slide(self) -> None:
@ -276,36 +236,20 @@ class Presentation:
def reset(self) -> None:
"""Rests current presentation."""
self.current_animation = 0
self.load_animation_cap(0)
self.load_slide_cap(0)
self.current_slide_index = 0
self.slides[-1].terminated = False
def load_last_slide(self) -> None:
"""Loads last slide."""
self.current_slide_index = len(self.slides) - 1
assert (
self.current_slide_index >= 0
), "Slides should be at list of a least one element"
self.current_animation = self.current_slide.start_animation
self.load_animation_cap(self.current_animation)
self.load_slide_cap(self.current_slide_index)
self.slides[-1].terminated = False
@property
def next_animation(self) -> int:
"""Returns the next animation."""
if self.reverse:
return self.current_animation - 1
else:
return self.current_animation + 1
@property
def is_last_animation(self) -> bool:
"""Returns True if current animation is the last one of current slide."""
if self.reverse:
return self.current_animation == self.current_slide.start_animation
else:
return self.next_animation == self.current_slide.end_animation
def is_last_slide(self) -> bool:
"""Returns True if current slide is the last one."""
return self.current_slide_index == len(self.slides) - 1
@property
def current_frame_number(self) -> int:
@ -331,25 +275,17 @@ class Presentation:
return self.lastframe, State.PLAYING
# Video was terminated
if self.is_last_animation:
if self.current_slide.is_loop():
if self.reverse:
state = State.WAIT
else:
self.current_animation = self.current_slide.start_animation
state = State.PLAYING
self.rewind_current_slide()
elif self.current_slide.is_last():
state = State.END
else:
if self.current_slide.is_loop():
if self.reverse:
state = State.WAIT
else:
state = State.PLAYING
self.rewind_current_slide()
elif self.current_slide.is_last():
state = State.END
else:
# Play next video!
self.current_animation = self.next_animation
self.load_animation_cap(self.current_animation)
# Reset video to position zero if it has been played before
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
state = State.WAIT
return self.lastframe, state
@ -372,7 +308,6 @@ class Display(QThread): # type: ignore
exit_after_last_slide: bool = False,
start_at_scene_number: Optional[int] = None,
start_at_slide_number: Optional[int] = None,
start_at_animation_number: Optional[int] = None,
) -> None:
super().__init__()
self.presentations = presentations
@ -388,9 +323,6 @@ class Display(QThread): # type: ignore
self.__current_presentation_index = 0
self.current_presentation_index = start_at_scene_number # type: ignore
self.current_presentation.current_slide_index = start_at_slide_number # type: ignore
self.current_presentation.set_current_animation_and_update_slide_number(
start_at_animation_number
)
self.run_flag = True
@ -535,9 +467,8 @@ class Display(QThread): # type: ignore
"""Shows updated information about presentations."""
self.change_info_signal.emit(
{
"animation": self.current_presentation.current_animation,
"state": self.state,
"slide_index": self.current_presentation.current_slide.number,
"slide_index": self.current_presentation.current_slide_index + 1,
"n_slides": len(self.current_presentation.slides),
"type": self.current_presentation.current_slide.type,
"scene_index": self.current_presentation_index + 1,
@ -612,13 +543,11 @@ class Info(QWidget): # type: ignore
self.setLayout(self.layout)
self.animationLabel = QLabel()
self.stateLabel = QLabel()
self.slideLabel = QLabel()
self.typeLabel = QLabel()
self.sceneLabel = QLabel()
self.layout.addWidget(self.animationLabel, 0, 0, 1, 2)
self.layout.addWidget(self.stateLabel, 1, 0)
self.layout.addWidget(self.slideLabel, 1, 1)
self.layout.addWidget(self.typeLabel, 2, 0)
@ -628,7 +557,6 @@ class Info(QWidget): # type: ignore
@Slot(dict)
def update_info(self, info: Dict[str, Union[str, int]]) -> None:
self.animationLabel.setText("Animation: {}".format(info.get("animation", "na")))
self.stateLabel.setText("State: {}".format(info.get("state", "unknown")))
self.slideLabel.setText(
"Slide: {}/{}".format(
@ -889,8 +817,8 @@ def get_scenes_presentation_config(
def start_at_callback(
ctx: Context, param: Parameter, values: str
) -> Tuple[Optional[int], ...]:
if values == "(None, None, None)":
return (None, None, None)
if values == "(None, None)":
return (None, None)
def str_to_int_or_none(value: str) -> Optional[int]:
if value.lower().strip() == "":
@ -907,11 +835,11 @@ def start_at_callback(
values_tuple = values.split(",")
n_values = len(values_tuple)
if n_values == 3:
if n_values == 2:
return tuple(map(str_to_int_or_none, values_tuple))
raise click.BadParameter(
f"exactly 3 arguments are expected but you gave {n_values}, please use commas to separate them",
f"exactly 2 arguments are expected but you gave {n_values}, please use commas to separate them",
ctx=ctx,
param=param,
)
@ -991,11 +919,11 @@ def start_at_callback(
"--sa",
"--start-at",
"start_at",
metavar="<SCENE,SLIDE,ANIMATION>",
metavar="<SCENE,SLIDE>",
type=str,
callback=start_at_callback,
default=(None, None, None),
help="Start presenting at (x, y, z), equivalent to --sacn x --sasn y --saan z, and overrides values if not None.",
default=(None, None),
help="Start presenting at (x, y), equivalent to --sacn x --sasn y, and overrides values if not None.",
)
@click.option(
"--sacn",
@ -1015,15 +943,6 @@ def start_at_callback(
default=None,
help="Start presenting at a given slide number (0 is first, -1 is last).",
)
@click.option(
"--saan",
"--start-at-animation-number",
"start_at_animation_number",
metavar="INDEX",
type=int,
default=0,
help="Start presenting at a given animation number (0 is first, -1 is last). This conflicts with slide number since animation number is absolute to the presentation.",
)
@click.option(
"--screen",
"screen_number",
@ -1051,7 +970,6 @@ def present(
start_at: Tuple[Optional[int], Optional[int], Optional[int]],
start_at_scene_number: Optional[int],
start_at_slide_number: Optional[int],
start_at_animation_number: Optional[int],
screen_number: Optional[int] = None,
) -> None:
"""
@ -1115,9 +1033,6 @@ def present(
if start_at[1]:
start_at_slide_number = start_at[1]
if start_at[2]:
start_at_animation_number = start_at[2]
if not QApplication.instance():
app = QApplication(sys.argv)
else:
@ -1150,7 +1065,6 @@ def present(
resize_mode=RESIZE_MODES[resize_mode],
start_at_scene_number=start_at_scene_number,
start_at_slide_number=start_at_slide_number,
start_at_animation_number=start_at_animation_number,
screen=screen,
)

View File

@ -1,6 +1,4 @@
import platform
import shutil
import subprocess
from pathlib import Path
from typing import (
Any,
@ -17,10 +15,9 @@ from warnings import warn
import numpy as np
from tqdm import tqdm
from .config import PresentationConfig, SlideConfig, SlideType
from .config import PresentationConfig, PreSlideConfig, SlideConfig, SlideType
from .defaults import FOLDER_PATH
from .manim import (
FFMPEG_BIN,
LEFT,
MANIMGL,
AnimationGroup,
@ -32,20 +29,7 @@ from .manim import (
config,
logger,
)
def reverse_video_file(src: Path, dst: Path) -> None:
"""Reverses a video file, writting the result to `dst`."""
command = [str(FFMPEG_BIN), "-y", "-i", str(src), "-vf", "reverse", str(dst)]
logger.debug(" ".join(command))
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output, error = process.communicate()
if output:
logger.debug(output.decode())
if error:
logger.debug(error.decode())
from .utils import concatenate_video_files, merge_basenames, reverse_video_file
class Slide(Scene): # type:ignore
@ -69,7 +53,7 @@ class Slide(Scene): # type:ignore
super().__init__(*args, **kwargs)
self.__output_folder: Path = output_folder
self.__slides: List[SlideConfig] = []
self.__slides: List[PreSlideConfig] = []
self.__current_slide = 1
self.__current_animation = 0
self.__loop_start_animation: Optional[int] = None
@ -369,7 +353,7 @@ class Slide(Scene): # type:ignore
self.wait(self.wait_time_between_slides)
self.__slides.append(
SlideConfig(
PreSlideConfig(
type=SlideType.slide,
start_animation=self.__pause_start_animation,
end_animation=self.__current_animation,
@ -404,7 +388,7 @@ class Slide(Scene): # type:ignore
return
self.__slides.append(
SlideConfig(
PreSlideConfig(
type=SlideType.last,
start_animation=self.__pause_start_animation,
end_animation=self.__current_animation,
@ -458,7 +442,7 @@ class Slide(Scene): # type:ignore
self.__loop_start_animation is not None
), "You have to start a loop before ending it"
self.__slides.append(
SlideConfig(
PreSlideConfig(
type=SlideType.loop,
start_animation=self.__loop_start_animation,
end_animation=self.__current_animation,
@ -484,51 +468,57 @@ class Slide(Scene): # type:ignore
scene_files_folder.mkdir(parents=True, exist_ok=True)
files = []
for src_file in tqdm(
self.__partial_movie_files,
desc=f"Copying animation files to '{scene_files_folder}' and generating reversed animations",
leave=self.__leave_progress_bar,
ascii=True if platform.system() == "Windows" else None,
disable=not self.__show_progress_bar,
):
if src_file is None and not MANIMGL:
# This happens if rendering with -na,b (manim only)
# where animations not in [a,b] will be skipped
# but animations before a will have a None src_file
continue
dst_file = scene_files_folder / src_file.name
rev_file = scene_files_folder / f"{src_file.stem}_reversed{src_file.suffix}"
# We only copy animation if it was not present
if not use_cache or not dst_file.exists():
shutil.copyfile(src_file, dst_file)
# We only reverse video if it was not present
if not use_cache or not rev_file.exists():
reverse_video_file(src_file, rev_file)
files.append(dst_file)
# When rendering with -na,b (manim only)
# the animations not in [a,b] will be skipped,
# but animation before a will have a None source file.
files: List[Path] = list(filter(None, self.__partial_movie_files))
# We must filter slides that end before the animation offset
if offset := self.__start_at_animation_number:
self.__slides = [
slide for slide in self.__slides if slide.end_animation > offset
]
for slide in self.__slides:
slide.start_animation -= offset
slide.start_animation = max(0, slide.start_animation - offset)
slide.end_animation -= offset
slides: List[SlideConfig] = []
for pre_slide_config in tqdm(
self.__slides,
desc=f"Concatenating animation files to '{scene_files_folder}' and generating reversed animations",
leave=self.__leave_progress_bar,
ascii=True if platform.system() == "Windows" else None,
disable=not self.__show_progress_bar,
):
slide_files = files[pre_slide_config.slides_slice]
file = merge_basenames(slide_files)
dst_file = scene_files_folder / file.name
rev_file = scene_files_folder / f"{file.stem}_reversed{file.suffix}"
# We only concat animations if it was not present
if not use_cache or not dst_file.exists():
concatenate_video_files(slide_files, dst_file)
# We only reverse video if it was not present
if not use_cache or not rev_file.exists():
reverse_video_file(dst_file, rev_file)
slides.append(
SlideConfig.from_pre_slide_config_and_files(
pre_slide_config, dst_file, rev_file
)
)
logger.info(
f"Copied {len(files)} animations to '{scene_files_folder.absolute()}' and generated reversed animations"
f"Generated {len(slides)} slides to '{scene_files_folder.absolute()}'"
)
slide_path = self.__output_folder / f"{scene_name}.json"
PresentationConfig(
slides=self.__slides,
files=files,
slides=slides,
resolution=self.__resolution,
background_color=self.__background_color,
).to_file(slide_path)

80
manim_slides/utils.py Normal file
View File

@ -0,0 +1,80 @@
import hashlib
import subprocess
import tempfile
from pathlib import Path
from typing import List
from .manim import FFMPEG_BIN, logger
def concatenate_video_files(files: List[Path], dest: Path) -> None:
"""
Concatenate multiple video files into one.
"""
f = tempfile.NamedTemporaryFile(mode="w", delete=False)
f.writelines(f"file '{path.absolute()}'\n" for path in files)
f.close()
command: List[str] = [
str(FFMPEG_BIN),
"-f",
"concat",
"-safe",
"0",
"-i",
f.name,
"-c",
"copy",
str(dest),
"-y",
]
logger.debug(" ".join(command))
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output, error = process.communicate()
if output:
logger.debug(output.decode())
if error:
logger.debug(error.decode())
if not dest.exists():
raise ValueError(
"could not properly concatenate files, use `-v DEBUG` for more details"
)
def merge_basenames(files: List[Path]) -> Path:
"""
Merge multiple filenames by concatenating basenames.
"""
dirname: Path = files[0].parent
ext = files[0].suffix
basenames = list(file.stem for file in files)
basenames_str = ",".join(f"{len(b)}:{b}" for b in basenames)
# We use hashes to prevent too-long filenames, see issue #123:
# https://github.com/jeertmans/manim-slides/issues/123
basename = hashlib.sha256(basenames_str.encode()).hexdigest()
logger.info(f"Generated a new basename for basenames: {basenames} -> '{basename}'")
return dirname.joinpath(basename + ext)
def reverse_video_file(src: Path, dst: Path) -> None:
"""Reverses a video file, writting the result to `dst`."""
command = [str(FFMPEG_BIN), "-y", "-i", str(src), "-vf", "reverse", str(dst)]
logger.debug(" ".join(command))
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output, error = process.communicate()
if output:
logger.debug(output.decode())
if error:
logger.debug(error.decode())

View File

@ -1,13 +1,62 @@
import random
import string
from pathlib import Path
from typing import Iterator
from typing import Generator, Iterator, List
import pytest
from manim_slides.config import PresentationConfig
from manim_slides.logger import make_logger
_ = make_logger() # This is run so that "PERF" level is created
@pytest.fixture
def folder_path() -> Iterator[Path]:
yield (Path(__file__).parent / "slides").resolve()
def data_folder() -> Iterator[Path]:
path = (Path(__file__).parent / "data").resolve()
assert path.exists()
yield path
@pytest.fixture
def slides_folder(data_folder: Path) -> Iterator[Path]:
path = (data_folder / "slides").resolve()
assert path.exists()
yield path
@pytest.fixture
def slides_file(data_folder: Path) -> Iterator[Path]:
path = (data_folder / "slides.py").resolve()
assert path.exists()
yield path
def random_path(
length: int = 20,
dirname: Path = Path("./media/videos/example"),
suffix: str = ".mp4",
touch: bool = False,
) -> Path:
basename = "".join(random.choices(string.ascii_letters, k=length))
filepath = dirname.joinpath(basename + suffix)
if touch:
filepath.touch()
return filepath
@pytest.fixture
def paths() -> Generator[List[Path], None, None]:
random.seed(1234)
yield [random_path() for _ in range(20)]
@pytest.fixture
def presentation_config(
slides_folder: Path,
) -> Generator[PresentationConfig, None, None]:
yield PresentationConfig.from_file(slides_folder / "BasicSlide.json")

24
tests/data/slides.py Normal file
View File

@ -0,0 +1,24 @@
# flake8: noqa: F403, F405
# type: ignore
from manim import *
from manim_slides import Slide
class BasicSlide(Slide):
def construct(self):
circle = Circle(radius=3, color=BLUE)
dot = Dot()
self.play(GrowFromCenter(circle))
self.next_slide()
self.start_loop()
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
self.wait(2.0)
self.end_loop()
self.play(dot.animate.move_to(ORIGIN))
self.next_slide()
self.play(self.wipe(Group(dot, circle), []))

View File

@ -0,0 +1,29 @@
{
"slides": [
{
"type": "slide",
"file": "slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4.mp4",
"rev_file": "slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4_reversed.mp4"
},
{
"type": "loop",
"file": "slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da.mp4",
"rev_file": "slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da_reversed.mp4"
},
{
"type": "slide",
"file": "slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810.mp4",
"rev_file": "slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810_reversed.mp4"
},
{
"type": "last",
"file": "slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9.mp4",
"rev_file": "slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9_reversed.mp4"
}
],
"resolution": [
854,
480
],
"background_color": "black"
}

View File

@ -1,32 +0,0 @@
{
"slides": [
{
"type": "slide",
"start_animation": 0,
"end_animation": 1,
"number": 1
},
{
"type": "loop",
"start_animation": 1,
"end_animation": 2,
"number": 2
},
{
"type": "last",
"start_animation": 2,
"end_animation": 3,
"number": 3
}
],
"files": [
"slides/files/BasicExample/1413466013_3346521118_223132457.mp4",
"slides/files/BasicExample/1672018281_3136302242_2191168284.mp4",
"slides/files/BasicExample/1672018281_1369283980_3942561600.mp4"
],
"resolution": [
1920,
1080
],
"background_color": "black"
}

View File

@ -1,80 +1,9 @@
import random
import string
import tempfile
from pathlib import Path
from typing import Any, Generator, List
from typing import Any
import pytest
from pydantic import ValidationError
from manim_slides.config import (
Key,
PresentationConfig,
SlideConfig,
SlideType,
merge_basenames,
)
def random_path(
length: int = 20,
dirname: Path = Path("./media/videos/example"),
suffix: str = ".mp4",
touch: bool = False,
) -> Path:
basename = "".join(random.choices(string.ascii_letters, k=length))
filepath = dirname.joinpath(basename + suffix)
if touch:
filepath.touch()
return filepath
@pytest.fixture
def paths() -> Generator[List[Path], None, None]:
random.seed(1234)
yield [random_path() for _ in range(20)]
@pytest.fixture
def presentation_config(paths: List[Path]) -> Generator[PresentationConfig, None, None]:
dirname = Path(tempfile.mkdtemp())
files = [random_path(dirname=dirname, touch=True) for _ in range(10)]
slides = [
SlideConfig(
type=SlideType.slide,
start_animation=0,
end_animation=5,
number=1,
),
SlideConfig(
type=SlideType.loop,
start_animation=5,
end_animation=6,
number=2,
),
SlideConfig(
type=SlideType.last,
start_animation=6,
end_animation=10,
number=3,
),
]
yield PresentationConfig(
slides=slides,
files=files,
)
def test_merge_basenames(paths: List[Path]) -> None:
path = merge_basenames(paths)
assert path.suffix == paths[0].suffix
assert path.parent == paths[0].parent
from manim_slides.config import Key, PresentationConfig
class TestKey:

View File

@ -16,29 +16,29 @@ def test_help() -> None:
assert results.exit_code == 0
def test_defaults_to_present(folder_path: Path) -> None:
def test_defaults_to_present(slides_folder: Path) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
results = runner.invoke(
cli, ["BasicExample", "--folder", str(folder_path), "-s"]
cli, ["BasicSlide", "--folder", str(slides_folder), "-s"]
)
assert results.exit_code == 0
def test_present(folder_path: Path) -> None:
def test_present(slides_folder: Path) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
results = runner.invoke(
cli, ["present", "BasicExample", "--folder", str(folder_path), "-s"]
cli, ["present", "BasicSlide", "--folder", str(slides_folder), "-s"]
)
assert results.exit_code == 0
def test_convert(folder_path: Path) -> None:
def test_convert(slides_folder: Path) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
@ -46,10 +46,10 @@ def test_convert(folder_path: Path) -> None:
cli,
[
"convert",
"BasicExample",
"BasicSlide",
"basic_example.html",
"--folder",
str(folder_path),
str(slides_folder),
],
)
@ -71,7 +71,7 @@ def test_init() -> None:
assert results.exit_code == 0
def test_list_scenes(folder_path: Path) -> None:
def test_list_scenes(slides_folder: Path) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
@ -80,12 +80,12 @@ def test_list_scenes(folder_path: Path) -> None:
[
"list-scenes",
"--folder",
str(folder_path),
str(slides_folder),
],
)
assert results.exit_code == 0
assert "BasicExample" in results.output
assert "BasicSlide" in results.output
def test_wizard() -> None:

View File

@ -1,7 +1,12 @@
from pathlib import Path
import pytest
from click.testing import CliRunner
from manim import Text
from manim.__main__ import main as cli
from pydantic import ValidationError
from manim_slides.config import PresentationConfig
from manim_slides.slide import Slide
@ -14,6 +19,41 @@ def assert_construct(cls: type) -> type:
return Wrapper
def test_render_basic_examples(
slides_file: Path, presentation_config: PresentationConfig
) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
results = runner.invoke(cli, [str(slides_file), "BasicSlide", "-ql"])
assert results.exit_code == 0
local_slides_folder = Path("slides")
assert local_slides_folder.exists()
local_config_file = local_slides_folder / "BasicSlide.json"
assert local_config_file.exists()
local_presentation_config = PresentationConfig.from_file(local_config_file)
assert len(local_presentation_config.slides) == len(presentation_config.slides)
assert (
local_presentation_config.background_color
== presentation_config.background_color
)
assert (
local_presentation_config.background_color
== presentation_config.background_color
)
assert local_presentation_config.resolution == presentation_config.resolution
class TestSlide:
@assert_construct
class TestLoop(Slide):

23
tests/test_utils.py Normal file
View File

@ -0,0 +1,23 @@
from pathlib import Path
from typing import List
from manim_slides.utils import merge_basenames
def test_merge_basenames(paths: List[Path]) -> None:
path = merge_basenames(paths)
assert path.suffix == paths[0].suffix
assert path.parent == paths[0].parent
def test_merge_basenames_same_with_different_parent_directories(
paths: List[Path],
) -> None:
d1 = Path("a/b/c")
d2 = Path("d/e/f")
p1 = d1 / "one.txt"
p2 = d1 / "a/b/c/two.txt"
p3 = d2 / "d/e/f/one.txt"
p4 = d2 / "d/e/f/two.txt"
assert merge_basenames([p1, p2]).name == merge_basenames([p3, p4]).name