diff --git a/README.md b/README.md index a6919b6..74208b4 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ![PyPI - Downloads](https://img.shields.io/pypi/dm/manim-slides) # Manim Slides -Tool for live presentations using either [manim](http://3b1b.github.io/manim/) or [manim-community](https://www.manim.community/). +Tool for live presentations using either [manim-community](https://www.manim.community/). Currently, support for 3b1b's manim is not planned. > **_NOTE:_** This project is a fork of [`manim-presentation`](https://github.com/galatolofederico/manim-presentation). Since the project seemed to be inactive, I decided to create my own fork to deploy new features more rapidly. @@ -65,6 +65,7 @@ Default keybindings to control the presentation: | Right Arrow | Continue/Next Slide | | Left Arrow | Previous Slide | | R | Re-Animate Current Slide | +| V | Reverse Current Slide | | Spacebar | Play/Pause | | Q | Quit | @@ -122,11 +123,10 @@ Here are a few things that I implemented (or that I'm planning to implement) on - [x] Only one cli (to rule them all) - [x] User can easily generate dummy config file - [x] Config file path can be manually set -- [ ] Play animation in reverse [#9](https://github.com/galatolofederico/manim-presentation/issues/9) +- [x] Play animation in reverse [#9](https://github.com/galatolofederico/manim-presentation/issues/9) - [x] Handle 3D scenes out of the box -- [ ] Can work with both community and 3b1b versions (not tested) - [ ] Generate docs online -- [ ] Fix the quality problem on Windows platforms with `fullscreen` flag +- [x] Fix the quality problem on Windows platforms with `fullscreen` flag ## Contributions and license diff --git a/manim_slides/__version__.py b/manim_slides/__version__.py index 0552768..f5f41e5 100644 --- a/manim_slides/__version__.py +++ b/manim_slides/__version__.py @@ -1 +1 @@ -__version__ = "3.0.1" +__version__ = "3.1.0" diff --git a/manim_slides/config.py b/manim_slides/config.py index 7f777e0..131f481 100644 --- a/manim_slides/config.py +++ b/manim_slides/config.py @@ -23,6 +23,7 @@ class Config(BaseModel): QUIT: Key = Key(ids=[ord("q")], name="QUIT") CONTINUE: Key = Key(ids=[RIGHT_ARROW_KEY_CODE], name="CONTINUE / NEXT") BACK: Key = Key(ids=[LEFT_ARROW_KEY_CODE], name="BACK") + REVERSE: Key = Key(ids=[ord("v")], name="REVERSE") REWIND: Key = Key(ids=[ord("r")], name="REWIND") PLAY_PAUSE: Key = Key(ids=[32], name="PLAY / PAUSE") @@ -33,7 +34,7 @@ class Config(BaseModel): for key in values.values(): if len(ids.intersection(key.ids)) != 0: raise ValueError( - f"Two or more keys share a common key code: please make sure each key has distinc key codes" + "Two or more keys share a common key code: please make sure each key has distinc key codes" ) ids.update(key.ids) diff --git a/manim_slides/main.py b/manim_slides/main.py index 645d296..4378bf2 100644 --- a/manim_slides/main.py +++ b/manim_slides/main.py @@ -2,7 +2,7 @@ import click from click_default_group import DefaultGroup from . import __version__ -from .present import present +from .present import list_scenes, present from .wizard import init, wizard @@ -13,6 +13,7 @@ def cli(): pass +cli.add_command(list_scenes) cli.add_command(present) cli.add_command(wizard) cli.add_command(init) diff --git a/manim_slides/present.py b/manim_slides/present.py index df38e9b..4adf201 100644 --- a/manim_slides/present.py +++ b/manim_slides/present.py @@ -16,6 +16,7 @@ import numpy as np from .commons import config_path_option from .config import Config from .defaults import CONFIG_PATH, FOLDER_PATH +from .slide import reverse_video_path @unique @@ -41,7 +42,9 @@ class Presentation: def __init__(self, config, last_frame_next: bool = False): self.last_frame_next = last_frame_next self.slides = config["slides"] - self.files = config["files"] + self.files = [path for path in config["files"]] + self.reverse = False + self.reversed_slide = -1 self.lastframe = [] @@ -79,19 +82,34 @@ class Presentation: self.current_slide_i = max(0, self.current_slide_i - 1) self.rewind_slide() - def rewind_slide(self): + def reverse_slide(self): + self.rewind_slide(reverse=True) + + def rewind_slide(self, reverse: bool = False): + self.reverse = reverse self.current_animation = self.current_slide["start_animation"] self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, 0) - def load_this_cap(self, cap_number): - if self.caps[cap_number] == None: + def load_this_cap(self, cap_number: int): + if ( + self.caps[cap_number] is None + or (self.reverse and self.reversed_slide != cap_number) + or (not self.reverse and self.reversed_slide == cap_number) + ): # unload other caps for i in range(len(self.caps)): - if self.caps[i] != None: + if not self.caps[i] is None: self.caps[i].release() self.caps[i] = None # load this cap - self.caps[cap_number] = cv2.VideoCapture(self.files[cap_number]) + file = self.files[cap_number] + if self.reverse: + self.reversed_slide = cap_number + file = "{}_reversed{}".format(*os.path.splitext(file)) + else: + self.reversed_slide = -1 + + self.caps[cap_number] = cv2.VideoCapture(file) @property def current_slide(self): @@ -170,7 +188,9 @@ class Display: if platform.system() == "Windows": user32 = ctypes.windll.user32 - self.screen_width, self.screen_height = user32.GetSystemMetrics(0), user32.GetSystemMetrics(1) + self.screen_width, self.screen_height = user32.GetSystemMetrics( + 0 + ), user32.GetSystemMetrics(1) if fullscreen: cv2.namedWindow("Video", cv2.WND_PROP_FULLSCREEN) @@ -283,6 +303,9 @@ class Display: else: self.current_presentation.prev() self.state = State.PLAYING + elif self.config.REVERSE.match(key): + self.current_presentation.reverse_slide() + self.state = State.PLAYING elif self.config.REWIND.match(key): self.current_presentation.rewind_slide() self.state = State.PLAYING @@ -292,6 +315,31 @@ class Display: sys.exit() +@click.command() +@click.option( + "--folder", + default=FOLDER_PATH, + type=click.Path(exists=True, file_okay=False), + help="Set slides folder.", +) +@click.help_option("-h", "--help") +def list_scenes(folder): + """List available scenes.""" + + for i, scene in enumerate(_list_scenes(folder), start=1): + click.secho(f"{i}: {scene}", fg="green") + + +def _list_scenes(folder): + scenes = [] + + for file in os.listdir(folder): + if file.endswith(".json"): + scenes.append(os.path.basename(file)[:-5]) + + return scenes + + @click.command() @click.argument("scenes", nargs=-1) @config_path_option @@ -312,6 +360,37 @@ class Display: def present(scenes, config_path, folder, start_paused, fullscreen, last_frame_next): """Present the different scenes.""" + if len(scenes) == 0: + scene_choices = _list_scenes(folder) + + scene_choices = dict(enumerate(scene_choices, start=1)) + + for i, scene in scene_choices.items(): + click.secho(f"{i}: {scene}", fg="green") + + click.echo() + + click.echo("Choose number corresponding to desired scene/arguments.") + click.echo("(Use comma separated list for multiple entries)") + + def value_proc(value: str): + indices = list(map(int, value.strip().replace(" ", "").split(","))) + + if not all(map(lambda i: 0 < i <= len(scene_choices), indices)): + raise ValueError("Please only enter numbers displayed on the screen.") + + return [scene_choices[i] for i in indices] + + if len(scene_choices) == 0: + raise ValueError("No scenes were found, are you in the correct directory?") + + while True: + try: + scenes = click.prompt("Choice(s)", value_proc=value_proc) + break + except ValueError as e: + click.secho(e, fg="red") + presentations = list() for scene in scenes: config_file = os.path.join(folder, f"{scene}.json") diff --git a/manim_slides/slide.py b/manim_slides/slide.py index 88999ae..9667ec2 100644 --- a/manim_slides/slide.py +++ b/manim_slides/slide.py @@ -1,12 +1,36 @@ import json import os +import platform import shutil +import subprocess -from manim import Scene, ThreeDScene, config +from tqdm import tqdm + +try: + from .manim import Scene, ThreeDScene, config, logger +except ImportError: + Scene = ThreeDScene = config = logger = None + # TODO: manage both manim and manimgl + +try: # For manim str: + file, ext = os.path.splitext(src) + return f"{file}_reversed{ext}" + + +def reverse_video_file(src: str, dst: str): + command = [config.ffmpeg_executable, "-i", src, "-vf", "reverse", dst] + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + process.communicate() + + class Slide(Scene): def __init__(self, *args, output_folder=FOLDER_PATH, **kwargs): super().__init__(*args, **kwargs) @@ -72,21 +96,54 @@ class Slide(Scene): scene_name = type(self).__name__ scene_files_folder = os.path.join(files_folder, scene_name) - if os.path.exists(scene_files_folder): - shutil.rmtree(scene_files_folder) + old_animation_files = set() if not os.path.exists(scene_files_folder): os.mkdir(scene_files_folder) + else: + old_animation_files.update(os.listdir(scene_files_folder)) files = list() - for src_file in self.renderer.file_writer.partial_movie_files: - dst_file = os.path.join(scene_files_folder, os.path.basename(src_file)) - shutil.copyfile(src_file, dst_file) + for src_file in tqdm( + self.renderer.file_writer.partial_movie_files, + desc=f"Copying animation files to '{scene_files_folder}' and generating reversed animations", + leave=config["progress_bar"] == "leave", + ascii=True if platform.system() == "Windows" else None, + disable=config["progress_bar"] == "none", + ): + filename = os.path.basename(src_file) + _hash, ext = os.path.splitext(filename) + + rev_filename = f"{_hash}_reversed{ext}" + + dst_file = os.path.join(scene_files_folder, filename) + # We only copy animation if it was not present + if filename in old_animation_files: + old_animation_files.remove(filename) + else: + shutil.copyfile(src_file, dst_file) + + # We only reverse video if it was not present + if rev_filename in old_animation_files: + old_animation_files.remove(rev_filename) + else: + rev_file = os.path.join(scene_files_folder, rev_filename) + reverse_video_file(src_file, rev_file) + files.append(dst_file) - f = open(os.path.join(self.output_folder, "%s.json" % (scene_name,)), "w") + logger.info( + f"Copied {len(files)} animations to '{os.path.abspath(scene_files_folder)}' and generated reversed animations" + ) + + slide_path = os.path.join(self.output_folder, "%s.json" % (scene_name,)) + + f = open(slide_path, "w") json.dump(dict(slides=self.slides, files=files), f) f.close() + logger.info( + f"Slide '{scene_name}' configuration written in '{os.path.abspath(slide_path)}'" + ) class ThreeDSlide(Slide, ThreeDScene): diff --git a/setup.py b/setup.py index c7ec056..3964f78 100644 --- a/setup.py +++ b/setup.py @@ -2,9 +2,7 @@ import sys import setuptools -sys.path.append("manim_slides") # To avoid importing manim, which may not be installed - -from __version__ import __version__ as version +from manim_slides import __version__ as version if sys.version_info < (3, 7): raise RuntimeError("This package requires Python 3.7+")