mirror of
https://github.com/jeertmans/manim-slides.git
synced 2025-05-18 11:05:54 +08:00
Compare commits
17 Commits
Author | SHA1 | Date | |
---|---|---|---|
138cf014d2 | |||
4816fc9a41 | |||
f6f851bd09 | |||
87dba671ac | |||
c6c19bce89 | |||
58999d0681 | |||
8696fca829 | |||
2856aeb89b | |||
2ba0d48ac1 | |||
5f730593fb | |||
dfc5c9eb6c | |||
14c17e1d24 | |||
449ff4cd00 | |||
606c521573 | |||
b199fc7023 | |||
4b05f22c8c | |||
1da3492732 |
15
.github/workflows/languagetool.yml
vendored
Normal file
15
.github/workflows/languagetool.yml
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
name: LanguageTool check
|
||||
|
||||
jobs:
|
||||
languagetool_check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: reviewdog/action-languagetool@v1
|
||||
with:
|
||||
reporter: github-pr-review
|
||||
level: warning
|
100
.github/workflows/test_examples.yml
vendored
Normal file
100
.github/workflows/test_examples.yml
vendored
Normal file
@ -0,0 +1,100 @@
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.py'
|
||||
workflow_dispatch:
|
||||
|
||||
name: Test Examples
|
||||
|
||||
jobs:
|
||||
build-examples:
|
||||
strategy:
|
||||
matrix:
|
||||
manim: [manim, manimgl]
|
||||
os: [macos-latest, ubuntu-latest, windows-latest]
|
||||
pyversion: ['3.7', '3.8', '3.9', '3.10']
|
||||
exclude:
|
||||
# excludes manimgl on Windows because if throws errors
|
||||
# related to OpenGL, which seems hard to fix:
|
||||
# Your graphics drivers do not support OpenGL 2.0.
|
||||
- os: windows-latest
|
||||
manim: manimgl
|
||||
# manimgl actually requires Python >= 3.8, see:
|
||||
# https://github.com/3b1b/manim/issues/1808
|
||||
- manim: manimgl
|
||||
pyversion: '3.7'
|
||||
# We only test Python 3.10 on Windows and MacOS
|
||||
- os: windows-latest
|
||||
pyversion: '3.7'
|
||||
- os: windows-latest
|
||||
pyversion: '3.8'
|
||||
- os: windows-latest
|
||||
pyversion: '3.9'
|
||||
- os: macos-latest
|
||||
pyversion: '3.7'
|
||||
- os: macos-latest
|
||||
pyversion: '3.8'
|
||||
- os: macos-latest
|
||||
pyversion: '3.9'
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.pyversion }}
|
||||
- name: Append to Path on MacOS and Ubuntu
|
||||
if: matrix.os == 'macos-latest' || matrix.os == 'ubuntu-latest'
|
||||
run: echo "${HOME}/.local/bin" >> $GITHUB_PATH
|
||||
- name: Append to Path on Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: echo "${HOME}/.local/bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
- name: Install MacOS dependencies (manim only)
|
||||
if: matrix.os == 'macos-latest' && matrix.manim == 'manim'
|
||||
run: brew install py3cairo
|
||||
- name: Install MacOS dependencies
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: brew install ffmpeg
|
||||
- name: Install Ubuntu dependencies
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: sudo apt install libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev xvfb
|
||||
- name: Install Windows dependencies
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: choco install ffmpeg
|
||||
- name: Install manim on MacOs
|
||||
if: matrix.manim == 'manim' && matrix.os == 'macos-latest'
|
||||
run: pip3 install --user manim
|
||||
- name: Install manim on Ubuntu and Windows
|
||||
if: matrix.manim == 'manim' && (matrix.os == 'ubuntu-latest' || matrix.os == 'windows-latest')
|
||||
run: python -m pip install --user manim
|
||||
- name: Install manimgl on MacOs
|
||||
if: matrix.manim == 'manimgl' && matrix.os == 'macos-latest'
|
||||
run: pip3 install --user manimgl
|
||||
- name: Install manimgl on Ubuntu and Windows
|
||||
if: matrix.manim == 'manimgl' && matrix.os != 'macos-latest'
|
||||
run: python -m pip install --user manimgl
|
||||
- name: Install manim-slides on MacOS
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: pip3 install --user .
|
||||
- name: Install manim-slides on Ubuntu
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: xvfb-run -a -s "-screen 0 1400x900x24" python -m pip install --user .
|
||||
- name: Install manim-slides on Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: pip3 install -e .
|
||||
- name: Build slides with manim
|
||||
if: matrix.manim == 'manim'
|
||||
run: python -m manim -ql example.py Example ThreeDExample
|
||||
- name: Build slides with manimgl on Ubuntu
|
||||
if: matrix.manim == 'manimgl' && matrix.os == 'ubuntu-latest'
|
||||
run: xvfb-run -a -s "-screen 0 1400x900x24" manim-render -l example.py Example ThreeDExample
|
||||
- name: Build slides with manimgl on MacOS or Windows
|
||||
if: matrix.manim == 'manimgl' && (matrix.os == 'macos-latest' || matrix.os == 'windows-latest')
|
||||
run: manimgl -l example.py Example ThreeDExample
|
||||
- name: Test slides on Ubuntu
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: xvfb-run -a -s "-screen 0 1400x900x24" manim-slides Example ThreeDExample --skip-all
|
||||
- name: Test slides on MacOS or Windows
|
||||
if: matrix.os == 'macos-latest' || matrix.os == 'windows-latest'
|
||||
run: manim-slides Example ThreeDExample --skip-all
|
@ -15,3 +15,7 @@ repos:
|
||||
- id: isort
|
||||
name: isort (python)
|
||||
args: ["--profile", "black"]
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 22.8.0
|
||||
hooks:
|
||||
- id: black
|
||||
|
169
README.md
169
README.md
@ -1,33 +1,70 @@
|
||||

|
||||
|
||||
[![Latest Release][pypi-version-badge]][pypi-version-url]
|
||||
[![Python version][pypi-python-version-badge]][pypi-version-url]
|
||||

|
||||
# Manim Slides
|
||||
|
||||
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!
|
||||
Tool for live presentations using either [Manim (community edition)](https://www.manim.community/) or [ManimGL](https://3b1b.github.io/manim/). Manim Slides will *automatically* detect the one you are using!
|
||||
|
||||
> **_NOTE:_** This project extends the work of [`manim-presentation`](https://github.com/galatolofederico/manim-presentation), with a lot more features!
|
||||
|
||||
## Install
|
||||
- [Install](#install)
|
||||
* [Dependencies](#dependencies)
|
||||
* [Pip install](#pip-install)
|
||||
* [Install From Repository](#install-from-repository)
|
||||
- [Usage](#usage)
|
||||
* [Basic Example](#basic-example)
|
||||
* [Key Bindings](#key-bindings)
|
||||
* [Other Examples](#other-examples)
|
||||
- [Features and Comparison with Original manim-presentation](#features-and-comparison-with-original-manim-presentation)
|
||||
- [Contributing](#contributing)
|
||||
|
||||
```
|
||||
## Installation
|
||||
|
||||
While installing Manim Slides and its dependencies on your global Python is fine, I recommend using a [virtualenv](https://docs.python.org/3/tutorial/venv.html) for a local installation.
|
||||
|
||||
### Dependencies
|
||||
|
||||
Manim Slides requires either Manim or ManimGL to be installed. Having both packages installed is fine too.
|
||||
|
||||
If none of those packages are installed, please refer to their specifc installation guidelines:
|
||||
- [Manim](https://docs.manim.community/en/stable/installation.html)
|
||||
- [ManimGL](https://3b1b.github.io/manim/getting_started/installation.html)
|
||||
|
||||
### Pip Install
|
||||
|
||||
The recommended way to install the latest release is to use pip:
|
||||
|
||||
```bash
|
||||
pip install manim-slides
|
||||
```
|
||||
|
||||
## Usage
|
||||
### Install From Repository
|
||||
|
||||
Use the class `Slide` as your scenes base class:
|
||||
```python
|
||||
from manim_slides import Slide
|
||||
An alternative way to install Manim Slides is to clone the git repository, and install from there:
|
||||
|
||||
class Example(Slide):
|
||||
def construct(self):
|
||||
...
|
||||
```bash
|
||||
git clone https://github.com/jeertmans/manim-slides
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
call `self.pause()` when you want to pause the playback and wait for an input to continue (check the keybindings).
|
||||
> *Note:* the `-e` flag allows you to edit the files, and observe the changes directly when using Manim Slides
|
||||
|
||||
## Usage
|
||||
|
||||
Using Manim Slides is a two-step process:
|
||||
1. Render animations using `Slide` (resp. `ThreeDSlide`) as a base class instead of `Scene` (resp. `ThreeDScene`), and add calls to `self.pause()` everytime you want to create a new slide.
|
||||
2. Run `manim-slides` on rendered animations and display them like a *Power Point* presentation.
|
||||
|
||||
### Basic Example
|
||||
|
||||
|
||||
Wrap a series of animations between `self.start_loop()` and `self.stop_loop()` when you want to loop them (until input to continue):
|
||||
|
||||
```python
|
||||
# example.py
|
||||
|
||||
from manim import *
|
||||
# or: from manimlib import *
|
||||
from manim_slides import Slide
|
||||
@ -38,105 +75,103 @@ class Example(Slide):
|
||||
dot = Dot()
|
||||
|
||||
self.play(GrowFromCenter(circle))
|
||||
self.pause()
|
||||
self.pause() # Waits user to press continue to go to the next slide
|
||||
|
||||
self.start_loop()
|
||||
self.start_loop() # Start loop
|
||||
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
|
||||
self.end_loop()
|
||||
self.end_loop() # This will loop until user inputs a key
|
||||
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
self.pause()
|
||||
self.pause() # Waits user to press continue to go to the next slide
|
||||
|
||||
self.wait()
|
||||
self.wait() # The presentation directly exits after last animation
|
||||
```
|
||||
|
||||
You **must** end your `Slide` with a `self.play(...)` or a `self.wait(..)`.
|
||||
You **must** end your `Slide` with a `self.play(...)` or a `self.wait(...)`.
|
||||
|
||||
First, render the animation files:
|
||||
|
||||
```bash
|
||||
manim example.py
|
||||
# or
|
||||
manimgl example.py
|
||||
```
|
||||
|
||||
To start the presentation using `Scene1`, `Scene2` and so on simply run:
|
||||
```
|
||||
manim-slides Scene1 Scene2...
|
||||
|
||||
```bash
|
||||
manim-slides [OPTIONS] Scene1 Scene2...
|
||||
```
|
||||
|
||||
## Keybindings
|
||||
Or in this example:
|
||||
|
||||
Default keybindings to control the presentation:
|
||||
```bash
|
||||
manim-slides Example
|
||||
```
|
||||
|
||||
## Key Bindings
|
||||
|
||||
The default key bindings to control the presentation are:
|
||||
|
||||
| Keybinding | Action |
|
||||
|:-----------:|:------------------------:|
|
||||
| Right Arrow | Continue/Next Slide |
|
||||
| Left Arrow | Previous Slide |
|
||||
| R | Re-Animate Current Slide |
|
||||
| R | Replay Current Slide |
|
||||
| V | Reverse Current Slide |
|
||||
| Spacebar | Play/Pause |
|
||||
| Q | Quit |
|
||||
|
||||
You can run the **configuration wizard** to change those key bindings:
|
||||
|
||||
You can run the **configuration wizard** with:
|
||||
|
||||
```
|
||||
```bash
|
||||
manim-slides wizard
|
||||
```
|
||||
|
||||
Alternatively you can specify different keybindings creating a file named `.manim-slides.json` with the keys: `QUIT` `CONTINUE` `BACK` `REVERSE` `REWIND` and `PLAY_PAUSE`.
|
||||
Alternatively you can specify different key bindings 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:
|
||||
```
|
||||
|
||||
```bash
|
||||
manim-slides init
|
||||
```
|
||||
|
||||
> **_NOTE:_** `manim-slides` uses `cv2.waitKeyEx()` to wait for keypresses, and directly registers the key code.
|
||||
|
||||
## Run Example
|
||||
## Other Examples
|
||||
|
||||
Clone this repository:
|
||||
```
|
||||
git clone https://github.com/jeertmans/manim-slides.git
|
||||
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 Example
|
||||
# or
|
||||
manimgl --hd example.py Example
|
||||
```
|
||||
|
||||
Run the presentation
|
||||
```
|
||||
manim-slides Example
|
||||
```
|
||||
Other examples are available in the [`example.py`](https://github.com/jeertmans/manim-slides/blob/main/example.py) file, if you downloaded the git repository.
|
||||
|
||||
Below is a small recording of me playing with the slides back and forth.
|
||||
|
||||

|
||||
|
||||
|
||||
## Comparison with original `manim-presentation`
|
||||
## Features and 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:
|
||||
Below is a non-exhaustive list of features:
|
||||
|
||||
- [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
|
||||
- [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
|
||||
- [x] Fix the quality problem on Windows platforms with `fullscreen` flag
|
||||
| Feature | `manim-slides` | `manim-presentation` |
|
||||
|:--------|:--------------:|:--------------------:|
|
||||
| Support for Manim | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Support for ManimGL | :heavy_check_mark: | :heavy_multiplication_x: |
|
||||
| Configurable key bindings | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Configurable paths | :heavy_check_mark: | :heavy_multiplication_x: |
|
||||
| Play / Pause slides | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Next / Previous slide | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Replay slide | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| Reverse slide | :heavy_check_mark: | :heavy_multiplication_x: |
|
||||
| Multiple key per actions | :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: |
|
||||
| Support for 3D Scenes | :heavy_check_mark: | :heavy_multiplication_x: |
|
||||
| Documented code | :heavy_check_mark: | :heavy_multiplication_x: |
|
||||
| Tested on Unix, macOS, and Windows | :heavy_check_mark: | :heavy_multiplication_x: |
|
||||
|
||||
## Contributions and license
|
||||
|
||||
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.
|
||||
## Contributing
|
||||
|
||||
Contributions are more than welcome!
|
||||
|
||||
[pypi-version-badge]: https://img.shields.io/pypi/v/manim-slides?label=manim-slides
|
||||
[pypi-version-url]: https://pypi.org/project/manim-slides/
|
||||
|
114
example.py
114
example.py
@ -1,6 +1,15 @@
|
||||
# If you want to use manimgl, uncomment change
|
||||
# manim to manimlib
|
||||
from manimlib import *
|
||||
import sys
|
||||
|
||||
if "manim" in sys.modules:
|
||||
from manim import *
|
||||
|
||||
MANIMGL = False
|
||||
elif "manimlib" in sys.modules:
|
||||
from manimlib import *
|
||||
|
||||
MANIMGL = True
|
||||
else:
|
||||
raise ImportError("This script must be run with either `manim` or `manimgl`")
|
||||
|
||||
from manim_slides import Slide, ThreeDSlide
|
||||
|
||||
@ -31,37 +40,88 @@ class Example(Slide):
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
|
||||
|
||||
class ThreeDExample(ThreeDSlide):
|
||||
def construct(self):
|
||||
axes = ThreeDAxes()
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
dot = Dot(color=RED)
|
||||
# For ThreeDExample, things are different
|
||||
|
||||
self.add(axes)
|
||||
if not MANIMGL:
|
||||
|
||||
self.set_camera_orientation(phi=75 * DEGREES, theta=30 * DEGREES)
|
||||
class ThreeDExample(ThreeDSlide):
|
||||
def construct(self):
|
||||
axes = ThreeDAxes()
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
dot = Dot(color=RED)
|
||||
|
||||
self.play(GrowFromCenter(circle))
|
||||
self.begin_ambient_camera_rotation(rate=75 * DEGREES / 4)
|
||||
self.add(axes)
|
||||
|
||||
self.pause()
|
||||
self.set_camera_orientation(phi=75 * DEGREES, theta=30 * DEGREES)
|
||||
|
||||
self.start_loop()
|
||||
self.play(MoveAlongPath(dot, circle), run_time=4, rate_func=linear)
|
||||
self.end_loop()
|
||||
self.play(GrowFromCenter(circle))
|
||||
self.begin_ambient_camera_rotation(rate=75 * DEGREES / 4)
|
||||
|
||||
self.stop_ambient_camera_rotation()
|
||||
self.move_camera(phi=75 * DEGREES, theta=30 * DEGREES)
|
||||
self.pause()
|
||||
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
self.pause()
|
||||
self.start_loop()
|
||||
self.play(MoveAlongPath(dot, circle), run_time=4, rate_func=linear)
|
||||
self.end_loop()
|
||||
|
||||
self.play(dot.animate.move_to(RIGHT * 3))
|
||||
self.pause()
|
||||
self.stop_ambient_camera_rotation()
|
||||
self.move_camera(phi=75 * DEGREES, theta=30 * DEGREES)
|
||||
|
||||
self.start_loop()
|
||||
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
|
||||
self.end_loop()
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
self.pause()
|
||||
|
||||
# Each slide MUST end with an animation (a self.wait is considered an animation)
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
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))
|
||||
|
||||
else:
|
||||
# WARNING: 3b1b's manim change how ThreeDScene work,
|
||||
# this is why things have to be managed differently.
|
||||
class ThreeDExample(Slide):
|
||||
CONFIG = {
|
||||
"camera_class": ThreeDCamera,
|
||||
}
|
||||
|
||||
def construct(self):
|
||||
axes = ThreeDAxes()
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
dot = Dot(color=RED)
|
||||
|
||||
self.add(axes)
|
||||
|
||||
frame = self.camera.frame
|
||||
frame.set_euler_angles(
|
||||
theta=30 * DEGREES,
|
||||
phi=75 * DEGREES,
|
||||
gamma=0,
|
||||
)
|
||||
|
||||
self.play(GrowFromCenter(circle))
|
||||
updater = lambda m, dt: m.increment_theta((75 * DEGREES / 4) * dt)
|
||||
frame.add_updater(updater)
|
||||
|
||||
self.pause()
|
||||
|
||||
self.start_loop()
|
||||
self.play(MoveAlongPath(dot, circle), run_time=4, rate_func=linear)
|
||||
self.end_loop()
|
||||
|
||||
frame.remove_updater(updater)
|
||||
self.play(frame.animate.set_theta(30 * DEGREES))
|
||||
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))
|
||||
|
@ -1 +1 @@
|
||||
__version__ = "3.2.2"
|
||||
__version__ = "4.0.0rc1"
|
||||
|
@ -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
|
||||
|
||||
|
||||
class Key(BaseModel):
|
||||
"""Represents a list of key codes, with optionally a name."""
|
||||
|
||||
ids: Set[int]
|
||||
name: Optional[str] = None
|
||||
|
||||
@ -20,6 +24,8 @@ class Key(BaseModel):
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
"""General Manim Slides config"""
|
||||
|
||||
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")
|
||||
@ -47,3 +53,83 @@ class Config(BaseModel):
|
||||
key.name = other_key.name or key.name
|
||||
|
||||
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
|
||||
|
@ -1,6 +1,20 @@
|
||||
import os
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from importlib.util import find_spec
|
||||
|
||||
|
||||
@contextmanager
|
||||
def suppress_stdout():
|
||||
with open(os.devnull, "w") as devnull:
|
||||
old_stdout = sys.stdout
|
||||
sys.stdout = devnull
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
|
||||
|
||||
MANIM_PACKAGE_NAME = "manim"
|
||||
MANIM_AVAILABLE = find_spec(MANIM_PACKAGE_NAME) is not None
|
||||
MANIM_IMPORTED = MANIM_PACKAGE_NAME in sys.modules
|
||||
@ -17,14 +31,20 @@ if MANIM_IMPORTED and MANIMGL_IMPORTED:
|
||||
)
|
||||
MANIM = True
|
||||
MANIMGL = False
|
||||
elif MANIM_AVAILABLE and not MANIMGL_IMPORTED:
|
||||
elif MANIM_IMPORTED:
|
||||
MANIM = True
|
||||
MANIMGL = False
|
||||
elif MANIMGL_IMPORTED:
|
||||
MANIM = False
|
||||
MANIMGL = True
|
||||
elif MANIM_AVAILABLE:
|
||||
MANIM = True
|
||||
MANIMGL = False
|
||||
elif MANIMGL_AVAILABLE:
|
||||
MANIM = False
|
||||
MANIMGL = True
|
||||
else:
|
||||
raise ImportError(
|
||||
raise ModuleNotFoundError(
|
||||
"Either manim (community) or manimgl (3b1b) package must be installed"
|
||||
)
|
||||
|
||||
@ -37,9 +57,10 @@ if MANIMGL:
|
||||
from manimlib.logger import log as logger
|
||||
|
||||
else:
|
||||
from manim import Scene, ThreeDScene, config, logger
|
||||
with suppress_stdout(): # Avoids printing "Manim Community v..."
|
||||
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
|
||||
try: # For manim<v0.16.0.post0
|
||||
from manim.constants import FFMPEG_BIN as FFMPEG_BIN
|
||||
except ImportError:
|
||||
FFMPEG_BIN = config.ffmpeg_executable
|
||||
|
@ -5,6 +5,7 @@ import platform
|
||||
import sys
|
||||
import time
|
||||
from enum import IntEnum, auto, unique
|
||||
from typing import List, Tuple
|
||||
|
||||
if platform.system() == "Windows":
|
||||
import ctypes
|
||||
@ -12,124 +13,197 @@ if platform.system() == "Windows":
|
||||
import click
|
||||
import cv2
|
||||
import numpy as np
|
||||
from pydantic import ValidationError
|
||||
|
||||
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 .slide import reverse_video_path
|
||||
|
||||
WINDOW_NAME = "Manim Slides"
|
||||
|
||||
|
||||
@unique
|
||||
class State(IntEnum):
|
||||
"""Represents all possible states of a slide presentation."""
|
||||
|
||||
PLAYING = auto()
|
||||
PAUSED = auto()
|
||||
WAIT = auto()
|
||||
END = auto()
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return self.name.capitalize()
|
||||
|
||||
|
||||
def now() -> int:
|
||||
"""Returns time.time() in seconds."""
|
||||
return round(time.time() * 1000)
|
||||
|
||||
|
||||
def fix_time(x: float) -> float:
|
||||
return x if x > 0 else 1
|
||||
def fix_time(t: float) -> float:
|
||||
"""Clips time t such that it is always positive."""
|
||||
return t if t > 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 = [path for path in config["files"]]
|
||||
"""Creates presentation from a configuration object."""
|
||||
|
||||
def __init__(self, config: PresentationConfig):
|
||||
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.reversed_slide = -1
|
||||
self.reversed_animation = -1
|
||||
|
||||
self.lastframe = []
|
||||
self.lastframe = None
|
||||
|
||||
self.caps = [None for _ in self.files]
|
||||
self.reset()
|
||||
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):
|
||||
last_slide_end = self.slides[-1]["end_animation"]
|
||||
last_animation = len(self.files)
|
||||
"""Add a 'last' slide to the end of slides."""
|
||||
self.slides.append(
|
||||
dict(
|
||||
start_animation=last_slide_end,
|
||||
end_animation=last_animation,
|
||||
type="last",
|
||||
number=len(self.slides) + 1,
|
||||
terminated=False,
|
||||
SlideConfig(
|
||||
start_animation=self.last_slide.end_animation,
|
||||
end_animation=self.last_slide.end_animation + 1,
|
||||
type=SlideType.last,
|
||||
number=self.last_slide.number + 1,
|
||||
)
|
||||
)
|
||||
|
||||
def reset(self):
|
||||
"""Rests current presentation."""
|
||||
self.current_animation = 0
|
||||
self.load_this_cap(0)
|
||||
self.current_slide_i = 0
|
||||
self.slides[-1]["terminated"] = False
|
||||
self.load_animation_cap(0)
|
||||
self.current_slide_index = 0
|
||||
self.slides[-1].terminated = False
|
||||
|
||||
def next(self):
|
||||
if self.current_slide["type"] == "last":
|
||||
self.current_slide["terminated"] = True
|
||||
def load_last_slide(self):
|
||||
"""Loads last slide."""
|
||||
self.current_slide_index = len(self.slides) - 2
|
||||
assert (
|
||||
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
|
||||
|
||||
@property
|
||||
def next_animation(self) -> int:
|
||||
"""Returns the next animation."""
|
||||
if self.reverse:
|
||||
return self.current_animation - 1
|
||||
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.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)
|
||||
return self.current_animation + 1
|
||||
|
||||
@property
|
||||
def current_slide(self):
|
||||
return self.slides[self.current_slide_i]
|
||||
def is_last_animation(self) -> int:
|
||||
"""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 current_cap(self):
|
||||
self.load_this_cap(self.current_animation)
|
||||
return self.caps[self.current_animation]
|
||||
def update_state(self, state) -> Tuple[np.ndarray, State]:
|
||||
"""
|
||||
Updates the current state given the previous one.
|
||||
|
||||
@property
|
||||
def fps(self):
|
||||
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):
|
||||
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.
|
||||
"""
|
||||
if state == State.PAUSED:
|
||||
if len(self.lastframe) == 0:
|
||||
if self.lastframe is None:
|
||||
_, self.lastframe = self.current_cap.read()
|
||||
return self.lastframe, state
|
||||
still_playing, frame = self.current_cap.read()
|
||||
@ -137,36 +211,31 @@ class Presentation:
|
||||
self.lastframe = frame
|
||||
elif state in [state.WAIT, state.PAUSED]:
|
||||
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
|
||||
|
||||
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()
|
||||
else: # not still playing
|
||||
if self.is_last_animation:
|
||||
if self.current_slide.is_slide():
|
||||
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":
|
||||
self.current_slide["terminated"] = True
|
||||
elif self.current_slide.is_loop():
|
||||
if self.reverse:
|
||||
state = State.WAIT
|
||||
else:
|
||||
self.current_animation = self.current_slide.start_animation
|
||||
state = State.PLAYING
|
||||
self.rewind_current_slide()
|
||||
elif self.current_slide.is_last():
|
||||
self.current_slide.terminated = True
|
||||
elif (
|
||||
self.current_slide["type"] == "last"
|
||||
and self.current_slide["end_animation"] == self.current_animation
|
||||
self.current_slide.is_last()
|
||||
and self.current_slide.end_animation == self.current_animation
|
||||
):
|
||||
print("HERE")
|
||||
state = State.WAIT
|
||||
else:
|
||||
# Play next video!
|
||||
self.current_animation += 1
|
||||
self.load_this_cap(self.current_animation)
|
||||
self.current_animation = self.next_animation
|
||||
self.load_animation_cap(self.current_animation)
|
||||
# Reset video to position zero if it has been played before
|
||||
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
|
||||
@ -174,31 +243,50 @@ class Presentation:
|
||||
|
||||
|
||||
class Display:
|
||||
def __init__(self, presentations, config, start_paused=False, fullscreen=False):
|
||||
"""Displays one or more presentations one after each other."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
presentations,
|
||||
config,
|
||||
start_paused=False,
|
||||
fullscreen=False,
|
||||
skip_all=False,
|
||||
):
|
||||
self.presentations = presentations
|
||||
self.start_paused = start_paused
|
||||
self.config = config
|
||||
self.skip_all = skip_all
|
||||
self.fullscreen = fullscreen
|
||||
self.is_windows = platform.system() == "Windows"
|
||||
|
||||
self.state = State.PLAYING
|
||||
self.lastframe = None
|
||||
self.current_presentation_i = 0
|
||||
self.current_presentation_index = 0
|
||||
self.exit = False
|
||||
|
||||
self.lag = 0
|
||||
self.last_time = now()
|
||||
|
||||
if platform.system() == "Windows":
|
||||
if self.is_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)
|
||||
if self.fullscreen:
|
||||
cv2.namedWindow(WINDOW_NAME, cv2.WND_PROP_FULLSCREEN)
|
||||
cv2.setWindowProperty(
|
||||
"Video", cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN
|
||||
WINDOW_NAME, cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN
|
||||
)
|
||||
|
||||
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]
|
||||
|
||||
scale_height = self.screen_height / frame_height
|
||||
@ -206,14 +294,16 @@ class Display:
|
||||
|
||||
scale = min(scale_height, scale_width)
|
||||
|
||||
return cv2.resize(frame, (int(scale * frame_height, scale * frame_width)))
|
||||
return cv2.resize(frame, (int(scale * frame_height), int(scale * frame_width)))
|
||||
|
||||
@property
|
||||
def current_presentation(self):
|
||||
return self.presentations[self.current_presentation_i]
|
||||
def current_presentation(self) -> Presentation:
|
||||
"""Returns the current presentation"""
|
||||
return self.presentations[self.current_presentation_index]
|
||||
|
||||
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.state
|
||||
)
|
||||
@ -222,27 +312,30 @@ class Display:
|
||||
self.state = State.PAUSED
|
||||
self.start_paused = False
|
||||
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()
|
||||
continue
|
||||
else:
|
||||
self.current_presentation_i += 1
|
||||
self.current_presentation_index += 1
|
||||
self.state = State.PLAYING
|
||||
self.handle_key()
|
||||
self.show_video()
|
||||
self.show_info()
|
||||
|
||||
def show_video(self):
|
||||
"""Shows updated video."""
|
||||
self.lag = now() - self.last_time
|
||||
self.last_time = now()
|
||||
|
||||
frame = self.lastframe
|
||||
|
||||
if platform.system() == "Windows":
|
||||
if self.is_windows and self.fullscreen:
|
||||
frame = self.resize_frame_to_screen(frame)
|
||||
|
||||
cv2.imshow("Video", frame)
|
||||
cv2.imshow(WINDOW_NAME, frame)
|
||||
|
||||
def show_info(self):
|
||||
"""Shows updated information about presentations."""
|
||||
info = np.zeros((130, 420), np.uint8)
|
||||
font_args = (cv2.FONT_HERSHEY_SIMPLEX, 0.7, 255)
|
||||
grid_x = [30, 230]
|
||||
@ -258,27 +351,28 @@ class Display:
|
||||
|
||||
cv2.putText(
|
||||
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]),
|
||||
*font_args,
|
||||
)
|
||||
cv2.putText(
|
||||
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]),
|
||||
*font_args,
|
||||
)
|
||||
|
||||
cv2.putText(
|
||||
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]),
|
||||
*font_args,
|
||||
)
|
||||
|
||||
cv2.imshow("Info", info)
|
||||
cv2.imshow(f"{WINDOW_NAME}: Info", info)
|
||||
|
||||
def handle_key(self):
|
||||
"""Handles key strokes."""
|
||||
sleep_time = math.ceil(1000 / self.current_presentation.fps)
|
||||
key = cv2.waitKeyEx(fix_time(sleep_time - self.lag))
|
||||
|
||||
@ -291,28 +385,34 @@ class Display:
|
||||
elif self.state == State.WAIT and (
|
||||
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
|
||||
elif self.state == State.PLAYING and self.config.CONTINUE.match(key):
|
||||
self.current_presentation.next()
|
||||
elif (
|
||||
self.state == State.PLAYING and self.config.CONTINUE.match(key)
|
||||
) or self.skip_all:
|
||||
self.current_presentation.load_next_slide()
|
||||
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()
|
||||
if self.current_presentation.current_slide_index == 0:
|
||||
if self.current_presentation_index == 0:
|
||||
self.current_presentation.rewind_current_slide()
|
||||
else:
|
||||
self.current_presentation_index -= 1
|
||||
self.current_presentation.load_last_slide()
|
||||
self.state = State.PLAYING
|
||||
else:
|
||||
self.current_presentation.prev()
|
||||
self.current_presentation.load_previous_slide()
|
||||
self.state = State.PLAYING
|
||||
elif self.config.REVERSE.match(key):
|
||||
self.current_presentation.reverse_slide()
|
||||
self.current_presentation.reverse_current_slide()
|
||||
self.state = State.PLAYING
|
||||
elif self.config.REWIND.match(key):
|
||||
self.current_presentation.rewind_slide()
|
||||
self.current_presentation.rewind_current_slide()
|
||||
self.state = State.PLAYING
|
||||
|
||||
def quit(self):
|
||||
"""Destroys all windows created by presentations and exits gracefully."""
|
||||
cv2.destroyAllWindows()
|
||||
sys.exit()
|
||||
self.exit = True
|
||||
|
||||
|
||||
@click.command()
|
||||
@ -330,12 +430,18 @@ def list_scenes(folder):
|
||||
click.secho(f"{i}: {scene}", fg="green")
|
||||
|
||||
|
||||
def _list_scenes(folder):
|
||||
def _list_scenes(folder) -> List[str]:
|
||||
"""Lists available scenes in given directory."""
|
||||
scenes = []
|
||||
|
||||
for file in os.listdir(folder):
|
||||
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
|
||||
|
||||
@ -352,12 +458,12 @@ def _list_scenes(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",
|
||||
"--skip-all",
|
||||
is_flag=True,
|
||||
help="Show the next animation first frame as last frame (hack).",
|
||||
help="Skip all slides, useful the test if slides are working.",
|
||||
)
|
||||
@click.help_option("-h", "--help")
|
||||
def present(scenes, config_path, folder, start_paused, fullscreen, last_frame_next):
|
||||
def present(scenes, config_path, folder, start_paused, fullscreen, skip_all):
|
||||
"""Present the different scenes."""
|
||||
|
||||
if len(scenes) == 0:
|
||||
@ -402,15 +508,25 @@ def present(scenes, config_path, folder, start_paused, fullscreen, last_frame_ne
|
||||
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))
|
||||
try:
|
||||
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):
|
||||
config = Config.parse_file(config_path)
|
||||
try:
|
||||
config = Config.parse_file(config_path)
|
||||
except ValidationError as e:
|
||||
raise click.UsageError(str(e))
|
||||
else:
|
||||
config = Config()
|
||||
|
||||
display = Display(
|
||||
presentations, config=config, start_paused=start_paused, fullscreen=fullscreen
|
||||
presentations,
|
||||
config=config,
|
||||
start_paused=start_paused,
|
||||
fullscreen=fullscreen,
|
||||
skip_all=skip_all,
|
||||
)
|
||||
display.run()
|
||||
|
@ -3,25 +3,27 @@ import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import List
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
from .config import PresentationConfig, SlideConfig, SlideType
|
||||
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):
|
||||
"""Reverses a video file, writting the result to `dst`."""
|
||||
command = [FFMPEG_BIN, "-i", src, "-vf", "reverse", dst]
|
||||
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
process.communicate()
|
||||
|
||||
|
||||
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):
|
||||
if MANIMGL:
|
||||
if not os.path.isdir("videos"):
|
||||
@ -44,7 +46,8 @@ class Slide(Scene):
|
||||
self.pause_start_animation = 0
|
||||
|
||||
@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:
|
||||
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
|
||||
|
||||
@property
|
||||
def show_progress_bar(self):
|
||||
def show_progress_bar(self) -> bool:
|
||||
"""Returns True if progress bar should be displayed."""
|
||||
if MANIMGL:
|
||||
return getattr(super(Scene, self), "show_progress_bar", True)
|
||||
else:
|
||||
@ -67,19 +71,22 @@ class Slide(Scene):
|
||||
|
||||
@property
|
||||
def leave_progress_bar(self):
|
||||
"""Returns True if progress bar should be left after completed."""
|
||||
if MANIMGL:
|
||||
return getattr(super(Scene, self), "leave_progress_bars", False)
|
||||
else:
|
||||
return config["progress_bar"] == "leave"
|
||||
|
||||
def play(self, *args, **kwargs):
|
||||
"""Overloads `self.play` and increment animation count."""
|
||||
super().play(*args, **kwargs)
|
||||
self.current_animation += 1
|
||||
|
||||
def pause(self):
|
||||
"""Creates a new slide with previous animations."""
|
||||
self.slides.append(
|
||||
dict(
|
||||
type="slide",
|
||||
SlideConfig(
|
||||
type=SlideType.slide,
|
||||
start_animation=self.pause_start_animation,
|
||||
end_animation=self.current_animation,
|
||||
number=self.current_slide,
|
||||
@ -89,16 +96,18 @@ class Slide(Scene):
|
||||
self.pause_start_animation = self.current_animation
|
||||
|
||||
def start_loop(self):
|
||||
"""Starts a loop."""
|
||||
assert self.loop_start_animation is None, "You cannot nest loops"
|
||||
self.loop_start_animation = self.current_animation
|
||||
|
||||
def end_loop(self):
|
||||
"""Ends an existing loop."""
|
||||
assert (
|
||||
self.loop_start_animation is not None
|
||||
), "You have to start a loop before ending it"
|
||||
self.slides.append(
|
||||
dict(
|
||||
type="loop",
|
||||
SlideConfig(
|
||||
type=SlideType.loop,
|
||||
start_animation=self.loop_start_animation,
|
||||
end_animation=self.current_animation,
|
||||
number=self.current_slide,
|
||||
@ -109,7 +118,11 @@ class Slide(Scene):
|
||||
self.pause_start_animation = self.current_animation
|
||||
|
||||
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):
|
||||
os.mkdir(self.output_folder)
|
||||
|
||||
@ -139,9 +152,7 @@ class Slide(Scene):
|
||||
disable=not self.show_progress_bar,
|
||||
):
|
||||
filename = os.path.basename(src_file)
|
||||
_hash, ext = os.path.splitext(filename)
|
||||
|
||||
rev_filename = f"{_hash}_reversed{ext}"
|
||||
rev_filename = "{}_reversed{}".format(*os.path.splitext(filename))
|
||||
|
||||
dst_file = os.path.join(scene_files_folder, filename)
|
||||
# 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,))
|
||||
|
||||
f = open(slide_path, "w")
|
||||
json.dump(dict(slides=self.slides, files=files), f)
|
||||
f.close()
|
||||
with open(slide_path, "w") as f:
|
||||
f.write(PresentationConfig(slides=self.slides, files=files).json(indent=2))
|
||||
|
||||
logger.info(
|
||||
f"Slide '{scene_name}' configuration written in '{os.path.abspath(slide_path)}'"
|
||||
)
|
||||
@ -191,4 +202,10 @@ class Slide(Scene):
|
||||
|
||||
|
||||
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
|
||||
|
15
setup.py
15
setup.py
@ -1,18 +1,25 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
|
||||
import setuptools
|
||||
|
||||
from manim_slides import __version__ as version
|
||||
|
||||
if sys.version_info < (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:
|
||||
long_description = f.read()
|
||||
|
||||
setuptools.setup(
|
||||
name="manim-slides",
|
||||
version=version,
|
||||
version=version.__version__,
|
||||
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",
|
||||
@ -32,7 +39,7 @@ setuptools.setup(
|
||||
"numpy>=1.19.3",
|
||||
"pydantic>=1.9.1",
|
||||
"opencv-python>=4.6",
|
||||
"tqdm",
|
||||
"tqdm>=4.62.3",
|
||||
],
|
||||
classifiers=[
|
||||
"Programming Language :: Python :: 3",
|
||||
|
BIN
static/logo.png
Normal file
BIN
static/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 110 KiB |
39
static/logo.py
Normal file
39
static/logo.py
Normal file
@ -0,0 +1,39 @@
|
||||
from manim import *
|
||||
|
||||
|
||||
class ManimSlidesLogo(Scene):
|
||||
def construct(self):
|
||||
tex_template = TexTemplate()
|
||||
tex_template.add_to_preamble(r"\usepackage{graphicx}\usepackage{fontawesome5}")
|
||||
self.camera.background_color = "#ffffff"
|
||||
logo_green = "#87c2a5"
|
||||
logo_blue = "#525893"
|
||||
logo_red = "#e07a5f"
|
||||
logo_black = "#343434"
|
||||
ds_m = MathTex(r"\mathbb{M}", fill_color=logo_black).scale(7)
|
||||
ds_m.shift(2.25 * LEFT + 1.5 * UP)
|
||||
slides = MathTex(r"\mathbb{S}\text{lides}", fill_color=logo_black).scale(4)
|
||||
slides.next_to(ds_m, DOWN)
|
||||
slides.shift(DOWN)
|
||||
play = Tex(
|
||||
r"\faStepBackward\faStepForward",
|
||||
fill_color=logo_black,
|
||||
tex_template=tex_template,
|
||||
).scale(4)
|
||||
play.next_to(ds_m, LEFT)
|
||||
play.shift(LEFT + 0.5 * DOWN)
|
||||
comment = Tex(
|
||||
r"\reflectbox{\faComment*[regular]}",
|
||||
fill_color=logo_black,
|
||||
tex_template=tex_template,
|
||||
).scale(9)
|
||||
comment.move_to(play)
|
||||
comment.shift(0.4 * DOWN)
|
||||
circle = Circle(color=logo_green, fill_opacity=1).shift(LEFT)
|
||||
square = Square(color=logo_blue, fill_opacity=1).shift(UP)
|
||||
triangle = Triangle(color=logo_red, fill_opacity=1).shift(RIGHT)
|
||||
logo = VGroup(
|
||||
triangle, square, circle, ds_m, slides, comment, play
|
||||
) # order matters
|
||||
logo.move_to(ORIGIN)
|
||||
self.add(logo)
|
Reference in New Issue
Block a user