mirror of
https://github.com/jeertmans/manim-slides.git
synced 2025-05-18 03:05:21 +08:00
Compare commits
42 Commits
Author | SHA1 | Date | |
---|---|---|---|
db5fa33e0c | |||
da0972ef43 | |||
8081617e29 | |||
9247568531 | |||
94e399ba91 | |||
b3f988421a | |||
b9eb2b395a | |||
f3d2c4e731 | |||
ddeb20646d | |||
588c4b285f | |||
6fdf6d670b | |||
2a8b6734ec | |||
d2f04341fa | |||
19fa17212c | |||
73107f5065 | |||
0429cf3543 | |||
87bbc9a8c6 | |||
c0a5a70f93 | |||
ea171e1301 | |||
d1c3e9c075 | |||
38e03db9e9 | |||
2eafa0b82e | |||
8a4fea687d | |||
128d6718ae | |||
22cbb7ec94 | |||
5feb13da10 | |||
90b2e4d46b | |||
43b9fa7cf7 | |||
1cc070db86 | |||
c5274fb57f | |||
2a136ed585 | |||
c9390f0e59 | |||
41de205675 | |||
133ec17ebb | |||
09199777e0 | |||
bfcf7db26e | |||
b6522f4756 | |||
4216299b39 | |||
cc8df92596 | |||
ef22141f91 | |||
6881e24954 | |||
d7d6ac610a |
22
.github/workflows/deploy-pipy.yaml
vendored
22
.github/workflows/deploy-pipy.yaml
vendored
@ -1,22 +0,0 @@
|
||||
name: Publish top PyPI
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish to PyPI
|
||||
if: startsWith(github.ref, 'refs/tags')
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Install
|
||||
run: python -m pip install build --user
|
||||
- name: Build binary wheel and a source tarball
|
||||
run: python -m build --sdist --wheel --outdir dist/ .
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@master
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
49
.github/workflows/python-publish.yml
vendored
Normal file
49
.github/workflows/python-publish.yml
vendored
Normal file
@ -0,0 +1,49 @@
|
||||
# From: https://github.com/pypa/cibuildwheel
|
||||
name: Upload Python Package
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build_wheels:
|
||||
name: Build wheels on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
# Used to host cibuildwheel
|
||||
- uses: actions/setup-python@v2
|
||||
|
||||
- name: Install cibuildwheel
|
||||
run: python -m pip install -U setuptools wheel pip
|
||||
|
||||
- name: Build wheels
|
||||
run: python setup.py sdist
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: dist
|
||||
path: dist/*.tar.*
|
||||
|
||||
release:
|
||||
name: Release
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ build_wheels ]
|
||||
steps:
|
||||
- uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
- name: Upload to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@master
|
||||
with:
|
||||
user: __token__
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -8,4 +8,8 @@ __pycache__/
|
||||
/media
|
||||
/presentation
|
||||
|
||||
/.vscode
|
||||
/.vscode
|
||||
|
||||
slides/
|
||||
|
||||
.manim-slides.json
|
||||
|
13
.pre-commit-config.yaml
Normal file
13
.pre-commit-config.yaml
Normal file
@ -0,0 +1,13 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.3.0
|
||||
hooks:
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.10.1
|
||||
hooks:
|
||||
- id: isort
|
||||
name: isort (python)
|
||||
args: ["--profile", "black"]
|
@ -671,4 +671,4 @@ into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
102
README.md
102
README.md
@ -1,32 +1,35 @@
|
||||
# manim-presentation
|
||||
[![Latest Release][pypi-version-badge]][pypi-version-url]
|
||||
[![Python version][pypi-python-version-badge]][pypi-version-url]
|
||||

|
||||
# Manim Slides
|
||||
|
||||
Tool for live presentations using [manim](https://www.manim.community/)
|
||||
Tool for live presentations using either [manim](http://3b1b.github.io/manim/) or [manim-community](https://www.manim.community/).
|
||||
|
||||
> **_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.
|
||||
|
||||
## Install
|
||||
|
||||
```
|
||||
pip install manim_presentation opencv-python
|
||||
pip install manim-slides
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Use the class `Slide` as your scenes base class
|
||||
|
||||
Use the class `Slide` as your scenes base class:
|
||||
```python
|
||||
from manim_presentation import Slide
|
||||
from manim_slides import Slide
|
||||
|
||||
class Example(Slide):
|
||||
def construct(self):
|
||||
...
|
||||
```
|
||||
|
||||
call `self.pause()` when you want to pause the playback and wait for an input to continue (check the keybindings)
|
||||
|
||||
Wrap a series of animations between `self.start_loop()` and `self.stop_loop()` when you want to loop them (until input to continue)
|
||||
call `self.pause()` when you want to pause the playback and wait for an input to continue (check the keybindings).
|
||||
|
||||
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 *
|
||||
from manim_presentation import Slide
|
||||
from manim_slides import Slide
|
||||
|
||||
class Example(Slide):
|
||||
def construct(self):
|
||||
@ -46,15 +49,16 @@ class Example(Slide):
|
||||
self.wait()
|
||||
```
|
||||
|
||||
You **must** end your `Slide` with a `self.play(...)` or a `self.wait(..)`.
|
||||
|
||||
To start the presentation using `Scene1`, `Scene2` and so on simply run:
|
||||
|
||||
```
|
||||
manim_presentation Scene1 Scene2...
|
||||
manim-slides Scene1 Scene2...
|
||||
```
|
||||
|
||||
## Default Keybindings
|
||||
## Keybindings
|
||||
|
||||
Default keybindings to control the presentation
|
||||
Default keybindings to control the presentation:
|
||||
|
||||
| Keybinding | Action |
|
||||
|:-----------:|:------------------------:|
|
||||
@ -64,42 +68,70 @@ Default keybindings to control the presentation
|
||||
| Spacebar | Play/Pause |
|
||||
| Q | Quit |
|
||||
|
||||
|
||||
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`.
|
||||
|
||||
A default file can be created with:
|
||||
```
|
||||
manim-slides init
|
||||
```
|
||||
|
||||
> **_NOTE:_** `manim-slides` uses `cv2.waitKeyEx()` to wait for keypresses, and directly registers the key code.
|
||||
|
||||
## Run Example
|
||||
|
||||
Clone this repository
|
||||
|
||||
Clone this repository:
|
||||
```
|
||||
git clone https://github.com/galatolofederico/manim-presentation.git
|
||||
cd manim-presentation
|
||||
git clone https://github.com/jeertmans/manim-slides.git
|
||||
cd manim-slides
|
||||
```
|
||||
|
||||
Create a virtualenv
|
||||
|
||||
Install `manim` and `manim-slides`:
|
||||
```
|
||||
virtualenv --python=python3.7 env
|
||||
. ./env/bin/activate
|
||||
pip install manim manim-slides
|
||||
```
|
||||
|
||||
Install `manim` and `manim_presentation`
|
||||
|
||||
```
|
||||
pip install manim manim_presentation opencv-python
|
||||
```
|
||||
|
||||
Render the example scene
|
||||
|
||||
Render the example scene:
|
||||
```
|
||||
manim -qh example.py
|
||||
```
|
||||
|
||||
Run the presentation
|
||||
```
|
||||
manim-slides Example
|
||||
```
|
||||
|
||||
```
|
||||
manim_presentation Example
|
||||
```
|
||||
Below is a small recording of me playing with the slides back and forth.
|
||||
|
||||

|
||||
|
||||
|
||||
## Comparison with original `manim-presentation`
|
||||
|
||||
Here are a few things that I implemented (or that I'm planning to implement) on top of the original work:
|
||||
|
||||
- [x] Allowing multiple keys to control one action (useful when you use a laser pointer)
|
||||
- [x] More robust config files checking
|
||||
- [x] Dependencies are installed with the package
|
||||
- [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)
|
||||
- [ ] Generate docs online
|
||||
- [ ] Fix the quality problem on Windows platforms with `fullscreen` flag
|
||||
|
||||
## Contributions and license
|
||||
|
||||
The code is released as Free Software under the [GNU/GPLv3](https://choosealicense.com/licenses/gpl-3.0/) license. Copying, adapting e republishing it is not only consent but also encouraged.
|
||||
The code is released as Free Software under the [GNU/GPLv3](https://choosealicense.com/licenses/gpl-3.0/) license. Copying, adapting and republishing it is not only consent but also encouraged.
|
||||
|
||||
For any further question feel free to reach me at [federico.galatolo@ing.unipi.it](mailto:federico.galatolo@ing.unipi.it) or on Telegram [@galatolo](https://t.me/galatolo)
|
||||
[pypi-version-badge]: https://img.shields.io/pypi/v/manim-slides?label=manim-slides
|
||||
[pypi-version-url]: https://pypi.org/project/manim-slides/
|
||||
[pypi-python-version-badge]: https://img.shields.io/pypi/pyversions/manim-slides
|
||||
|
35
example.py
35
example.py
@ -1,5 +1,7 @@
|
||||
from manim import *
|
||||
from manim_presentation import Slide
|
||||
|
||||
from manim_slides import Slide, ThreeDSlide
|
||||
|
||||
|
||||
class Example(Slide):
|
||||
def construct(self):
|
||||
@ -16,4 +18,35 @@ class Example(Slide):
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
self.pause()
|
||||
|
||||
self.play(dot.animate.move_to(RIGHT * 3))
|
||||
self.pause()
|
||||
|
||||
self.start_loop()
|
||||
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
|
||||
self.end_loop()
|
||||
|
||||
# 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()
|
||||
|
||||
self.set_camera_orientation(phi=75 * DEGREES, theta=30 * DEGREES)
|
||||
self.add(circle,axes)
|
||||
|
||||
self.begin_ambient_camera_rotation(rate=0.1)
|
||||
self.pause()
|
||||
|
||||
self.stop_ambient_camera_rotation()
|
||||
self.move_camera(phi=75 * DEGREES, theta=30 * DEGREES)
|
||||
self.wait()
|
||||
|
||||
self.start_loop()
|
||||
self.play(circle.animate.move_to(ORIGIN + UP))
|
||||
self.end_loop()
|
||||
|
||||
self.play(circle.animate.move_to(ORIGIN))
|
||||
|
||||
# TODO: fixit
|
||||
|
@ -1 +0,0 @@
|
||||
from manim_presentation.slide import Slide
|
1
manim_slides/__init__.py
Normal file
1
manim_slides/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .slide import Slide, ThreeDSlide
|
28
manim_slides/commons.py
Normal file
28
manim_slides/commons.py
Normal file
@ -0,0 +1,28 @@
|
||||
import click
|
||||
|
||||
from .defaults import CONFIG_PATH
|
||||
|
||||
|
||||
def config_path_option(function):
|
||||
return click.option(
|
||||
"-c",
|
||||
"--config",
|
||||
"config_path",
|
||||
default=CONFIG_PATH,
|
||||
type=click.Path(dir_okay=False),
|
||||
help="Set path to configuration file.",
|
||||
)(function)
|
||||
|
||||
|
||||
def config_options(function):
|
||||
function = config_path_option(function)
|
||||
function = click.option(
|
||||
"-f", "--force", is_flag=True, help="Overwrite any existing configuration file."
|
||||
)(function)
|
||||
function = click.option(
|
||||
"-m",
|
||||
"--merge",
|
||||
is_flag=True,
|
||||
help="Merge any existing configuration file with the new configuration.",
|
||||
)(function)
|
||||
return function
|
48
manim_slides/config.py
Normal file
48
manim_slides/config.py
Normal file
@ -0,0 +1,48 @@
|
||||
from typing import Optional, Set
|
||||
|
||||
from pydantic import BaseModel, root_validator, validator
|
||||
|
||||
from .defaults import LEFT_ARROW_KEY_CODE, RIGHT_ARROW_KEY_CODE
|
||||
|
||||
|
||||
class Key(BaseModel):
|
||||
ids: Set[int]
|
||||
name: Optional[str] = None
|
||||
|
||||
@validator("ids", each_item=True)
|
||||
def id_is_posint(cls, v: int):
|
||||
if v < 0:
|
||||
raise ValueError("Key ids cannot be negative integers")
|
||||
return v
|
||||
|
||||
def match(self, key_id: int):
|
||||
return key_id in self.ids
|
||||
|
||||
|
||||
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")
|
||||
REWIND: Key = Key(ids=[ord("r")], name="REWIND")
|
||||
PLAY_PAUSE: Key = Key(ids=[32], name="PLAY / PAUSE")
|
||||
|
||||
@root_validator
|
||||
def ids_are_unique_across_keys(cls, values):
|
||||
ids = set()
|
||||
|
||||
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"
|
||||
)
|
||||
ids.update(key.ids)
|
||||
|
||||
return values
|
||||
|
||||
def merge_with(self, other: "Config") -> "Config":
|
||||
for key_name, key in self:
|
||||
other_key = getattr(other, key_name)
|
||||
key.ids.update(other_key.ids)
|
||||
key.name = other_key.name or key.name
|
||||
|
||||
return self
|
11
manim_slides/defaults.py
Normal file
11
manim_slides/defaults.py
Normal file
@ -0,0 +1,11 @@
|
||||
import platform
|
||||
|
||||
FOLDER_PATH: str = "./slides"
|
||||
CONFIG_PATH: str = ".manim-slides.json"
|
||||
|
||||
if platform.system() == "Windows":
|
||||
RIGHT_ARROW_KEY_CODE = 2555904
|
||||
LEFT_ARROW_KEY_CODE = 2424832
|
||||
else:
|
||||
RIGHT_ARROW_KEY_CODE = 65363
|
||||
LEFT_ARROW_KEY_CODE = 65361
|
20
manim_slides/main.py
Normal file
20
manim_slides/main.py
Normal file
@ -0,0 +1,20 @@
|
||||
import click
|
||||
from click_default_group import DefaultGroup
|
||||
|
||||
from . import __version__
|
||||
from .present import present
|
||||
from .wizard import init, wizard
|
||||
|
||||
|
||||
@click.group(cls=DefaultGroup, default="present", default_if_no_args=True)
|
||||
@click.version_option(__version__, "-v", "--version")
|
||||
@click.help_option("-h", "--help")
|
||||
def cli():
|
||||
pass
|
||||
|
||||
cli.add_command(present)
|
||||
cli.add_command(wizard)
|
||||
cli.add_command(init)
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
@ -1,19 +1,18 @@
|
||||
import cv2
|
||||
import numpy as np
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import argparse
|
||||
from enum import Enum
|
||||
|
||||
class Config:
|
||||
QUIT_KEY = ord("q")
|
||||
CONTINUE_KEY = 83 #right arrow
|
||||
BACK_KEY = 81 #left arrow
|
||||
REWIND_KEY = ord("r")
|
||||
PLAYPAUSE_KEY = 32 #spacebar
|
||||
import click
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from .commons import config_path_option
|
||||
from .config import Config
|
||||
from .defaults import CONFIG_PATH, FOLDER_PATH
|
||||
|
||||
|
||||
class State(Enum):
|
||||
PLAYING = 0
|
||||
@ -35,83 +34,124 @@ def fix_time(x):
|
||||
return x if x > 0 else 1
|
||||
|
||||
class Presentation:
|
||||
def __init__(self, config):
|
||||
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.reset()
|
||||
self.load_files()
|
||||
self.add_last_slide()
|
||||
|
||||
def reset(self):
|
||||
self.current_animation = 0
|
||||
self.current_slide_i = 0
|
||||
self.lastframe = []
|
||||
|
||||
self.caps = [None for _ in self.files]
|
||||
self.reset()
|
||||
self.add_last_slide()
|
||||
|
||||
def add_last_slide(self):
|
||||
last_slide_end = self.slides[-1]["end_animation"]
|
||||
last_animation = len(self.files) - 1
|
||||
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
|
||||
number = len(self.slides) + 1,
|
||||
terminated = False
|
||||
))
|
||||
|
||||
def load_files(self):
|
||||
self.caps = list()
|
||||
for f in self.files:
|
||||
self.caps.append(cv2.VideoCapture(f))
|
||||
|
||||
|
||||
def reset(self):
|
||||
self.current_animation = 0
|
||||
self.load_this_cap(0)
|
||||
self.current_slide_i = 0
|
||||
self.slides[-1]["terminated"] = False
|
||||
|
||||
def next(self):
|
||||
self.current_slide_i += 1
|
||||
self.current_animation = self.current_slide["start_animation"]
|
||||
|
||||
if self.current_slide["type"] == "last":
|
||||
self.current_slide["terminated"] = True
|
||||
else:
|
||||
self.current_slide_i = min(len(self.slides) - 1, self.current_slide_i + 1)
|
||||
self.rewind_slide()
|
||||
|
||||
def prev(self):
|
||||
self.current_slide_i = max(0, self.current_slide_i - 1)
|
||||
self.current_animation = self.current_slide["start_animation"]
|
||||
self.rewind_slide()
|
||||
|
||||
def rewind(self):
|
||||
def rewind_slide(self):
|
||||
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:
|
||||
# unload other caps
|
||||
for i in range(len(self.caps)):
|
||||
if self.caps[i] != None:
|
||||
self.caps[i].release()
|
||||
self.caps[i] = None
|
||||
# load this cap
|
||||
self.caps[cap_number] = cv2.VideoCapture(self.files[cap_number])
|
||||
|
||||
@property
|
||||
def current_slide(self):
|
||||
return self.slides[self.current_slide_i]
|
||||
|
||||
|
||||
@property
|
||||
def current_cap(self):
|
||||
self.load_this_cap(self.current_animation)
|
||||
return self.caps[self.current_animation]
|
||||
|
||||
@property
|
||||
def fps(self):
|
||||
return self.current_cap.get(cv2.CAP_PROP_FPS)
|
||||
|
||||
def get_frame_and_state(self):
|
||||
ret, frame = self.current_cap.read()
|
||||
state = State.PLAYING
|
||||
if ret:
|
||||
# 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 len(self.lastframe) == 0:
|
||||
_, self.lastframe = self.current_cap.read()
|
||||
return self.lastframe, state
|
||||
still_playing, frame = self.current_cap.read()
|
||||
if still_playing:
|
||||
self.lastframe = frame
|
||||
else:
|
||||
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
elif state in [state.WAIT, state.PAUSED]:
|
||||
return self.lastframe, state
|
||||
elif self.current_slide["type"] == "last" and self.current_slide["terminated"]:
|
||||
return self.lastframe, State.END
|
||||
|
||||
if not still_playing:
|
||||
if self.current_slide["end_animation"] == self.current_animation + 1:
|
||||
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
|
||||
elif self.current_slide["type"] == "loop":
|
||||
self.current_animation = self.current_slide["start_animation"]
|
||||
state = State.PLAYING
|
||||
self.rewind_slide()
|
||||
elif self.current_slide["type"] == "last":
|
||||
return self.lastframe, State.END
|
||||
self.current_slide["terminated"] = True
|
||||
elif self.current_slide["type"] == "last" and self.current_slide["end_animation"] == self.current_animation:
|
||||
return self.lastframe, State.END
|
||||
state = State.WAIT
|
||||
else:
|
||||
# Play next video!
|
||||
self.current_animation += 1
|
||||
|
||||
self.load_this_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)
|
||||
|
||||
return self.lastframe, state
|
||||
|
||||
|
||||
class Display:
|
||||
def __init__(self, presentations, start_paused=False):
|
||||
def __init__(self, presentations, config, start_paused=False, fullscreen=False):
|
||||
self.presentations = presentations
|
||||
self.start_paused = start_paused
|
||||
self.config = config
|
||||
|
||||
self.state = State.PLAYING
|
||||
self.lastframe = None
|
||||
@ -119,15 +159,19 @@ class Display:
|
||||
|
||||
self.lag = 0
|
||||
self.last_time = now()
|
||||
|
||||
|
||||
if fullscreen:
|
||||
cv2.namedWindow("Video", cv2.WND_PROP_FULLSCREEN)
|
||||
cv2.setWindowProperty("Video", cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN)
|
||||
|
||||
@property
|
||||
def current_presentation(self):
|
||||
return self.presentations[self.current_presentation_i]
|
||||
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
if self.state == State.PLAYING:
|
||||
self.lastframe, self.state = self.current_presentation.get_frame_and_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
|
||||
self.start_paused = False
|
||||
@ -137,16 +181,15 @@ class Display:
|
||||
else:
|
||||
self.current_presentation_i += 1
|
||||
self.state = State.PLAYING
|
||||
|
||||
self.handle_key()
|
||||
self.show_video()
|
||||
self.show_info()
|
||||
self.handle_key()
|
||||
|
||||
|
||||
def show_video(self):
|
||||
self.lag = now() - self.last_time
|
||||
self.last_time = now()
|
||||
cv2.imshow("Video", self.lastframe)
|
||||
|
||||
|
||||
def show_info(self):
|
||||
info = np.zeros((130, 420), np.uint8)
|
||||
font_args = (cv2.FONT_HERSHEY_SIMPLEX, 0.7, 255)
|
||||
@ -185,25 +228,25 @@ class Display:
|
||||
((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)
|
||||
key = cv2.waitKey(fix_time(sleep_time - self.lag)) & 0xFF
|
||||
key = cv2.waitKeyEx(fix_time(sleep_time - self.lag))
|
||||
|
||||
if key == Config.QUIT_KEY:
|
||||
if self.config.QUIT.match(key):
|
||||
self.quit()
|
||||
elif self.state == State.PLAYING and key == Config.PLAYPAUSE_KEY:
|
||||
elif self.state == State.PLAYING and self.config.PLAY_PAUSE.match(key):
|
||||
self.state = State.PAUSED
|
||||
elif self.state == State.PAUSED and key == Config.PLAYPAUSE_KEY:
|
||||
elif self.state == State.PAUSED and self.config.PLAY_PAUSE.match(key):
|
||||
self.state = State.PLAYING
|
||||
elif self.state == State.WAIT and (key == Config.CONTINUE_KEY or key == Config.PLAYPAUSE_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 key == Config.CONTINUE_KEY:
|
||||
elif self.state == State.PLAYING and self.config.CONTINUE.match(key):
|
||||
self.current_presentation.next()
|
||||
elif key == Config.BACK_KEY:
|
||||
elif self.config.BACK.match(key):
|
||||
if self.current_presentation.current_slide_i == 0:
|
||||
self.current_presentation_i = max(0, self.current_presentation_i - 1)
|
||||
self.current_presentation.reset()
|
||||
@ -211,36 +254,39 @@ class Display:
|
||||
else:
|
||||
self.current_presentation.prev()
|
||||
self.state = State.PLAYING
|
||||
elif key == Config.REWIND_KEY:
|
||||
self.current_presentation.rewind()
|
||||
elif self.config.REWIND.match(key):
|
||||
self.current_presentation.rewind_slide()
|
||||
self.state = State.PLAYING
|
||||
|
||||
|
||||
|
||||
def quit(self):
|
||||
cv2.destroyAllWindows()
|
||||
sys.exit()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
|
||||
parser.add_argument("scenes", metavar="scenes", type=str, nargs="+", help="Scenes to present")
|
||||
parser.add_argument("--folder", type=str, default="./presentation", help="Presentation files folder")
|
||||
parser.add_argument("--start-paused", action="store_true", help="Start paused")
|
||||
|
||||
|
||||
args = parser.parse_args()
|
||||
@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("--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.help_option("-h", "--help")
|
||||
def present(scenes, config_path, folder, start_paused, fullscreen, last_frame_next):
|
||||
"""Present the different scenes"""
|
||||
|
||||
presentations = list()
|
||||
for scene in args.scenes:
|
||||
config_file = os.path.join(args.folder, f"{scene}.json")
|
||||
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")
|
||||
config = json.load(open(config_file))
|
||||
presentations.append(Presentation(config))
|
||||
presentations.append(Presentation(config, last_frame_next=last_frame_next))
|
||||
|
||||
display = Display(presentations, start_paused=args.start_paused)
|
||||
if os.path.exists(config_path):
|
||||
config = Config.parse_file(config_path)
|
||||
else:
|
||||
config = Config()
|
||||
|
||||
display = Display(presentations, config=config, start_paused=start_paused, fullscreen=fullscreen)
|
||||
display.run()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
@ -1,12 +1,16 @@
|
||||
import os
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from manim import Scene, config
|
||||
|
||||
from manim import Scene, ThreeDScene, config
|
||||
|
||||
from .defaults import FOLDER_PATH
|
||||
|
||||
|
||||
class Slide(Scene):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.output_folder = kwargs.pop("output_folder", "./presentation")
|
||||
def __init__(self, *args, output_folder=FOLDER_PATH, **kwargs):
|
||||
super(Slide, self).__init__(*args, **kwargs)
|
||||
self.output_folder = output_folder
|
||||
self.slides = list()
|
||||
self.current_slide = 1
|
||||
self.current_animation = 0
|
||||
@ -16,7 +20,7 @@ class Slide(Scene):
|
||||
def play(self, *args, **kwargs):
|
||||
super(Slide, self).play(*args, **kwargs)
|
||||
self.current_animation += 1
|
||||
|
||||
|
||||
def pause(self):
|
||||
self.slides.append(dict(
|
||||
type="slide",
|
||||
@ -26,11 +30,11 @@ class Slide(Scene):
|
||||
))
|
||||
self.current_slide += 1
|
||||
self.pause_start_animation = self.current_animation
|
||||
|
||||
|
||||
def start_loop(self):
|
||||
assert self.loop_start_animation is None, "You cant nest loops"
|
||||
assert self.loop_start_animation is None, "You cannot nest loops"
|
||||
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(
|
||||
@ -42,32 +46,32 @@ class Slide(Scene):
|
||||
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
|
||||
|
||||
if not os.path.exists(self.output_folder):
|
||||
os.mkdir(self.output_folder)
|
||||
|
||||
|
||||
files_folder = os.path.join(self.output_folder, "files")
|
||||
if not os.path.exists(files_folder):
|
||||
os.mkdir(files_folder)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
if not os.path.exists(scene_files_folder):
|
||||
|
||||
if not os.path.exists(scene_files_folder):
|
||||
os.mkdir(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))
|
||||
@ -79,4 +83,7 @@ class Slide(Scene):
|
||||
slides=self.slides,
|
||||
files=files
|
||||
), f)
|
||||
f.close()
|
||||
f.close()
|
||||
|
||||
class ThreeDSlide(ThreeDScene, Slide):
|
||||
pass
|
71
manim_slides/wizard.py
Normal file
71
manim_slides/wizard.py
Normal file
@ -0,0 +1,71 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import click
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from .commons import config_options
|
||||
from .config import Config
|
||||
from .defaults import CONFIG_PATH
|
||||
|
||||
|
||||
def prompt(question: str) -> int:
|
||||
font_args = (cv2.FONT_HERSHEY_SIMPLEX, 0.7, 255)
|
||||
display = np.zeros((130, 420), np.uint8)
|
||||
|
||||
cv2.putText(display, "* Manim Slides Wizard *", (70, 33), *font_args)
|
||||
cv2.putText(display, question, (30, 85), *font_args)
|
||||
|
||||
cv2.imshow("Manim Slides Configuration Wizard", display)
|
||||
return cv2.waitKeyEx(-1)
|
||||
|
||||
|
||||
@click.command()
|
||||
@config_options
|
||||
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))
|
||||
|
||||
force = choice == "o"
|
||||
merge = choice == "m"
|
||||
|
||||
if force:
|
||||
click.secho("Overwriting.")
|
||||
elif merge:
|
||||
click.secho("Merging.")
|
||||
else:
|
||||
click.secho("Exiting.")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
config = Config()
|
||||
|
||||
if not skip_interactive:
|
||||
|
||||
prompt("Press any key to continue")
|
||||
|
||||
for _, key in config:
|
||||
key.ids = [prompt(f"Press the {key.name} key")]
|
||||
|
||||
if merge:
|
||||
config = Config.parse_file(config_path).merge_with(config)
|
||||
|
||||
with open(config_path, "w") as config_file:
|
||||
config_file.write(config.json(indent=4))
|
||||
|
||||
click.echo(f"Configuration file successfully save to `{config_path}`")
|
41
setup.py
41
setup.py
@ -1,28 +1,43 @@
|
||||
import setuptools
|
||||
import os
|
||||
import sys
|
||||
|
||||
with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), "README.md"), "r") as fh:
|
||||
long_description = fh.read()
|
||||
import setuptools
|
||||
|
||||
if sys.version_info < (3, 7):
|
||||
raise RuntimeError("This package requires Python 3.7+")
|
||||
|
||||
with open("README.md", "r") as f:
|
||||
long_description = f.read()
|
||||
|
||||
setuptools.setup(
|
||||
name="manim_presentation",
|
||||
version="0.1.2",
|
||||
author="Federico A. Galatolo",
|
||||
author_email="federico.galatolo@ing.unipi.it",
|
||||
name="manim-slides",
|
||||
version="3.0.0",
|
||||
author="Jérome Eertmans (previously, Federico A. Galatolo)",
|
||||
author_email="jeertmans@icloud.com (resp., federico.galatolo@ing.unipi.it)",
|
||||
description="Tool for live presentations using manim",
|
||||
url="https://github.com/galatolofederico/manim-presentation",
|
||||
url="https://github.com/jeertmans/manim-slides",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
packages=setuptools.find_packages(),
|
||||
entry_points = {
|
||||
"console_scripts": ["manim_presentation=manim_presentation.present:main"],
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"manim-slides=manim_slides.main:cli",
|
||||
],
|
||||
},
|
||||
python_requires=">=3.7",
|
||||
install_requires=[
|
||||
"click>=8.0",
|
||||
"click-default-group>=1.2"
|
||||
"numpy>=1.19.3",
|
||||
"pydantic>=1.9.1",
|
||||
"opencv-python>=4.6",
|
||||
],
|
||||
classifiers=[
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
||||
"Operating System :: OS Independent",
|
||||
"Development Status :: 4 - Beta"
|
||||
],
|
||||
)
|
||||
)
|
||||
|
BIN
static/example.gif
Normal file
BIN
static/example.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 670 KiB |
Reference in New Issue
Block a user