mirror of
https://github.com/jeertmans/manim-slides.git
synced 2025-05-18 03:05:21 +08:00
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:
2
.gitignore
vendored
2
.gitignore
vendored
@ -17,7 +17,7 @@ videos/
|
||||
.manim-slides.toml
|
||||
|
||||
slides/
|
||||
!tests/slides/
|
||||
!tests/data/slides/
|
||||
|
||||
slides_assets/
|
||||
|
||||
|
26
CHANGELOG.md
Normal file
26
CHANGELOG.md
Normal 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)
|
10
example.py
10
example.py
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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]
|
||||
|
||||
|
@ -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,
|
||||
)
|
||||
|
||||
|
@ -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
80
manim_slides/utils.py
Normal 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())
|
@ -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
24
tests/data/slides.py
Normal 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), []))
|
29
tests/data/slides/BasicSlide.json
Normal file
29
tests/data/slides/BasicSlide.json
Normal 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"
|
||||
}
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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"
|
||||
}
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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
23
tests/test_utils.py
Normal 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
|
Reference in New Issue
Block a user