feat: large "present" feature refactoring (#15)

* fix(cli): previous presentation starts last slide

This changes the previous behavior that was such that going to previous presentation (scene) would start it from zero.

* fix(README): remove non-working svgs

Seems like GitHub does not support that

* wip: onto a cleaner parser, and more robust presentation

* fix(README): remove non-working svgs

Seems like GitHub does not support that

* wip: onto a cleaner parser, and more robust presentation

* wip: some progress

* fix: this is kinda working now

* fix: repeated reversed slide

* fix: import __version__ without importing package

* fix: typing list -> List

* chore(README): update features
This commit is contained in:
Jérome Eertmans
2022-09-15 15:10:12 +02:00
committed by GitHub
parent f6f851bd09
commit 4816fc9a41
6 changed files with 362 additions and 167 deletions

View File

@ -113,14 +113,14 @@ manim-slides Example
The default key bindings to control the presentation are: The default key bindings to control the presentation are:
| Keybinding | Action | Icon | | Keybinding | Action |
|:-----------:|:------------------------:|:----:| |:-----------:|:------------------------:|
| Right Arrow | Continue/Next Slide | <svg height="25px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M52.5 440.6c-9.5 7.9-22.8 9.7-34.1 4.4S0 428.4 0 416V96C0 83.6 7.2 72.3 18.4 67s24.5-3.6 34.1 4.4l192 160L256 241V96c0-17.7 14.3-32 32-32s32 14.3 32 32V416c0 17.7-14.3 32-32 32s-32-14.3-32-32V271l-11.5 9.6-192 160z"/></svg> | | Right Arrow | Continue/Next Slide |
| Left Arrow | Previous Slide | <svg height="25px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M267.5 440.6c9.5 7.9 22.8 9.7 34.1 4.4s18.4-16.6 18.4-29V96c0-12.4-7.2-23.7-18.4-29s-24.5-3.6-34.1 4.4l-192 160L64 241V96c0-17.7-14.3-32-32-32S0 78.3 0 96V416c0 17.7 14.3 32 32 32s32-14.3 32-32V271l11.5 9.6 192 160z"/></svg> | | Left Arrow | Previous Slide |
| R | Replay Current Slide | <svg height="25px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M48.5 224H40c-13.3 0-24-10.7-24-24V72c0-9.7 5.8-18.5 14.8-22.2s19.3-1.7 26.2 5.2L98.6 96.6c87.6-86.5 228.7-86.2 315.8 1c87.5 87.5 87.5 229.3 0 316.8s-229.3 87.5-316.8 0c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0c62.5 62.5 163.8 62.5 226.3 0s62.5-163.8 0-226.3c-62.2-62.2-162.7-62.5-225.3-1L185 183c6.9 6.9 8.9 17.2 5.2 26.2s-12.5 14.8-22.2 14.8H48.5z"/></svg> | | R | Replay Current Slide |
| V | Reverse Current Slide | <svg height="25px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M459.5 440.6c9.5 7.9 22.8 9.7 34.1 4.4s18.4-16.6 18.4-29V96c0-12.4-7.2-23.7-18.4-29s-24.5-3.6-34.1 4.4L288 214.3V256v41.7L459.5 440.6zM256 352V256 128 96c0-12.4-7.2-23.7-18.4-29s-24.5-3.6-34.1 4.4l-192 160C4.2 237.5 0 246.5 0 256s4.2 18.5 11.5 24.6l192 160c9.5 7.9 22.8 9.7 34.1 4.4s18.4-16.6 18.4-29V352z"/></svg> | | V | Reverse Current Slide |
| Spacebar | Play/Pause | <svg height="25px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80V432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"/></svg> <svg height="25px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M48 64C21.5 64 0 85.5 0 112V400c0 26.5 21.5 48 48 48H80c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48H48zm192 0c-26.5 0-48 21.5-48 48V400c0 26.5 21.5 48 48 48h32c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48H240z"/></svg> | | Spacebar | Play/Pause |
| Q | Quit | <svg height="25px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M310.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L160 210.7 54.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L114.7 256 9.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 301.3 265.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L205.3 256 310.6 150.6z"/></svg> | | Q | Quit |
You can run the **configuration wizard** to change those key bindings: You can run the **configuration wizard** to change those key bindings:
@ -165,7 +165,7 @@ Below is a non-exhaustive list of features:
| One command line tool | :heavy_check_mark: | :heavy_multiplication_x: | | One command line tool | :heavy_check_mark: | :heavy_multiplication_x: |
| Robust config file parsing | :heavy_check_mark: | :heavy_multiplication_x: | | Robust config file parsing | :heavy_check_mark: | :heavy_multiplication_x: |
| Support for 3D Scenes | :heavy_check_mark: | :heavy_multiplication_x: | | Support for 3D Scenes | :heavy_check_mark: | :heavy_multiplication_x: |
| Documented code | WIP | :heavy_multiplication_x: | | Documented code | :heavy_check_mark: | :heavy_multiplication_x: |
| Tested on Unix, macOS, and Windows | :heavy_check_mark: | :heavy_multiplication_x: | | Tested on Unix, macOS, and Windows | :heavy_check_mark: | :heavy_multiplication_x: |

View File

@ -1 +1 @@
__version__ = "3.2.4" __version__ = "4-rc.1"

View File

@ -1,11 +1,15 @@
from typing import Optional, Set import os
from enum import Enum
from typing import List, Optional, Set
from pydantic import BaseModel, root_validator, validator from pydantic import BaseModel, FilePath, root_validator, validator
from .defaults import LEFT_ARROW_KEY_CODE, RIGHT_ARROW_KEY_CODE from .defaults import LEFT_ARROW_KEY_CODE, RIGHT_ARROW_KEY_CODE
class Key(BaseModel): class Key(BaseModel):
"""Represents a list of key codes, with optionally a name."""
ids: Set[int] ids: Set[int]
name: Optional[str] = None name: Optional[str] = None
@ -20,6 +24,8 @@ class Key(BaseModel):
class Config(BaseModel): class Config(BaseModel):
"""General Manim Slides config"""
QUIT: Key = Key(ids=[ord("q")], name="QUIT") QUIT: Key = Key(ids=[ord("q")], name="QUIT")
CONTINUE: Key = Key(ids=[RIGHT_ARROW_KEY_CODE], name="CONTINUE / NEXT") CONTINUE: Key = Key(ids=[RIGHT_ARROW_KEY_CODE], name="CONTINUE / NEXT")
BACK: Key = Key(ids=[LEFT_ARROW_KEY_CODE], name="BACK") BACK: Key = Key(ids=[LEFT_ARROW_KEY_CODE], name="BACK")
@ -47,3 +53,83 @@ class Config(BaseModel):
key.name = other_key.name or key.name key.name = other_key.name or key.name
return self return self
class SlideType(str, Enum):
slide = "slide"
loop = "loop"
last = "last"
class SlideConfig(BaseModel):
type: SlideType
start_animation: int
end_animation: int
number: int
terminated: bool = False
@validator("start_animation", "end_animation")
def index_is_posint(cls, v: int):
if v < 0:
raise ValueError("Animation index (start or end) cannot be negative")
return v
@validator("number")
def number_is_strictly_posint(cls, v: int):
if v <= 0:
raise ValueError("Slide number cannot be negative or zero")
return v
@root_validator
def start_animation_is_before_end(cls, values):
if values["start_animation"] >= values["end_animation"]:
raise ValueError(
"Start animation index must be strictly lower than end animation index"
)
return values
def is_slide(self):
return self.type == SlideType.slide
def is_loop(self):
return self.type == SlideType.loop
def is_last(self):
return self.type == SlideType.last
class PresentationConfig(BaseModel):
slides: List[SlideConfig]
files: List[str]
@validator("files", pre=True, each_item=True)
def is_file_and_exists(cls, v):
if not os.path.exists(v):
raise ValueError(
f"Animation file {v} does not exist. Are you in the right directory?"
)
if not os.path.isfile(v):
raise ValueError(f"Animation file {v} is not a file")
return v
@root_validator
def animation_indices_match_files(cls, values):
files = values.get("files")
slides = values.get("slides")
if files is None or slides is None:
return values
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 values

View File

@ -5,6 +5,7 @@ import platform
import sys import sys
import time import time
from enum import IntEnum, auto, unique from enum import IntEnum, auto, unique
from typing import List, Tuple
if platform.system() == "Windows": if platform.system() == "Windows":
import ctypes import ctypes
@ -12,132 +13,197 @@ if platform.system() == "Windows":
import click import click
import cv2 import cv2
import numpy as np import numpy as np
from pydantic import ValidationError
from .commons import config_path_option from .commons import config_path_option
from .config import Config from .config import Config, PresentationConfig, SlideConfig, SlideType
from .defaults import CONFIG_PATH, FOLDER_PATH from .defaults import CONFIG_PATH, FOLDER_PATH
from .slide import reverse_video_path
WINDOW_NAME = "Manim Slides" WINDOW_NAME = "Manim Slides"
@unique @unique
class State(IntEnum): class State(IntEnum):
"""Represents all possible states of a slide presentation."""
PLAYING = auto() PLAYING = auto()
PAUSED = auto() PAUSED = auto()
WAIT = auto() WAIT = auto()
END = auto() END = auto()
def __str__(self): def __str__(self) -> str:
return self.name.capitalize() return self.name.capitalize()
def now() -> int: def now() -> int:
"""Returns time.time() in seconds."""
return round(time.time() * 1000) return round(time.time() * 1000)
def fix_time(x: float) -> float: def fix_time(t: float) -> float:
return x if x > 0 else 1 """Clips time t such that it is always positive."""
return t if t > 0 else 1
class Presentation: class Presentation:
def __init__(self, config, last_frame_next: bool = False): """Creates presentation from a configuration object."""
self.last_frame_next = last_frame_next
self.slides = config["slides"] def __init__(self, config: PresentationConfig):
self.files = [path for path in config["files"]] self.slides: List[SlideConfig] = config.slides
self.files: List[str] = config.files
self.current_slide_index = 0
self.current_animation = self.current_slide.start_animation
self.loaded_animation_cap = -1
self.cap = None # cap = cv2.VideoCapture
self.reverse = False self.reverse = False
self.reversed_slide = -1 self.reversed_animation = -1
self.lastframe = [] self.lastframe = None
self.caps = [None for _ in self.files]
self.reset() self.reset()
self.add_last_slide() self.add_last_slide()
@property
def current_slide(self) -> SlideConfig:
"""Returns currently playing slide."""
return self.slides[self.current_slide_index]
@property
def first_slide(self) -> SlideConfig:
"""Returns first slide."""
return self.slides[0]
@property
def last_slide(self) -> SlideConfig:
"""Returns last slide."""
return self.slides[-1]
def release_cap(self):
"""Releases current Video Capture, if existing."""
if not self.cap is None:
self.cap.release()
self.loaded_animation_cap = -1
def load_animation_cap(self, animation: int):
"""Loads video file of given animation."""
# We must load a new VideoCapture file if:
if (self.loaded_animation_cap != animation) or (
self.reverse and self.reversed_animation != animation
): # cap already loaded
self.release_cap()
file = self.files[animation]
if self.reverse:
file = "{}_reversed{}".format(*os.path.splitext(file))
self.reversed_animation = animation
self.cap = cv2.VideoCapture(file)
self.loaded_animation_cap = animation
@property
def current_cap(self) -> cv2.VideoCapture:
"""Returns current VideoCapture object."""
self.load_animation_cap(self.current_animation)
return self.cap
def rewind_current_slide(self):
"""Rewinds current slide to first frame."""
if self.reverse:
self.current_animation = self.current_slide.end_animation - 1
else:
self.current_animation = self.current_slide.start_animation
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
def reverse_current_slide(self):
"""Reverses current slide."""
self.reverse = True
self.rewind_current_slide()
def load_next_slide(self):
"""Loads next slide."""
if self.reverse:
self.reverse = False
self.reversed_animation = -1
self.release_cap()
self.rewind_current_slide()
elif self.current_slide.is_last():
self.current_slide.terminated = True
else:
self.current_slide_index = min(
len(self.slides) - 1, self.current_slide_index + 1
)
self.rewind_current_slide()
def load_previous_slide(self):
"""Loads previous slide."""
self.current_slide_index = max(0, self.current_slide_index - 1)
self.rewind_current_slide()
@property
def fps(self) -> int:
"""Returns the number of frames per second of the current video."""
return self.current_cap.get(cv2.CAP_PROP_FPS)
def add_last_slide(self): def add_last_slide(self):
last_slide_end = self.slides[-1]["end_animation"] """Add a 'last' slide to the end of slides."""
last_animation = len(self.files)
self.slides.append( self.slides.append(
dict( SlideConfig(
start_animation=last_slide_end, start_animation=self.last_slide.end_animation,
end_animation=last_animation, end_animation=self.last_slide.end_animation + 1,
type="last", type=SlideType.last,
number=len(self.slides) + 1, number=self.last_slide.number + 1,
terminated=False,
) )
) )
def reset(self): def reset(self):
"""Rests current presentation."""
self.current_animation = 0 self.current_animation = 0
self.load_this_cap(0) self.load_animation_cap(0)
self.current_slide_i = 0 self.current_slide_index = 0
self.slides[-1]["terminated"] = False self.slides[-1].terminated = False
def start_last_slide(self): def load_last_slide(self):
self.current_animation = self.slides[-1]["start_animation"] """Loads last slide."""
self.load_this_cap(self.current_animation) self.current_slide_index = len(self.slides) - 2
self.current_slide_i = len(self.slides[-1]) assert (
self.slides[-1]["terminated"] = False self.current_slide_index >= 0
), "Slides should be at list of a least two elements"
self.current_animation = self.current_slide.start_animation
self.load_animation_cap(self.current_animation)
self.slides[-1].terminated = False
def next(self): @property
if self.current_slide["type"] == "last": def next_animation(self) -> int:
self.current_slide["terminated"] = True """Returns the next animation."""
if self.reverse:
return self.current_animation - 1
else: else:
self.current_slide_i = min(len(self.slides) - 1, self.current_slide_i + 1) return self.current_animation + 1
self.rewind_slide()
def prev(self):
self.current_slide_i = max(0, self.current_slide_i - 1)
self.rewind_slide()
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: 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 not self.caps[i] is None:
self.caps[i].release()
self.caps[i] = None
# load this cap
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 @property
def current_slide(self): def is_last_animation(self) -> int:
return self.slides[self.current_slide_i] """Returns True if current animation is the last one of current slide."""
if self.reverse:
return self.current_animation == self.current_slide.start_animation
else:
return self.next_animation == self.current_slide.end_animation
@property def update_state(self, state) -> Tuple[np.ndarray, State]:
def current_cap(self): """
self.load_this_cap(self.current_animation) Updates the current state given the previous one.
return self.caps[self.current_animation]
@property It does this by reading the video information and checking if the state is still correct.
def fps(self): It returns the frame to show (lastframe) and the new state.
return self.current_cap.get(cv2.CAP_PROP_FPS) """
# This function updates the state given the previous state.
# It does this by reading the video information and checking if the state is still correct.
# It returns the frame to show (lastframe) and the new state.
def update_state(self, state):
if state == State.PAUSED: if state == State.PAUSED:
if len(self.lastframe) == 0: if self.lastframe is None:
_, self.lastframe = self.current_cap.read() _, self.lastframe = self.current_cap.read()
return self.lastframe, state return self.lastframe, state
still_playing, frame = self.current_cap.read() still_playing, frame = self.current_cap.read()
@ -145,36 +211,31 @@ class Presentation:
self.lastframe = frame self.lastframe = frame
elif state in [state.WAIT, state.PAUSED]: elif state in [state.WAIT, state.PAUSED]:
return self.lastframe, state return self.lastframe, state
elif self.current_slide["type"] == "last" and self.current_slide["terminated"]: elif self.current_slide.is_last() and self.current_slide.terminated:
return self.lastframe, State.END return self.lastframe, State.END
else: # not still playing
if not still_playing: if self.is_last_animation:
if self.current_slide["end_animation"] == self.current_animation + 1: if self.current_slide.is_slide():
if self.current_slide["type"] == "slide":
# To fix "it always ends one frame before the animation", uncomment this.
# But then clears on the next slide will clear the stationary after this slide.
if self.last_frame_next:
self.load_this_cap(self.next_cap)
self.next_cap = self.caps[self.current_animation + 1]
self.next_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
_, self.lastframe = self.next_cap.read()
state = State.WAIT state = State.WAIT
elif self.current_slide["type"] == "loop": elif self.current_slide.is_loop():
self.current_animation = self.current_slide["start_animation"] if self.reverse:
state = State.PLAYING state = State.WAIT
self.rewind_slide() else:
elif self.current_slide["type"] == "last": self.current_animation = self.current_slide.start_animation
self.current_slide["terminated"] = True state = State.PLAYING
self.rewind_current_slide()
elif self.current_slide.is_last():
self.current_slide.terminated = True
elif ( elif (
self.current_slide["type"] == "last" self.current_slide.is_last()
and self.current_slide["end_animation"] == self.current_animation and self.current_slide.end_animation == self.current_animation
): ):
print("HERE")
state = State.WAIT state = State.WAIT
else: else:
# Play next video! # Play next video!
self.current_animation += 1 self.current_animation = self.next_animation
self.load_this_cap(self.current_animation) self.load_animation_cap(self.current_animation)
# Reset video to position zero if it has been played before # Reset video to position zero if it has been played before
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, 0) self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
@ -182,6 +243,8 @@ class Presentation:
class Display: class Display:
"""Displays one or more presentations one after each other."""
def __init__( def __init__(
self, self,
presentations, presentations,
@ -199,7 +262,8 @@ class Display:
self.state = State.PLAYING self.state = State.PLAYING
self.lastframe = None self.lastframe = None
self.current_presentation_i = 0 self.current_presentation_index = 0
self.exit = False
self.lag = 0 self.lag = 0
self.last_time = now() self.last_time = now()
@ -217,6 +281,12 @@ class Display:
) )
def resize_frame_to_screen(self, frame: np.ndarray): def resize_frame_to_screen(self, frame: np.ndarray):
"""
Resizes a given frame to match screen dimensions.
Only works on Windows.
"""
assert self.is_windows, "Only Windows platforms need this method"
frame_height, frame_width = frame.shape[:2] frame_height, frame_width = frame.shape[:2]
scale_height = self.screen_height / frame_height scale_height = self.screen_height / frame_height
@ -227,11 +297,13 @@ class Display:
return cv2.resize(frame, (int(scale * frame_height), int(scale * frame_width))) return cv2.resize(frame, (int(scale * frame_height), int(scale * frame_width)))
@property @property
def current_presentation(self): def current_presentation(self) -> Presentation:
return self.presentations[self.current_presentation_i] """Returns the current presentation"""
return self.presentations[self.current_presentation_index]
def run(self): def run(self):
while True: """Runs a series of presentations until end or exit."""
while not self.exit:
self.lastframe, self.state = self.current_presentation.update_state( self.lastframe, self.state = self.current_presentation.update_state(
self.state self.state
) )
@ -240,16 +312,18 @@ class Display:
self.state = State.PAUSED self.state = State.PAUSED
self.start_paused = False self.start_paused = False
if self.state == State.END: if self.state == State.END:
if self.current_presentation_i == len(self.presentations) - 1: if self.current_presentation_index == len(self.presentations) - 1:
self.quit() self.quit()
continue
else: else:
self.current_presentation_i += 1 self.current_presentation_index += 1
self.state = State.PLAYING self.state = State.PLAYING
self.handle_key() self.handle_key()
self.show_video() self.show_video()
self.show_info() self.show_info()
def show_video(self): def show_video(self):
"""Shows updated video."""
self.lag = now() - self.last_time self.lag = now() - self.last_time
self.last_time = now() self.last_time = now()
@ -261,6 +335,7 @@ class Display:
cv2.imshow(WINDOW_NAME, frame) cv2.imshow(WINDOW_NAME, frame)
def show_info(self): def show_info(self):
"""Shows updated information about presentations."""
info = np.zeros((130, 420), np.uint8) info = np.zeros((130, 420), np.uint8)
font_args = (cv2.FONT_HERSHEY_SIMPLEX, 0.7, 255) font_args = (cv2.FONT_HERSHEY_SIMPLEX, 0.7, 255)
grid_x = [30, 230] grid_x = [30, 230]
@ -276,20 +351,20 @@ class Display:
cv2.putText( cv2.putText(
info, info,
f"Slide {self.current_presentation.current_slide['number']}/{len(self.current_presentation.slides)}", f"Slide {self.current_presentation.current_slide.number}/{len(self.current_presentation.slides)}",
(grid_x[0], grid_y[1]), (grid_x[0], grid_y[1]),
*font_args, *font_args,
) )
cv2.putText( cv2.putText(
info, info,
f"Slide Type: {self.current_presentation.current_slide['type']}", f"Slide Type: {self.current_presentation.current_slide.type}",
(grid_x[1], grid_y[1]), (grid_x[1], grid_y[1]),
*font_args, *font_args,
) )
cv2.putText( cv2.putText(
info, info,
f"Scene {self.current_presentation_i + 1}/{len(self.presentations)}", f"Scene {self.current_presentation_index + 1}/{len(self.presentations)}",
((grid_x[0] + grid_x[1]) // 2, grid_y[2]), ((grid_x[0] + grid_x[1]) // 2, grid_y[2]),
*font_args, *font_args,
) )
@ -297,6 +372,7 @@ class Display:
cv2.imshow(f"{WINDOW_NAME}: Info", info) cv2.imshow(f"{WINDOW_NAME}: Info", info)
def handle_key(self): def handle_key(self):
"""Handles key strokes."""
sleep_time = math.ceil(1000 / self.current_presentation.fps) sleep_time = math.ceil(1000 / self.current_presentation.fps)
key = cv2.waitKeyEx(fix_time(sleep_time - self.lag)) key = cv2.waitKeyEx(fix_time(sleep_time - self.lag))
@ -309,30 +385,34 @@ class Display:
elif self.state == State.WAIT and ( elif self.state == State.WAIT and (
self.config.CONTINUE.match(key) or self.config.PLAY_PAUSE.match(key) self.config.CONTINUE.match(key) or self.config.PLAY_PAUSE.match(key)
): ):
self.current_presentation.next() self.current_presentation.load_next_slide()
self.state = State.PLAYING self.state = State.PLAYING
elif ( elif (
self.state == State.PLAYING and self.config.CONTINUE.match(key) self.state == State.PLAYING and self.config.CONTINUE.match(key)
) or self.skip_all: ) or self.skip_all:
self.current_presentation.next() self.current_presentation.load_next_slide()
elif self.config.BACK.match(key): elif self.config.BACK.match(key):
if self.current_presentation.current_slide_i == 0: if self.current_presentation.current_slide_index == 0:
self.current_presentation_i = max(0, self.current_presentation_i - 1) if self.current_presentation_index == 0:
self.current_presentation.start_last_slide() self.current_presentation.rewind_current_slide()
else:
self.current_presentation_index -= 1
self.current_presentation.load_last_slide()
self.state = State.PLAYING self.state = State.PLAYING
else: else:
self.current_presentation.prev() self.current_presentation.load_previous_slide()
self.state = State.PLAYING self.state = State.PLAYING
elif self.config.REVERSE.match(key): elif self.config.REVERSE.match(key):
self.current_presentation.reverse_slide() self.current_presentation.reverse_current_slide()
self.state = State.PLAYING self.state = State.PLAYING
elif self.config.REWIND.match(key): elif self.config.REWIND.match(key):
self.current_presentation.rewind_slide() self.current_presentation.rewind_current_slide()
self.state = State.PLAYING self.state = State.PLAYING
def quit(self): def quit(self):
"""Destroys all windows created by presentations and exits gracefully."""
cv2.destroyAllWindows() cv2.destroyAllWindows()
sys.exit() self.exit = True
@click.command() @click.command()
@ -350,12 +430,18 @@ def list_scenes(folder):
click.secho(f"{i}: {scene}", fg="green") click.secho(f"{i}: {scene}", fg="green")
def _list_scenes(folder): def _list_scenes(folder) -> List[str]:
"""Lists available scenes in given directory."""
scenes = [] scenes = []
for file in os.listdir(folder): for file in os.listdir(folder):
if file.endswith(".json"): if file.endswith(".json"):
scenes.append(os.path.basename(file)[:-5]) try:
filepath = os.path.join(folder, file)
_ = PresentationConfig.parse_file(filepath)
scenes.append(os.path.basename(file)[:-5])
except Exception as e: # Could not parse this file as a proper presentation config
pass
return scenes return scenes
@ -371,20 +457,13 @@ def _list_scenes(folder):
) )
@click.option("--start-paused", is_flag=True, help="Start paused.") @click.option("--start-paused", is_flag=True, help="Start paused.")
@click.option("--fullscreen", is_flag=True, help="Fullscreen mode.") @click.option("--fullscreen", is_flag=True, help="Fullscreen mode.")
@click.option(
"--last-frame-next",
is_flag=True,
help="Show the next animation first frame as last frame (hack).",
)
@click.option( @click.option(
"--skip-all", "--skip-all",
is_flag=True, is_flag=True,
help="Skip all slides, useful the test if slides are working.", help="Skip all slides, useful the test if slides are working.",
) )
@click.help_option("-h", "--help") @click.help_option("-h", "--help")
def present( def present(scenes, config_path, folder, start_paused, fullscreen, skip_all):
scenes, config_path, folder, start_paused, fullscreen, last_frame_next, skip_all
):
"""Present the different scenes.""" """Present the different scenes."""
if len(scenes) == 0: if len(scenes) == 0:
@ -429,11 +508,17 @@ def present(
raise click.UsageError( raise click.UsageError(
f"File {config_file} does not exist, check the scene name and make sure to use Slide as your scene base class" f"File {config_file} does not exist, check the scene name and make sure to use Slide as your scene base class"
) )
config = json.load(open(config_file)) try:
presentations.append(Presentation(config, last_frame_next=last_frame_next)) config = PresentationConfig.parse_file(config_file)
presentations.append(Presentation(config))
except ValidationError as e:
raise click.UsageError(str(e))
if os.path.exists(config_path): if os.path.exists(config_path):
config = Config.parse_file(config_path) try:
config = Config.parse_file(config_path)
except ValidationError as e:
raise click.UsageError(str(e))
else: else:
config = Config() config = Config()

View File

@ -3,25 +3,27 @@ import os
import platform import platform
import shutil import shutil
import subprocess import subprocess
from typing import List
from tqdm import tqdm from tqdm import tqdm
from .config import PresentationConfig, SlideConfig, SlideType
from .defaults import FOLDER_PATH from .defaults import FOLDER_PATH
from .manim import FFMPEG_BIN, MANIMGL, Scene, ThreeDScene, config, logger from .manim import FFMPEG_BIN, MANIMGL, Scene, ThreeDScene, config, logger
def reverse_video_path(src: str) -> str:
file, ext = os.path.splitext(src)
return f"{file}_reversed{ext}"
def reverse_video_file(src: str, dst: str): def reverse_video_file(src: str, dst: str):
"""Reverses a video file, writting the result to `dst`."""
command = [FFMPEG_BIN, "-i", src, "-vf", "reverse", dst] command = [FFMPEG_BIN, "-i", src, "-vf", "reverse", dst]
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
process.communicate() process.communicate()
class Slide(Scene): class Slide(Scene):
"""
Inherits from `manim.Scene` or `manimlib.Scene` and provide necessary tools for slides rendering.
"""
def __init__(self, *args, output_folder=FOLDER_PATH, **kwargs): def __init__(self, *args, output_folder=FOLDER_PATH, **kwargs):
if MANIMGL: if MANIMGL:
if not os.path.isdir("videos"): if not os.path.isdir("videos"):
@ -44,7 +46,8 @@ class Slide(Scene):
self.pause_start_animation = 0 self.pause_start_animation = 0
@property @property
def partial_movie_files(self): def partial_movie_files(self) -> List[str]:
"""Returns a list of partial movie files, a.k.a animations."""
if MANIMGL: if MANIMGL:
from manimlib.utils.file_ops import get_sorted_integer_files from manimlib.utils.file_ops import get_sorted_integer_files
@ -59,7 +62,8 @@ class Slide(Scene):
return self.renderer.file_writer.partial_movie_files return self.renderer.file_writer.partial_movie_files
@property @property
def show_progress_bar(self): def show_progress_bar(self) -> bool:
"""Returns True if progress bar should be displayed."""
if MANIMGL: if MANIMGL:
return getattr(super(Scene, self), "show_progress_bar", True) return getattr(super(Scene, self), "show_progress_bar", True)
else: else:
@ -67,19 +71,22 @@ class Slide(Scene):
@property @property
def leave_progress_bar(self): def leave_progress_bar(self):
"""Returns True if progress bar should be left after completed."""
if MANIMGL: if MANIMGL:
return getattr(super(Scene, self), "leave_progress_bars", False) return getattr(super(Scene, self), "leave_progress_bars", False)
else: else:
return config["progress_bar"] == "leave" return config["progress_bar"] == "leave"
def play(self, *args, **kwargs): def play(self, *args, **kwargs):
"""Overloads `self.play` and increment animation count."""
super().play(*args, **kwargs) super().play(*args, **kwargs)
self.current_animation += 1 self.current_animation += 1
def pause(self): def pause(self):
"""Creates a new slide with previous animations."""
self.slides.append( self.slides.append(
dict( SlideConfig(
type="slide", type=SlideType.slide,
start_animation=self.pause_start_animation, start_animation=self.pause_start_animation,
end_animation=self.current_animation, end_animation=self.current_animation,
number=self.current_slide, number=self.current_slide,
@ -89,16 +96,18 @@ class Slide(Scene):
self.pause_start_animation = self.current_animation self.pause_start_animation = self.current_animation
def start_loop(self): def start_loop(self):
"""Starts a loop."""
assert self.loop_start_animation is None, "You cannot nest loops" assert self.loop_start_animation is None, "You cannot nest loops"
self.loop_start_animation = self.current_animation self.loop_start_animation = self.current_animation
def end_loop(self): def end_loop(self):
"""Ends an existing loop."""
assert ( assert (
self.loop_start_animation is not None self.loop_start_animation is not None
), "You have to start a loop before ending it" ), "You have to start a loop before ending it"
self.slides.append( self.slides.append(
dict( SlideConfig(
type="loop", type=SlideType.loop,
start_animation=self.loop_start_animation, start_animation=self.loop_start_animation,
end_animation=self.current_animation, end_animation=self.current_animation,
number=self.current_slide, number=self.current_slide,
@ -109,7 +118,11 @@ class Slide(Scene):
self.pause_start_animation = self.current_animation self.pause_start_animation = self.current_animation
def save_slides(self, use_cache=True): def save_slides(self, use_cache=True):
"""
Saves slides, optionally using cached files.
Note that cached files only work with Manim.
"""
if not os.path.exists(self.output_folder): if not os.path.exists(self.output_folder):
os.mkdir(self.output_folder) os.mkdir(self.output_folder)
@ -139,9 +152,7 @@ class Slide(Scene):
disable=not self.show_progress_bar, disable=not self.show_progress_bar,
): ):
filename = os.path.basename(src_file) filename = os.path.basename(src_file)
_hash, ext = os.path.splitext(filename) rev_filename = "{}_reversed{}".format(*os.path.splitext(filename))
rev_filename = f"{_hash}_reversed{ext}"
dst_file = os.path.join(scene_files_folder, filename) dst_file = os.path.join(scene_files_folder, filename)
# We only copy animation if it was not present # We only copy animation if it was not present
@ -165,9 +176,9 @@ class Slide(Scene):
slide_path = os.path.join(self.output_folder, "%s.json" % (scene_name,)) slide_path = os.path.join(self.output_folder, "%s.json" % (scene_name,))
f = open(slide_path, "w") with open(slide_path, "w") as f:
json.dump(dict(slides=self.slides, files=files), f) f.write(PresentationConfig(slides=self.slides, files=files).json(indent=2))
f.close()
logger.info( logger.info(
f"Slide '{scene_name}' configuration written in '{os.path.abspath(slide_path)}'" f"Slide '{scene_name}' configuration written in '{os.path.abspath(slide_path)}'"
) )
@ -191,4 +202,10 @@ class Slide(Scene):
class ThreeDSlide(Slide, ThreeDScene): class ThreeDSlide(Slide, ThreeDScene):
"""
Inherits from `manim.ThreeDScene` or `manimlib.ThreeDScene` and provide necessary tools for slides rendering.
Note that ManimGL does not need ThreeDScene for 3D rendering in recent versions, see `example.py`.
"""
pass pass

View File

@ -1,18 +1,25 @@
import importlib.util
import os
import sys import sys
import setuptools import setuptools
from manim_slides import __version__ as version
if sys.version_info < (3, 7): if sys.version_info < (3, 7):
raise RuntimeError("This package requires Python 3.7+") raise RuntimeError("This package requires Python 3.7+")
spec = importlib.util.spec_from_file_location(
"__version__", os.path.join("manim_slides", "__version__.py")
)
version = importlib.util.module_from_spec(spec)
spec.loader.exec_module(version)
with open("README.md", "r") as f: with open("README.md", "r") as f:
long_description = f.read() long_description = f.read()
setuptools.setup( setuptools.setup(
name="manim-slides", name="manim-slides",
version=version, version=version.__version__,
author="Jérome Eertmans (previously, Federico A. Galatolo)", author="Jérome Eertmans (previously, Federico A. Galatolo)",
author_email="jeertmans@icloud.com (resp., federico.galatolo@ing.unipi.it)", author_email="jeertmans@icloud.com (resp., federico.galatolo@ing.unipi.it)",
description="Tool for live presentations using manim", description="Tool for live presentations using manim",