mirror of
https://github.com/jeertmans/manim-slides.git
synced 2025-05-17 18:55:53 +08:00
Compare commits
38 Commits
Author | SHA1 | Date | |
---|---|---|---|
0d97bdabb8 | |||
8e50adf0ba | |||
d583d591c1 | |||
c243fe3481 | |||
eba84a44fc | |||
c8cf2e6074 | |||
743329c043 | |||
ed1b2eb698 | |||
cda304fef0 | |||
3e6c562b1d | |||
85c295a2c1 | |||
ac5582753c | |||
c53e410ff8 | |||
e7c2bcfe98 | |||
ac486f4f23 | |||
0bd29202e7 | |||
061706922b | |||
574c545f86 | |||
dab96c980d | |||
78941e10fc | |||
255c86ba25 | |||
981b34faf5 | |||
cc42a1bac5 | |||
efa6fe05cf | |||
11494209e4 | |||
e0669707e7 | |||
51ca828920 | |||
2076d65944 | |||
9640605ae9 | |||
efbe488660 | |||
e82ab99186 | |||
7128c01ab5 | |||
c6d44b0b3b | |||
14b334fa6f | |||
68858c3c33 | |||
3520c42d7c | |||
1720a7d742 | |||
d9eab15fa5 |
8
.github/workflows/python-publish.yml
vendored
8
.github/workflows/python-publish.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
os: [ubuntu-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@ -21,6 +21,12 @@ jobs:
|
||||
# Used to host cibuildwheel
|
||||
- uses: actions/setup-python@v2
|
||||
|
||||
- name: Install deps
|
||||
run: sudo apt-get install libsdl-pango-dev
|
||||
|
||||
- name: Install packages
|
||||
run: python -m pip install -U manim tqdm
|
||||
|
||||
- name: Install cibuildwheel
|
||||
run: python -m pip install -U setuptools wheel pip
|
||||
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -13,3 +13,7 @@ __pycache__/
|
||||
slides/
|
||||
|
||||
.manim-slides.json
|
||||
|
||||
videos/
|
||||
|
||||
images/
|
||||
|
@ -5,6 +5,10 @@ repos:
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 22.6.0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.10.1
|
||||
hooks:
|
||||
|
22
README.md
22
README.md
@ -3,9 +3,9 @@
|
||||

|
||||
# 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/) or [manimgl](https://3b1b.github.io/manim/). `manim-slides` will automatically detect the one you are using!
|
||||
|
||||
> **_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.
|
||||
> **_NOTE:_** This project extends the work of [`manim-presentation`](https://github.com/galatolofederico/manim-presentation), with a lot more features!
|
||||
|
||||
## Install
|
||||
|
||||
@ -29,6 +29,7 @@ call `self.pause()` when you want to pause the playback and wait for an input to
|
||||
Wrap a series of animations between `self.start_loop()` and `self.stop_loop()` when you want to loop them (until input to continue):
|
||||
```python
|
||||
from manim import *
|
||||
# or: from manimlib import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class Example(Slide):
|
||||
@ -65,6 +66,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 |
|
||||
|
||||
@ -75,7 +77,7 @@ You can run the **configuration wizard** with:
|
||||
manim-slides wizard
|
||||
```
|
||||
|
||||
Alternatively you can specify different keybindings creating a file named `.manim-slides.json` with the keys: `QUIT` `CONTINUE` `BACK` `REWIND` and `PLAY_PAUSE`.
|
||||
Alternatively you can specify different keybindings creating a file named `.manim-slides.json` with the keys: `QUIT` `CONTINUE` `BACK` `REVERSE` `REWIND` and `PLAY_PAUSE`.
|
||||
|
||||
A default file can be created with:
|
||||
```
|
||||
@ -95,11 +97,15 @@ cd manim-slides
|
||||
Install `manim` and `manim-slides`:
|
||||
```
|
||||
pip install manim manim-slides
|
||||
# or
|
||||
pip install manimgl manim-slides
|
||||
```
|
||||
|
||||
Render the example scene:
|
||||
```
|
||||
manim -qh example.py
|
||||
manim -qh example.py Example
|
||||
# or
|
||||
manimgl --hd example.py Example
|
||||
```
|
||||
|
||||
Run the presentation
|
||||
@ -122,11 +128,11 @@ 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)
|
||||
- [ ] Handle 3D scenes out of the box
|
||||
- [ ] Can work with both community and 3b1b versions (not tested)
|
||||
- [x] Play animation in reverse [#9](https://github.com/galatolofederico/manim-presentation/issues/9)
|
||||
- [x] Handle 3D scenes out of the box
|
||||
- [x] Support for both `manim` and `manimgl` modules
|
||||
- [ ] 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
|
||||
|
||||
|
33
example.py
33
example.py
@ -1,4 +1,6 @@
|
||||
from manim import *
|
||||
# If you want to use manimgl, uncomment change
|
||||
# manim to manimlib
|
||||
from manimlib import *
|
||||
|
||||
from manim_slides import Slide, ThreeDSlide
|
||||
|
||||
@ -28,25 +30,38 @@ class Example(Slide):
|
||||
# Each slide MUST end with an animation (a self.wait is considered an animation)
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
|
||||
|
||||
class ThreeDExample(ThreeDSlide):
|
||||
def construct(self):
|
||||
axes = ThreeDAxes()
|
||||
circle=Circle()
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
dot = Dot(color=RED)
|
||||
|
||||
self.add(axes)
|
||||
|
||||
self.set_camera_orientation(phi=75 * DEGREES, theta=30 * DEGREES)
|
||||
self.add(circle,axes)
|
||||
|
||||
self.begin_ambient_camera_rotation(rate=0.1)
|
||||
self.play(GrowFromCenter(circle))
|
||||
self.begin_ambient_camera_rotation(rate=75 * DEGREES / 4)
|
||||
|
||||
self.pause()
|
||||
|
||||
self.start_loop()
|
||||
self.play(MoveAlongPath(dot, circle), run_time=4, rate_func=linear)
|
||||
self.end_loop()
|
||||
|
||||
self.stop_ambient_camera_rotation()
|
||||
self.move_camera(phi=75 * DEGREES, theta=30 * DEGREES)
|
||||
self.wait()
|
||||
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
self.pause()
|
||||
|
||||
self.play(dot.animate.move_to(RIGHT * 3))
|
||||
self.pause()
|
||||
|
||||
self.start_loop()
|
||||
self.play(circle.animate.move_to(ORIGIN + UP))
|
||||
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
|
||||
self.end_loop()
|
||||
|
||||
self.play(circle.animate.move_to(ORIGIN))
|
||||
|
||||
# TODO: fixit
|
||||
# Each slide MUST end with an animation (a self.wait is considered an animation)
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
|
@ -1 +1 @@
|
||||
__version__ = "3.0.1"
|
||||
__version__ = "3.2.2"
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
||||
@ -12,6 +12,8 @@ from .wizard import init, wizard
|
||||
def cli():
|
||||
pass
|
||||
|
||||
|
||||
cli.add_command(list_scenes)
|
||||
cli.add_command(present)
|
||||
cli.add_command(wizard)
|
||||
cli.add_command(init)
|
||||
|
45
manim_slides/manim.py
Normal file
45
manim_slides/manim.py
Normal file
@ -0,0 +1,45 @@
|
||||
import sys
|
||||
from importlib.util import find_spec
|
||||
|
||||
MANIM_PACKAGE_NAME = "manim"
|
||||
MANIM_AVAILABLE = find_spec(MANIM_PACKAGE_NAME) is not None
|
||||
MANIM_IMPORTED = MANIM_PACKAGE_NAME in sys.modules
|
||||
|
||||
MANIMGL_PACKAGE_NAME = "manimlib"
|
||||
MANIMGL_AVAILABLE = find_spec(MANIMGL_PACKAGE_NAME) is not None
|
||||
MANIMGL_IMPORTED = MANIMGL_PACKAGE_NAME in sys.modules
|
||||
|
||||
if MANIM_IMPORTED and MANIMGL_IMPORTED:
|
||||
from manim import logger
|
||||
|
||||
logger.warn(
|
||||
"Both manim and manimgl are installed, therefore `manim-slide` needs to need which one to use. Please only import one of the two modules so that `manim-slide` knows which one to use. Here, manim is used by default"
|
||||
)
|
||||
MANIM = True
|
||||
MANIMGL = False
|
||||
elif MANIM_AVAILABLE and not MANIMGL_IMPORTED:
|
||||
MANIM = True
|
||||
MANIMGL = False
|
||||
elif MANIMGL_AVAILABLE:
|
||||
MANIM = False
|
||||
MANIMGL = True
|
||||
else:
|
||||
raise ImportError(
|
||||
"Either manim (community) or manimgl (3b1b) package must be installed"
|
||||
)
|
||||
|
||||
|
||||
FFMPEG_BIN = None
|
||||
|
||||
if MANIMGL:
|
||||
from manimlib import Scene, ThreeDScene, config
|
||||
from manimlib.constants import FFMPEG_BIN
|
||||
from manimlib.logger import log as logger
|
||||
|
||||
else:
|
||||
from manim import Scene, ThreeDScene, config, logger
|
||||
|
||||
try: # For manim<v0.16.0.post0
|
||||
from manim.constants import FFMPEG_BIN as FFMPEG_BIN
|
||||
except ImportError:
|
||||
FFMPEG_BIN = config.ffmpeg_executable
|
@ -1,9 +1,13 @@
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import time
|
||||
from enum import Enum
|
||||
from enum import IntEnum, auto, unique
|
||||
|
||||
if platform.system() == "Windows":
|
||||
import ctypes
|
||||
|
||||
import click
|
||||
import cv2
|
||||
@ -12,32 +16,35 @@ 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
|
||||
|
||||
|
||||
class State(Enum):
|
||||
PLAYING = 0
|
||||
PAUSED = 1
|
||||
WAIT = 2
|
||||
END = 3
|
||||
@unique
|
||||
class State(IntEnum):
|
||||
PLAYING = auto()
|
||||
PAUSED = auto()
|
||||
WAIT = auto()
|
||||
END = auto()
|
||||
|
||||
def __str__(self):
|
||||
if self.value == 0: return "Playing"
|
||||
if self.value == 1: return "Paused"
|
||||
if self.value == 2: return "Wait"
|
||||
if self.value == 3: return "End"
|
||||
return "..."
|
||||
return self.name.capitalize()
|
||||
|
||||
def now():
|
||||
|
||||
def now() -> int:
|
||||
return round(time.time() * 1000)
|
||||
|
||||
def fix_time(x):
|
||||
|
||||
def fix_time(x: float) -> float:
|
||||
return x if x > 0 else 1
|
||||
|
||||
|
||||
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 = []
|
||||
|
||||
@ -48,14 +55,15 @@ class Presentation:
|
||||
def add_last_slide(self):
|
||||
last_slide_end = self.slides[-1]["end_animation"]
|
||||
last_animation = len(self.files)
|
||||
self.slides.append(dict(
|
||||
start_animation = last_slide_end,
|
||||
end_animation = last_animation,
|
||||
type = "last",
|
||||
number = len(self.slides) + 1,
|
||||
terminated = False
|
||||
))
|
||||
|
||||
self.slides.append(
|
||||
dict(
|
||||
start_animation=last_slide_end,
|
||||
end_animation=last_animation,
|
||||
type="last",
|
||||
number=len(self.slides) + 1,
|
||||
terminated=False,
|
||||
)
|
||||
)
|
||||
|
||||
def reset(self):
|
||||
self.current_animation = 0
|
||||
@ -74,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):
|
||||
@ -135,7 +158,10 @@ class Presentation:
|
||||
self.rewind_slide()
|
||||
elif self.current_slide["type"] == "last":
|
||||
self.current_slide["terminated"] = True
|
||||
elif self.current_slide["type"] == "last" and self.current_slide["end_animation"] == self.current_animation:
|
||||
elif (
|
||||
self.current_slide["type"] == "last"
|
||||
and self.current_slide["end_animation"] == self.current_animation
|
||||
):
|
||||
state = State.WAIT
|
||||
else:
|
||||
# Play next video!
|
||||
@ -160,9 +186,27 @@ class Display:
|
||||
self.lag = 0
|
||||
self.last_time = now()
|
||||
|
||||
if platform.system() == "Windows":
|
||||
user32 = ctypes.windll.user32
|
||||
self.screen_width, self.screen_height = user32.GetSystemMetrics(
|
||||
0
|
||||
), user32.GetSystemMetrics(1)
|
||||
|
||||
if fullscreen:
|
||||
cv2.namedWindow("Video", cv2.WND_PROP_FULLSCREEN)
|
||||
cv2.setWindowProperty("Video", cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN)
|
||||
cv2.setWindowProperty(
|
||||
"Video", cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN
|
||||
)
|
||||
|
||||
def resize_frame_to_screen(self, frame: np.ndarray):
|
||||
frame_height, frame_width = frame.shape[:2]
|
||||
|
||||
scale_height = self.screen_height / frame_height
|
||||
scale_width = self.screen_width / frame_width
|
||||
|
||||
scale = min(scale_height, scale_width)
|
||||
|
||||
return cv2.resize(frame, (int(scale * frame_height, scale * frame_width)))
|
||||
|
||||
@property
|
||||
def current_presentation(self):
|
||||
@ -170,7 +214,9 @@ class Display:
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
self.lastframe, self.state = self.current_presentation.update_state(self.state)
|
||||
self.lastframe, self.state = self.current_presentation.update_state(
|
||||
self.state
|
||||
)
|
||||
if self.state == State.PLAYING or self.state == State.PAUSED:
|
||||
if self.start_paused:
|
||||
self.state = State.PAUSED
|
||||
@ -188,7 +234,13 @@ class Display:
|
||||
def show_video(self):
|
||||
self.lag = now() - self.last_time
|
||||
self.last_time = now()
|
||||
cv2.imshow("Video", self.lastframe)
|
||||
|
||||
frame = self.lastframe
|
||||
|
||||
if platform.system() == "Windows":
|
||||
frame = self.resize_frame_to_screen(frame)
|
||||
|
||||
cv2.imshow("Video", frame)
|
||||
|
||||
def show_info(self):
|
||||
info = np.zeros((130, 420), np.uint8)
|
||||
@ -200,39 +252,34 @@ class Display:
|
||||
info,
|
||||
f"Animation: {self.current_presentation.current_animation}",
|
||||
(grid_x[0], grid_y[0]),
|
||||
*font_args
|
||||
)
|
||||
cv2.putText(
|
||||
info,
|
||||
f"State: {self.state}",
|
||||
(grid_x[1], grid_y[0]),
|
||||
*font_args
|
||||
*font_args,
|
||||
)
|
||||
cv2.putText(info, f"State: {self.state}", (grid_x[1], grid_y[0]), *font_args)
|
||||
|
||||
cv2.putText(
|
||||
info,
|
||||
f"Slide {self.current_presentation.current_slide['number']}/{len(self.current_presentation.slides)}",
|
||||
(grid_x[0], grid_y[1]),
|
||||
*font_args
|
||||
*font_args,
|
||||
)
|
||||
cv2.putText(
|
||||
info,
|
||||
f"Slide Type: {self.current_presentation.current_slide['type']}",
|
||||
(grid_x[1], grid_y[1]),
|
||||
*font_args
|
||||
*font_args,
|
||||
)
|
||||
|
||||
cv2.putText(
|
||||
info,
|
||||
f"Scene {self.current_presentation_i + 1}/{len(self.presentations)}",
|
||||
((grid_x[0]+grid_x[1])//2, grid_y[2]),
|
||||
*font_args
|
||||
((grid_x[0] + grid_x[1]) // 2, grid_y[2]),
|
||||
*font_args,
|
||||
)
|
||||
|
||||
cv2.imshow("Info", info)
|
||||
|
||||
def handle_key(self):
|
||||
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))
|
||||
|
||||
if self.config.QUIT.match(key):
|
||||
@ -241,7 +288,9 @@ class Display:
|
||||
self.state = State.PAUSED
|
||||
elif self.state == State.PAUSED and self.config.PLAY_PAUSE.match(key):
|
||||
self.state = State.PLAYING
|
||||
elif self.state == State.WAIT and (self.config.CONTINUE.match(key) or self.config.PLAY_PAUSE.match(key)):
|
||||
elif self.state == State.WAIT and (
|
||||
self.config.CONTINUE.match(key) or self.config.PLAY_PAUSE.match(key)
|
||||
):
|
||||
self.current_presentation.next()
|
||||
self.state = State.PLAYING
|
||||
elif self.state == State.PLAYING and self.config.CONTINUE.match(key):
|
||||
@ -254,32 +303,105 @@ 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
|
||||
|
||||
|
||||
def quit(self):
|
||||
cv2.destroyAllWindows()
|
||||
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
|
||||
@click.option("--folder", default=FOLDER_PATH, type=click.Path(exists=True, file_okay=False), help="Set slides folder.")
|
||||
@click.option(
|
||||
"--folder",
|
||||
default=FOLDER_PATH,
|
||||
type=click.Path(exists=True, file_okay=False),
|
||||
help="Set slides folder.",
|
||||
)
|
||||
@click.option("--start-paused", is_flag=True, help="Start paused.")
|
||||
@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(
|
||||
"--last-frame-next",
|
||||
is_flag=True,
|
||||
help="Show the next animation first frame as last frame (hack).",
|
||||
)
|
||||
@click.help_option("-h", "--help")
|
||||
def present(scenes, config_path, folder, start_paused, fullscreen, last_frame_next):
|
||||
"""Present the different scenes"""
|
||||
"""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 click.UsageError(
|
||||
"Please only enter numbers displayed on the screen."
|
||||
)
|
||||
|
||||
return [scene_choices[i] for i in indices]
|
||||
|
||||
if len(scene_choices) == 0:
|
||||
raise click.UsageError(
|
||||
"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:
|
||||
raise click.UsageError(e)
|
||||
|
||||
presentations = list()
|
||||
for scene in scenes:
|
||||
config_file = os.path.join(folder, f"{scene}.json")
|
||||
if not os.path.exists(config_file):
|
||||
raise Exception(f"File {config_file} does not exist, check the scene name and make sure to use Slide as your scene base class")
|
||||
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"
|
||||
)
|
||||
config = json.load(open(config_file))
|
||||
presentations.append(Presentation(config, last_frame_next=last_frame_next))
|
||||
|
||||
@ -288,5 +410,7 @@ def present(scenes, config_path, folder, start_paused, fullscreen, last_frame_ne
|
||||
else:
|
||||
config = Config()
|
||||
|
||||
display = Display(presentations, config=config, start_paused=start_paused, fullscreen=fullscreen)
|
||||
display = Display(
|
||||
presentations, config=config, start_paused=start_paused, fullscreen=fullscreen
|
||||
)
|
||||
display.run()
|
||||
|
@ -1,15 +1,41 @@
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from manim import Scene, ThreeDScene, config
|
||||
from tqdm import tqdm
|
||||
|
||||
from .defaults import FOLDER_PATH
|
||||
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):
|
||||
command = [FFMPEG_BIN, "-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(Slide, self).__init__(*args, **kwargs)
|
||||
if MANIMGL:
|
||||
if not os.path.isdir("videos"):
|
||||
os.mkdir("videos")
|
||||
kwargs["file_writer_config"] = {
|
||||
"break_into_partial_movies": True,
|
||||
"output_directory": "",
|
||||
"write_to_movie": True,
|
||||
}
|
||||
|
||||
kwargs["preview"] = False
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.output_folder = output_folder
|
||||
self.slides = list()
|
||||
self.current_slide = 1
|
||||
@ -17,17 +43,48 @@ class Slide(Scene):
|
||||
self.loop_start_animation = None
|
||||
self.pause_start_animation = 0
|
||||
|
||||
@property
|
||||
def partial_movie_files(self):
|
||||
if MANIMGL:
|
||||
from manimlib.utils.file_ops import get_sorted_integer_files
|
||||
|
||||
kwargs = {
|
||||
"remove_non_integer_files": True,
|
||||
"extension": self.file_writer.movie_file_extension,
|
||||
}
|
||||
return get_sorted_integer_files(
|
||||
self.file_writer.partial_movie_directory, **kwargs
|
||||
)
|
||||
else:
|
||||
return self.renderer.file_writer.partial_movie_files
|
||||
|
||||
@property
|
||||
def show_progress_bar(self):
|
||||
if MANIMGL:
|
||||
return getattr(super(Scene, self), "show_progress_bar", True)
|
||||
else:
|
||||
return config["progress_bar"] != "none"
|
||||
|
||||
@property
|
||||
def leave_progress_bar(self):
|
||||
if MANIMGL:
|
||||
return getattr(super(Scene, self), "leave_progress_bars", False)
|
||||
else:
|
||||
return config["progress_bar"] == "leave"
|
||||
|
||||
def play(self, *args, **kwargs):
|
||||
super(Slide, self).play(*args, **kwargs)
|
||||
super().play(*args, **kwargs)
|
||||
self.current_animation += 1
|
||||
|
||||
def pause(self):
|
||||
self.slides.append(dict(
|
||||
type="slide",
|
||||
start_animation=self.pause_start_animation,
|
||||
end_animation=self.current_animation,
|
||||
number=self.current_slide
|
||||
))
|
||||
self.slides.append(
|
||||
dict(
|
||||
type="slide",
|
||||
start_animation=self.pause_start_animation,
|
||||
end_animation=self.current_animation,
|
||||
number=self.current_slide,
|
||||
)
|
||||
)
|
||||
self.current_slide += 1
|
||||
self.pause_start_animation = self.current_animation
|
||||
|
||||
@ -36,25 +93,22 @@ class Slide(Scene):
|
||||
self.loop_start_animation = self.current_animation
|
||||
|
||||
def end_loop(self):
|
||||
assert self.loop_start_animation is not None, "You have to start a loop before ending it"
|
||||
self.slides.append(dict(
|
||||
type="loop",
|
||||
start_animation=self.loop_start_animation,
|
||||
end_animation=self.current_animation,
|
||||
number=self.current_slide
|
||||
))
|
||||
assert (
|
||||
self.loop_start_animation is not None
|
||||
), "You have to start a loop before ending it"
|
||||
self.slides.append(
|
||||
dict(
|
||||
type="loop",
|
||||
start_animation=self.loop_start_animation,
|
||||
end_animation=self.current_animation,
|
||||
number=self.current_slide,
|
||||
)
|
||||
)
|
||||
self.current_slide += 1
|
||||
self.loop_start_animation = None
|
||||
self.pause_start_animation = self.current_animation
|
||||
|
||||
def render(self, *args, **kwargs):
|
||||
# We need to disable the caching limit since we rely on intermidiate files
|
||||
max_files_cached = config["max_files_cached"]
|
||||
config["max_files_cached"] = float("inf")
|
||||
|
||||
super(Slide, self).render(*args, **kwargs)
|
||||
|
||||
config["max_files_cached"] = max_files_cached
|
||||
def save_slides(self, use_cache=True):
|
||||
|
||||
if not os.path.exists(self.output_folder):
|
||||
os.mkdir(self.output_folder)
|
||||
@ -66,24 +120,75 @@ 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)
|
||||
elif not use_cache:
|
||||
shutil.rmtree(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.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,
|
||||
):
|
||||
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")
|
||||
json.dump(dict(
|
||||
slides=self.slides,
|
||||
files=files
|
||||
), f)
|
||||
f.close()
|
||||
logger.info(
|
||||
f"Copied {len(files)} animations to '{os.path.abspath(scene_files_folder)}' and generated reversed animations"
|
||||
)
|
||||
|
||||
class ThreeDSlide(ThreeDScene, Slide):
|
||||
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)}'"
|
||||
)
|
||||
|
||||
def run(self, *args, **kwargs):
|
||||
"""MANIMGL renderer"""
|
||||
super().run(*args, **kwargs)
|
||||
self.save_slides(use_cache=False)
|
||||
|
||||
def render(self, *args, **kwargs):
|
||||
"""MANIM render"""
|
||||
# We need to disable the caching limit since we rely on intermidiate files
|
||||
max_files_cached = config["max_files_cached"]
|
||||
config["max_files_cached"] = float("inf")
|
||||
|
||||
super().render(*args, **kwargs)
|
||||
|
||||
config["max_files_cached"] = max_files_cached
|
||||
|
||||
self.save_slides()
|
||||
|
||||
|
||||
class ThreeDSlide(Slide, ThreeDScene):
|
||||
pass
|
||||
|
@ -27,19 +27,24 @@ def wizard(config_path, force, merge):
|
||||
"""Launch configuration wizard."""
|
||||
return _init(config_path, force, merge, skip_interactive=False)
|
||||
|
||||
|
||||
@click.command()
|
||||
@config_options
|
||||
def init(config_path, force, merge, skip_interactive=False):
|
||||
"""Initialize a new default configuration file."""
|
||||
return _init(config_path, force, merge, skip_interactive=True)
|
||||
|
||||
|
||||
def _init(config_path, force, merge, skip_interactive=False):
|
||||
|
||||
if os.path.exists(config_path):
|
||||
click.secho(f"The `{CONFIG_PATH}` configuration file exists")
|
||||
|
||||
if not force and not merge:
|
||||
choice = click.prompt("Do you want to continue and (o)verwrite / (m)erge it, or (q)uit?", type=click.Choice(["o", "m", "q"], case_sensitive=False))
|
||||
choice = click.prompt(
|
||||
"Do you want to continue and (o)verwrite / (m)erge it, or (q)uit?",
|
||||
type=click.Choice(["o", "m", "q"], case_sensitive=False),
|
||||
)
|
||||
|
||||
force = choice == "o"
|
||||
merge = choice == "m"
|
||||
@ -52,7 +57,6 @@ def _init(config_path, force, merge, skip_interactive=False):
|
||||
click.secho("Exiting.")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
config = Config()
|
||||
|
||||
if not skip_interactive:
|
||||
|
7
setup.py
7
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+")
|
||||
@ -30,10 +28,11 @@ setuptools.setup(
|
||||
python_requires=">=3.7",
|
||||
install_requires=[
|
||||
"click>=8.0",
|
||||
"click-default-group>=1.2"
|
||||
"click-default-group>=1.2",
|
||||
"numpy>=1.19.3",
|
||||
"pydantic>=1.9.1",
|
||||
"opencv-python>=4.6",
|
||||
"tqdm",
|
||||
],
|
||||
classifiers=[
|
||||
"Programming Language :: Python :: 3",
|
||||
|
Reference in New Issue
Block a user