diff --git a/.gitignore b/.gitignore index 921dc23..bf8a301 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,7 @@ videos/ .manim-slides.toml slides/ -!tests/slides/ +!tests/data/slides/ slides_assets/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1fd967e --- /dev/null +++ b/CHANGELOG.md @@ -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) diff --git a/example.py b/example.py index 1062c8b..d6e2283 100644 --- a/example.py +++ b/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 diff --git a/manim_slides/config.py b/manim_slides/config.py index f62378a..f69c757 100644 --- a/manim_slides/config.py +++ b/manim_slides/config.py @@ -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 diff --git a/manim_slides/convert.py b/manim_slides/convert.py index 27d8633..de4731b 100644 --- a/manim_slides/convert.py +++ b/manim_slides/convert.py @@ -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] diff --git a/manim_slides/present.py b/manim_slides/present.py index f73a69d..af0dbe7 100644 --- a/manim_slides/present.py +++ b/manim_slides/present.py @@ -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="", + metavar="", 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, ) diff --git a/manim_slides/slide.py b/manim_slides/slide.py index c23508a..3b6f940 100644 --- a/manim_slides/slide.py +++ b/manim_slides/slide.py @@ -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) diff --git a/manim_slides/utils.py b/manim_slides/utils.py new file mode 100644 index 0000000..457e6cb --- /dev/null +++ b/manim_slides/utils.py @@ -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()) diff --git a/tests/conftest.py b/tests/conftest.py index 4772df9..e6bdb40 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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") diff --git a/tests/data/slides.py b/tests/data/slides.py new file mode 100644 index 0000000..5503ae4 --- /dev/null +++ b/tests/data/slides.py @@ -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), [])) diff --git a/tests/data/slides/BasicSlide.json b/tests/data/slides/BasicSlide.json new file mode 100644 index 0000000..cf8d7a2 --- /dev/null +++ b/tests/data/slides/BasicSlide.json @@ -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" +} diff --git a/tests/data/slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da.mp4 b/tests/data/slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da.mp4 new file mode 100644 index 0000000..d543e0f Binary files /dev/null and b/tests/data/slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da.mp4 differ diff --git a/tests/data/slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da_reversed.mp4 b/tests/data/slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da_reversed.mp4 new file mode 100644 index 0000000..cbf709f Binary files /dev/null and b/tests/data/slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da_reversed.mp4 differ diff --git a/tests/data/slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4.mp4 b/tests/data/slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4.mp4 new file mode 100644 index 0000000..d468763 Binary files /dev/null and b/tests/data/slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4.mp4 differ diff --git a/tests/data/slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4_reversed.mp4 b/tests/data/slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4_reversed.mp4 new file mode 100644 index 0000000..073050e Binary files /dev/null and b/tests/data/slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4_reversed.mp4 differ diff --git a/tests/data/slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9.mp4 b/tests/data/slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9.mp4 new file mode 100644 index 0000000..e0cfd22 Binary files /dev/null and b/tests/data/slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9.mp4 differ diff --git a/tests/data/slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9_reversed.mp4 b/tests/data/slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9_reversed.mp4 new file mode 100644 index 0000000..a0ffe05 Binary files /dev/null and b/tests/data/slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9_reversed.mp4 differ diff --git a/tests/data/slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810.mp4 b/tests/data/slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810.mp4 new file mode 100644 index 0000000..212137d Binary files /dev/null and b/tests/data/slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810.mp4 differ diff --git a/tests/data/slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810_reversed.mp4 b/tests/data/slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810_reversed.mp4 new file mode 100644 index 0000000..783f06e Binary files /dev/null and b/tests/data/slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810_reversed.mp4 differ diff --git a/tests/slides/BasicExample.json b/tests/slides/BasicExample.json deleted file mode 100644 index cdfbecb..0000000 --- a/tests/slides/BasicExample.json +++ /dev/null @@ -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" -} diff --git a/tests/slides/files/BasicExample/1413466013_3346521118_223132457.mp4 b/tests/slides/files/BasicExample/1413466013_3346521118_223132457.mp4 deleted file mode 100644 index e0e54f8..0000000 Binary files a/tests/slides/files/BasicExample/1413466013_3346521118_223132457.mp4 and /dev/null differ diff --git a/tests/slides/files/BasicExample/1413466013_3346521118_223132457_reversed.mp4 b/tests/slides/files/BasicExample/1413466013_3346521118_223132457_reversed.mp4 deleted file mode 100644 index c161b5f..0000000 Binary files a/tests/slides/files/BasicExample/1413466013_3346521118_223132457_reversed.mp4 and /dev/null differ diff --git a/tests/slides/files/BasicExample/1672018281_1369283980_3942561600.mp4 b/tests/slides/files/BasicExample/1672018281_1369283980_3942561600.mp4 deleted file mode 100644 index 917bdd3..0000000 Binary files a/tests/slides/files/BasicExample/1672018281_1369283980_3942561600.mp4 and /dev/null differ diff --git a/tests/slides/files/BasicExample/1672018281_1369283980_3942561600_reversed.mp4 b/tests/slides/files/BasicExample/1672018281_1369283980_3942561600_reversed.mp4 deleted file mode 100644 index a4cbf01..0000000 Binary files a/tests/slides/files/BasicExample/1672018281_1369283980_3942561600_reversed.mp4 and /dev/null differ diff --git a/tests/slides/files/BasicExample/1672018281_3136302242_2191168284.mp4 b/tests/slides/files/BasicExample/1672018281_3136302242_2191168284.mp4 deleted file mode 100644 index a457ea5..0000000 Binary files a/tests/slides/files/BasicExample/1672018281_3136302242_2191168284.mp4 and /dev/null differ diff --git a/tests/slides/files/BasicExample/1672018281_3136302242_2191168284_reversed.mp4 b/tests/slides/files/BasicExample/1672018281_3136302242_2191168284_reversed.mp4 deleted file mode 100644 index ac2b94b..0000000 Binary files a/tests/slides/files/BasicExample/1672018281_3136302242_2191168284_reversed.mp4 and /dev/null differ diff --git a/tests/test_config.py b/tests/test_config.py index d2b8d09..f1e5846 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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: diff --git a/tests/test_main.py b/tests/test_main.py index f52f009..196c1b0 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -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: diff --git a/tests/test_slide.py b/tests/test_slide.py index 70c6cec..146ce25 100644 --- a/tests/test_slide.py +++ b/tests/test_slide.py @@ -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): diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..c0bccff --- /dev/null +++ b/tests/test_utils.py @@ -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