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 .manim-slides.toml
slides/ slides/
!tests/slides/ !tests/data/slides/
slides_assets/ 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 # type: ignore
import sys import sys
if "manim" in sys.modules: if "manimlib" in sys.modules:
from manim import *
MANIMGL = False
elif "manimlib" in sys.modules:
from manimlib import * from manimlib import *
MANIMGL = True MANIMGL = True
else: else:
raise ImportError("This script must be run with either `manim` or `manimgl`") from manim import *
MANIMGL = False
from manim_slides import Slide, ThreeDSlide from manim_slides import Slide, ThreeDSlide

View File

@ -1,8 +1,5 @@
import hashlib
import json import json
import shutil import shutil
import subprocess
import tempfile
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple, Union 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 pydantic_extra_types.color import Color
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from .defaults import FFMPEG_BIN
from .logger import logger 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 class Key(BaseModel): # type: ignore
"""Represents a list of key codes, with optionally a name.""" """Represents a list of key codes, with optionally a name."""
@ -124,12 +100,10 @@ class SlideType(str, Enum):
last = "last" last = "last"
class SlideConfig(BaseModel): # type: ignore class PreSlideConfig(BaseModel): # type: ignore
type: SlideType type: SlideType
start_animation: int start_animation: int
end_animation: int end_animation: int
number: int
terminated: bool = Field(False, exclude=True)
@field_validator("start_animation", "end_animation") @field_validator("start_animation", "end_animation")
@classmethod @classmethod
@ -138,13 +112,6 @@ class SlideConfig(BaseModel): # type: ignore
raise ValueError("Animation index (start or end) cannot be negative") raise ValueError("Animation index (start or end) cannot be negative")
return v 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") @model_validator(mode="before")
def start_animation_is_before_end( def start_animation_is_before_end(
cls, values: Dict[str, Union[SlideType, int, bool]] cls, values: Dict[str, Union[SlideType, int, bool]]
@ -161,6 +128,23 @@ class SlideConfig(BaseModel): # type: ignore
return values 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: def is_slide(self) -> bool:
return self.type == SlideType.slide return self.type == SlideType.slide
@ -170,14 +154,9 @@ class SlideConfig(BaseModel): # type: ignore
def is_last(self) -> bool: def is_last(self) -> bool:
return self.type == SlideType.last return self.type == SlideType.last
@property
def slides_slice(self) -> slice:
return slice(self.start_animation, self.end_animation)
class PresentationConfig(BaseModel): # type: ignore class PresentationConfig(BaseModel): # type: ignore
slides: List[SlideConfig] = Field(min_length=1) slides: List[SlideConfig] = Field(min_length=1)
files: List[FilePath]
resolution: Tuple[PositiveInt, PositiveInt] = (1920, 1080) resolution: Tuple[PositiveInt, PositiveInt] = (1920, 1080)
background_color: Color = "black" background_color: Color = "black"
@ -187,12 +166,15 @@ class PresentationConfig(BaseModel): # type: ignore
with open(path, "r") as f: with open(path, "r") as f:
obj = json.load(f) obj = json.load(f)
if files := obj.get("files", None): slides = obj.setdefault("slides", [])
# First parent is ../slides parent = path.parent.parent # Never fails, but parents[1] can fail
# so we take the parent of this parent
parent = Path(path).parents[1] for slide in slides:
for i in range(len(files)): if file := slide.get("file", None):
files[i] = parent / files[i] 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 return cls.model_validate(obj) # type: ignore
@ -201,104 +183,25 @@ class PresentationConfig(BaseModel): # type: ignore
with open(path, "w") as f: with open(path, "w") as f:
f.write(self.model_dump_json(indent=2)) f.write(self.model_dump_json(indent=2))
@model_validator(mode="after") def copy_to(self, folder: Path, use_cached: bool = True) -> "PresentationConfig":
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":
""" """
Copy the files to a given directory. Copy the files to a given directory.
""" """
n = len(self.files) for slide_config in self.slides:
for i in range(n): file = slide_config.file
file = self.files[i] rev_file = slide_config.rev_file
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)
return self dest = folder / file.name
rev_dest = folder / rev_file.name
def concat_animations( slide_config.file = dest
self, dest: Optional[Path] = None, use_cached: bool = True slide_config.rev_file = rev_dest
) -> "PresentationConfig":
"""
Concatenate animations such that each slide contains one animation.
"""
dest_paths = [] if not use_cached or not dest.exists():
shutil.copy(file, dest)
for i, slide_config in enumerate(self.slides): if not use_cached or not rev_dest.exists():
files = self.files[slide_config.slides_slice] shutil.copy(rev_file, rev_dest)
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)
return self 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.""" """Generates a sequence of sections, one per slide, that will be included into the html template."""
for presentation_config in self.presentation_configs: for presentation_config in self.presentation_configs:
for slide_config in presentation_config.slides: 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}") 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.""" """Converts this configuration into a RevealJS HTML presentation, saved to DEST."""
if self.data_uri: if self.data_uri:
assets_dir = Path("") # Actually we won't care. assets_dir = Path("") # Actually we won't care.
for presentation_config in self.presentation_configs:
presentation_config.concat_animations()
else: else:
dirname = dest.parent dirname = dest.parent
basename = dest.stem basename = dest.stem
@ -417,7 +414,7 @@ class RevealJS(Converter):
full_assets_dir.mkdir(parents=True, exist_ok=True) full_assets_dir.mkdir(parents=True, exist_ok=True)
for presentation_config in self.presentation_configs: 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: with open(dest, "w") as f:
sections = "".join(self.get_sections_iter(assets_dir)) sections = "".join(self.get_sections_iter(assets_dir))
@ -470,15 +467,14 @@ class PDF(Converter):
images = [] images = []
for i, presentation_config in enumerate(self.presentation_configs): for i, presentation_config in enumerate(self.presentation_configs):
presentation_config.concat_animations()
for slide_config in tqdm( for slide_config in tqdm(
presentation_config.slides, presentation_config.slides,
desc=f"Generating video slides for config {i + 1}", desc=f"Generating video slides for config {i + 1}",
leave=False, leave=False,
): ):
file = presentation_config.files[slide_config.start_animation] images.append(
read_image_from_video_file(slide_config.file, self.frame_index)
images.append(read_image_from_video_file(file, self.frame_index)) )
images[0].save( images[0].save(
dest, dest,
@ -531,7 +527,7 @@ class PowerPoint(Converter):
return etree.ElementBase.xpath(el, query, namespaces=nsmap) return etree.ElementBase.xpath(el, query, namespaces=nsmap)
def save_first_image_from_video_file(file: Path) -> Optional[str]: 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() ret, frame = cap.read()
if ret: if ret:
@ -543,13 +539,12 @@ class PowerPoint(Converter):
return None return None
for i, presentation_config in enumerate(self.presentation_configs): for i, presentation_config in enumerate(self.presentation_configs):
presentation_config.concat_animations()
for slide_config in tqdm( for slide_config in tqdm(
presentation_config.slides, presentation_config.slides,
desc=f"Generating video slides for config {i + 1}", desc=f"Generating video slides for config {i + 1}",
leave=False, leave=False,
): ):
file = presentation_config.files[slide_config.start_animation] file = slide_config.file
mime_type = mimetypes.guess_type(file)[0] mime_type = mimetypes.guess_type(file)[0]

View File

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

View File

@ -1,6 +1,4 @@
import platform import platform
import shutil
import subprocess
from pathlib import Path from pathlib import Path
from typing import ( from typing import (
Any, Any,
@ -17,10 +15,9 @@ from warnings import warn
import numpy as np import numpy as np
from tqdm import tqdm from tqdm import tqdm
from .config import PresentationConfig, SlideConfig, SlideType from .config import PresentationConfig, PreSlideConfig, SlideConfig, SlideType
from .defaults import FOLDER_PATH from .defaults import FOLDER_PATH
from .manim import ( from .manim import (
FFMPEG_BIN,
LEFT, LEFT,
MANIMGL, MANIMGL,
AnimationGroup, AnimationGroup,
@ -32,20 +29,7 @@ from .manim import (
config, config,
logger, logger,
) )
from .utils import concatenate_video_files, merge_basenames, reverse_video_file
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())
class Slide(Scene): # type:ignore class Slide(Scene): # type:ignore
@ -69,7 +53,7 @@ class Slide(Scene): # type:ignore
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.__output_folder: Path = output_folder self.__output_folder: Path = output_folder
self.__slides: List[SlideConfig] = [] self.__slides: List[PreSlideConfig] = []
self.__current_slide = 1 self.__current_slide = 1
self.__current_animation = 0 self.__current_animation = 0
self.__loop_start_animation: Optional[int] = None self.__loop_start_animation: Optional[int] = None
@ -369,7 +353,7 @@ class Slide(Scene): # type:ignore
self.wait(self.wait_time_between_slides) self.wait(self.wait_time_between_slides)
self.__slides.append( self.__slides.append(
SlideConfig( PreSlideConfig(
type=SlideType.slide, type=SlideType.slide,
start_animation=self.__pause_start_animation, start_animation=self.__pause_start_animation,
end_animation=self.__current_animation, end_animation=self.__current_animation,
@ -404,7 +388,7 @@ class Slide(Scene): # type:ignore
return return
self.__slides.append( self.__slides.append(
SlideConfig( PreSlideConfig(
type=SlideType.last, type=SlideType.last,
start_animation=self.__pause_start_animation, start_animation=self.__pause_start_animation,
end_animation=self.__current_animation, end_animation=self.__current_animation,
@ -458,7 +442,7 @@ class Slide(Scene): # type:ignore
self.__loop_start_animation is not None self.__loop_start_animation is not None
), "You have to start a loop before ending it" ), "You have to start a loop before ending it"
self.__slides.append( self.__slides.append(
SlideConfig( PreSlideConfig(
type=SlideType.loop, type=SlideType.loop,
start_animation=self.__loop_start_animation, start_animation=self.__loop_start_animation,
end_animation=self.__current_animation, end_animation=self.__current_animation,
@ -484,51 +468,57 @@ class Slide(Scene): # type:ignore
scene_files_folder.mkdir(parents=True, exist_ok=True) scene_files_folder.mkdir(parents=True, exist_ok=True)
files = [] # When rendering with -na,b (manim only)
for src_file in tqdm( # the animations not in [a,b] will be skipped,
self.__partial_movie_files, # but animation before a will have a None source file.
desc=f"Copying animation files to '{scene_files_folder}' and generating reversed animations", files: List[Path] = list(filter(None, self.__partial_movie_files))
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)
# We must filter slides that end before the animation offset
if offset := self.__start_at_animation_number: if offset := self.__start_at_animation_number:
self.__slides = [ self.__slides = [
slide for slide in self.__slides if slide.end_animation > offset slide for slide in self.__slides if slide.end_animation > offset
] ]
for slide in self.__slides: for slide in self.__slides:
slide.start_animation -= offset slide.start_animation = max(0, slide.start_animation - offset)
slide.end_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( 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" slide_path = self.__output_folder / f"{scene_name}.json"
PresentationConfig( PresentationConfig(
slides=self.__slides, slides=slides,
files=files,
resolution=self.__resolution, resolution=self.__resolution,
background_color=self.__background_color, background_color=self.__background_color,
).to_file(slide_path) ).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 pathlib import Path
from typing import Iterator from typing import Generator, Iterator, List
import pytest import pytest
from manim_slides.config import PresentationConfig
from manim_slides.logger import make_logger from manim_slides.logger import make_logger
_ = make_logger() # This is run so that "PERF" level is created _ = make_logger() # This is run so that "PERF" level is created
@pytest.fixture @pytest.fixture
def folder_path() -> Iterator[Path]: def data_folder() -> Iterator[Path]:
yield (Path(__file__).parent / "slides").resolve() 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 from typing import Any
import string
import tempfile
from pathlib import Path
from typing import Any, Generator, List
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
from manim_slides.config import ( from manim_slides.config import Key, PresentationConfig
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
class TestKey: class TestKey:

View File

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

View File

@ -1,7 +1,12 @@
from pathlib import Path
import pytest import pytest
from click.testing import CliRunner
from manim import Text from manim import Text
from manim.__main__ import main as cli
from pydantic import ValidationError from pydantic import ValidationError
from manim_slides.config import PresentationConfig
from manim_slides.slide import Slide from manim_slides.slide import Slide
@ -14,6 +19,41 @@ def assert_construct(cls: type) -> type:
return Wrapper 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: class TestSlide:
@assert_construct @assert_construct
class TestLoop(Slide): 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