mirror of
https://github.com/jeertmans/manim-slides.git
synced 2025-05-18 03:05:21 +08:00
Compare commits
118 Commits
Author | SHA1 | Date | |
---|---|---|---|
d6ec0d3da9 | |||
546451e019 | |||
2457ca8a05 | |||
9900b3123e | |||
ee92e0aa88 | |||
cbee6320f5 | |||
382084f9ef | |||
068484b828 | |||
91f8d97acf | |||
49cdedc6fe | |||
fe1fa059f6 | |||
3f6d2e5e57 | |||
99ad798155 | |||
84c25f1ed5 | |||
7fb3fa01dd | |||
2d2a225afe | |||
b9d2cd92b5 | |||
620bb30960 | |||
b35a87befe | |||
84eb562f1b | |||
138cf014d2 | |||
4816fc9a41 | |||
f6f851bd09 | |||
87dba671ac | |||
c6c19bce89 | |||
58999d0681 | |||
8696fca829 | |||
2856aeb89b | |||
2ba0d48ac1 | |||
5f730593fb | |||
dfc5c9eb6c | |||
14c17e1d24 | |||
449ff4cd00 | |||
606c521573 | |||
b199fc7023 | |||
4b05f22c8c | |||
1da3492732 | |||
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 | |||
b3210ec285 | |||
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 |
72
.github/workflows/codeql-analysis.yml
vendored
Normal file
72
.github/workflows/codeql-analysis.yml
vendored
Normal file
@ -0,0 +1,72 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ "main" ]
|
||||
schedule:
|
||||
- cron: '45 3 * * 2'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'python' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
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 }}
|
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
|
50
.github/workflows/pages.yml
vendored
Normal file
50
.github/workflows/pages.yml
vendored
Normal file
@ -0,0 +1,50 @@
|
||||
# Simple workflow for deploying static content to GitHub Pages
|
||||
name: Deploy static content to Pages
|
||||
|
||||
on:
|
||||
# Runs on pushes targeting the default branch
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# Allow one concurrent deployment
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Single deploy job since we're just deploying
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v2
|
||||
- name: Install Linux Dependencies
|
||||
run: sudo apt install libcairo2-dev libpango1.0-dev ffmpeg
|
||||
- name: Install Python dependencies
|
||||
run: pip install manim sphinx sphinx_click furo
|
||||
- name: Install local Python package
|
||||
run: pip install -e .
|
||||
- name: Build docs
|
||||
run: cd docs && make html
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v1
|
||||
with:
|
||||
# Upload docs/build/html dir
|
||||
path: 'docs/build/html/'
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v1
|
55
.github/workflows/python-publish.yml
vendored
Normal file
55
.github/workflows/python-publish.yml
vendored
Normal file
@ -0,0 +1,55 @@
|
||||
# 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]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
# 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
|
||||
|
||||
- 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 }}
|
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
|
12
.gitignore
vendored
12
.gitignore
vendored
@ -8,4 +8,14 @@ __pycache__/
|
||||
/media
|
||||
/presentation
|
||||
|
||||
/.vscode
|
||||
/.vscode
|
||||
|
||||
slides/
|
||||
|
||||
.manim-slides.json
|
||||
|
||||
videos/
|
||||
|
||||
images/
|
||||
|
||||
docs/build/
|
||||
|
25
.pre-commit-config.yaml
Normal file
25
.pre-commit-config.yaml
Normal file
@ -0,0 +1,25 @@
|
||||
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/psf/black
|
||||
rev: 22.8.0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.10.1
|
||||
hooks:
|
||||
- id: isort
|
||||
name: isort (python)
|
||||
args: ["--profile", "black"]
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 22.8.0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/jendrikseipp/vulture
|
||||
rev: v2.3
|
||||
hooks:
|
||||
- id: vulture
|
@ -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>.
|
191
README.md
191
README.md
@ -1,32 +1,77 @@
|
||||
# manim-presentation
|
||||

|
||||
|
||||
Tool for live presentations using [manim](https://www.manim.community/)
|
||||
[![Latest Release][pypi-version-badge]][pypi-version-url]
|
||||
[![Python version][pypi-python-version-badge]][pypi-version-url]
|
||||

|
||||
# Manim Slides
|
||||
|
||||
## Install
|
||||
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!
|
||||
|
||||
- [Installation](#installation)
|
||||
* [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)
|
||||
- [F.A.Q](#faq)
|
||||
* [How to increase quality on Windows](#how-to-increase-quality-on-windows)
|
||||
- [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
|
||||
```
|
||||
pip install manim_presentation opencv-python
|
||||
|
||||
### Install From Repository
|
||||
|
||||
An alternative way to install Manim Slides is to clone the git repository, and install from there:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/jeertmans/manim-slides
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
> *Note:* the `-e` flag allows you to edit the files, and observe the changes directly when using Manim Slides
|
||||
|
||||
## Usage
|
||||
|
||||
Use the class `Slide` as your scenes base class
|
||||
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.
|
||||
|
||||
The command-line documentation is available [online](https://eertmans.be/manim-slides/).
|
||||
|
||||
### 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
|
||||
from manim_presentation import Slide
|
||||
# example.py
|
||||
|
||||
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)
|
||||
|
||||
```python
|
||||
from manim import *
|
||||
from manim_presentation import Slide
|
||||
# or: from manimlib import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class Example(Slide):
|
||||
def construct(self):
|
||||
@ -34,72 +79,114 @@ 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(...)`.
|
||||
|
||||
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_presentation Scene1 Scene2...
|
||||
```bash
|
||||
manim-slides [OPTIONS] Scene1 Scene2...
|
||||
```
|
||||
|
||||
## Default 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 |
|
||||
|
||||
## Run Example
|
||||
You can run the **configuration wizard** to change those key bindings:
|
||||
|
||||
Clone this repository
|
||||
|
||||
```
|
||||
git clone https://github.com/galatolofederico/manim-presentation.git
|
||||
cd manim-presentation
|
||||
```bash
|
||||
manim-slides wizard
|
||||
```
|
||||
|
||||
Create a virtualenv
|
||||
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`.
|
||||
|
||||
```
|
||||
virtualenv --python=python3.7 env
|
||||
. ./env/bin/activate
|
||||
A default file can be created with:
|
||||
|
||||
```bash
|
||||
manim-slides init
|
||||
```
|
||||
|
||||
Install `manim` and `manim_presentation`
|
||||
> **_NOTE:_** `manim-slides` uses `cv2.waitKeyEx()` to wait for keypresses, and directly registers the key code.
|
||||
|
||||
```
|
||||
pip install manim manim_presentation opencv-python
|
||||
```
|
||||
## Other Examples
|
||||
|
||||
Render the example scene
|
||||
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.
|
||||
|
||||
```
|
||||
manim -qh example.py
|
||||
```
|
||||
Below is a small recording of me playing with the slides back and forth.
|
||||
|
||||
Run the presentation
|
||||

|
||||
|
||||
```
|
||||
manim_presentation Example
|
||||
```
|
||||
|
||||
## Contributions and license
|
||||
## Features and Comparison with original manim-presentation
|
||||
|
||||
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.
|
||||
Below is a non-exhaustive list of features:
|
||||
|
||||
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)
|
||||
| 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: |
|
||||
|
||||
## F.A.Q
|
||||
|
||||
### How to increase quality on Windows
|
||||
|
||||
On Windows platform, one may encounter a lower image resolution than expected. Usually, this is observed because Windows rescales every application to fit the screen.
|
||||
As found by [@arashash](https://github.com/arashash), in [#20](https://github.com/jeertmans/manim-slides/issues/20), the problem can be addressed by changing the scaling factor to 100%:
|
||||
|
||||

|
||||
|
||||
in *Settings*->*Display*.
|
||||
|
||||
## 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/
|
||||
[pypi-python-version-badge]: https://img.shields.io/pypi/pyversions/manim-slides
|
||||
|
20
docs/Makefile
Normal file
20
docs/Makefile
Normal file
@ -0,0 +1,20 @@
|
||||
# Minimal makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line, and also
|
||||
# from the environment for the first two.
|
||||
SPHINXOPTS ?=
|
||||
SPHINXBUILD ?= sphinx-build
|
||||
SOURCEDIR = source
|
||||
BUILDDIR = build
|
||||
|
||||
# Put it first so that "make" without argument is like "make help".
|
||||
help:
|
||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
.PHONY: help Makefile
|
||||
|
||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||
%: Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
35
docs/make.bat
Normal file
35
docs/make.bat
Normal file
@ -0,0 +1,35 @@
|
||||
@ECHO OFF
|
||||
|
||||
pushd %~dp0
|
||||
|
||||
REM Command file for Sphinx documentation
|
||||
|
||||
if "%SPHINXBUILD%" == "" (
|
||||
set SPHINXBUILD=sphinx-build
|
||||
)
|
||||
set SOURCEDIR=source
|
||||
set BUILDDIR=build
|
||||
|
||||
%SPHINXBUILD% >NUL 2>NUL
|
||||
if errorlevel 9009 (
|
||||
echo.
|
||||
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
||||
echo.installed, then set the SPHINXBUILD environment variable to point
|
||||
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
||||
echo.may add the Sphinx directory to PATH.
|
||||
echo.
|
||||
echo.If you don't have Sphinx installed, grab it from
|
||||
echo.https://www.sphinx-doc.org/
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if "%1" == "" goto help
|
||||
|
||||
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||
goto end
|
||||
|
||||
:help
|
||||
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||
|
||||
:end
|
||||
popd
|
BIN
docs/source/_static/logo.png
Normal file
BIN
docs/source/_static/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 110 KiB |
26
docs/source/conf.py
Normal file
26
docs/source/conf.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Configuration file for the Sphinx documentation builder.
|
||||
#
|
||||
# For the full list of built-in configuration values, see the documentation:
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
||||
|
||||
project = "Manim Slides"
|
||||
copyright = "2022, Jérome Eertmans"
|
||||
author = "Jérome Eertmans"
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||
|
||||
extensions = ["sphinx.ext.autodoc", "sphinx_click"]
|
||||
|
||||
templates_path = ["_templates"]
|
||||
exclude_patterns = []
|
||||
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
|
||||
|
||||
html_theme = "furo"
|
||||
html_static_path = ["_static"]
|
20
docs/source/index.rst
Normal file
20
docs/source/index.rst
Normal file
@ -0,0 +1,20 @@
|
||||
.. manim-slides documentation master file, created by
|
||||
sphinx-quickstart on Wed Sep 21 15:07:28 2022.
|
||||
You can adapt this file completely to your liking, but it should at least
|
||||
contain the root `toctree` directive.
|
||||
|
||||
.. image:: _static/logo.png
|
||||
:width: 600
|
||||
:align: center
|
||||
:alt: Manim Slide logo
|
||||
|
||||
Welcome to Manim Slide's CLI documentation!
|
||||
===========================================
|
||||
|
||||
This page contains an exhaustive list of all the commands available with `manim-slides`.
|
||||
|
||||
If you need help installing or using Manim Slide, please refer to the `GitHub README <https://github.com/jeertmans/manim-slides>`_.
|
||||
|
||||
.. click:: manim_slides.main:cli
|
||||
:prog: manim-slides
|
||||
:nested: full
|
114
example.py
114
example.py
@ -1,5 +1,18 @@
|
||||
from manim import *
|
||||
from manim_presentation import Slide
|
||||
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
|
||||
|
||||
|
||||
class Example(Slide):
|
||||
def construct(self):
|
||||
@ -16,4 +29,99 @@ class Example(Slide):
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
self.pause()
|
||||
|
||||
self.wait()
|
||||
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))
|
||||
|
||||
|
||||
# For ThreeDExample, things are different
|
||||
|
||||
if not MANIMGL:
|
||||
|
||||
class ThreeDExample(ThreeDSlide):
|
||||
def construct(self):
|
||||
axes = ThreeDAxes()
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
dot = Dot(color=RED)
|
||||
|
||||
self.add(axes)
|
||||
|
||||
self.set_camera_orientation(phi=75 * DEGREES, theta=30 * DEGREES)
|
||||
|
||||
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.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))
|
||||
|
||||
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 +0,0 @@
|
||||
from manim_presentation.slide import Slide
|
@ -1,246 +0,0 @@
|
||||
import cv2
|
||||
import numpy as np
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import math
|
||||
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
|
||||
|
||||
class State(Enum):
|
||||
PLAYING = 0
|
||||
PAUSED = 1
|
||||
WAIT = 2
|
||||
END = 3
|
||||
|
||||
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 "..."
|
||||
|
||||
def now():
|
||||
return round(time.time() * 1000)
|
||||
|
||||
def fix_time(x):
|
||||
return x if x > 0 else 1
|
||||
|
||||
class Presentation:
|
||||
def __init__(self, config):
|
||||
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
|
||||
|
||||
|
||||
def add_last_slide(self):
|
||||
last_slide_end = self.slides[-1]["end_animation"]
|
||||
last_animation = len(self.files) - 1
|
||||
self.slides.append(dict(
|
||||
start_animation = last_slide_end,
|
||||
end_animation = last_animation,
|
||||
type = "last",
|
||||
number = len(self.slides) + 1
|
||||
))
|
||||
|
||||
def load_files(self):
|
||||
self.caps = list()
|
||||
for f in self.files:
|
||||
self.caps.append(cv2.VideoCapture(f))
|
||||
|
||||
def next(self):
|
||||
self.current_slide_i += 1
|
||||
self.current_animation = self.current_slide["start_animation"]
|
||||
|
||||
def prev(self):
|
||||
self.current_slide_i = max(0, self.current_slide_i - 1)
|
||||
self.current_animation = self.current_slide["start_animation"]
|
||||
|
||||
def rewind(self):
|
||||
self.current_animation = self.current_slide["start_animation"]
|
||||
|
||||
@property
|
||||
def current_slide(self):
|
||||
return self.slides[self.current_slide_i]
|
||||
|
||||
@property
|
||||
def current_cap(self):
|
||||
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:
|
||||
self.lastframe = frame
|
||||
else:
|
||||
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
if self.current_slide["end_animation"] == self.current_animation + 1:
|
||||
if self.current_slide["type"] == "slide":
|
||||
state = State.WAIT
|
||||
elif self.current_slide["type"] == "loop":
|
||||
self.current_animation = self.current_slide["start_animation"]
|
||||
elif self.current_slide["type"] == "last":
|
||||
return self.lastframe, State.END
|
||||
elif self.current_slide["type"] == "last" and self.current_slide["end_animation"] == self.current_animation:
|
||||
return self.lastframe, State.END
|
||||
else:
|
||||
self.current_animation += 1
|
||||
|
||||
return self.lastframe, state
|
||||
|
||||
|
||||
class Display:
|
||||
def __init__(self, presentations, start_paused=False):
|
||||
self.presentations = presentations
|
||||
self.start_paused = start_paused
|
||||
|
||||
self.state = State.PLAYING
|
||||
self.lastframe = None
|
||||
self.current_presentation_i = 0
|
||||
|
||||
self.lag = 0
|
||||
self.last_time = now()
|
||||
|
||||
@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()
|
||||
if self.start_paused:
|
||||
self.state = State.PAUSED
|
||||
self.start_paused = False
|
||||
if self.state == State.END:
|
||||
if self.current_presentation_i == len(self.presentations) - 1:
|
||||
self.quit()
|
||||
else:
|
||||
self.current_presentation_i += 1
|
||||
self.state = State.PLAYING
|
||||
|
||||
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)
|
||||
grid_x = [30, 230]
|
||||
grid_y = [30, 70, 110]
|
||||
|
||||
cv2.putText(
|
||||
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
|
||||
)
|
||||
|
||||
cv2.putText(
|
||||
info,
|
||||
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']}",
|
||||
(grid_x[1], grid_y[1]),
|
||||
*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
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
if key == Config.QUIT_KEY:
|
||||
self.quit()
|
||||
elif self.state == State.PLAYING and key == Config.PLAYPAUSE_KEY:
|
||||
self.state = State.PAUSED
|
||||
elif self.state == State.PAUSED and key == Config.PLAYPAUSE_KEY:
|
||||
self.state = State.PLAYING
|
||||
elif self.state == State.WAIT and (key == Config.CONTINUE_KEY or key == Config.PLAYPAUSE_KEY):
|
||||
self.current_presentation.next()
|
||||
self.state = State.PLAYING
|
||||
elif self.state == State.PLAYING and key == Config.CONTINUE_KEY:
|
||||
self.current_presentation.next()
|
||||
elif key == Config.BACK_KEY:
|
||||
if self.current_presentation.current_slide_i == 0:
|
||||
self.current_presentation_i = max(0, self.current_presentation_i - 1)
|
||||
self.current_presentation.reset()
|
||||
self.state = State.PLAYING
|
||||
else:
|
||||
self.current_presentation.prev()
|
||||
self.state = State.PLAYING
|
||||
elif key == Config.REWIND_KEY:
|
||||
self.current_presentation.rewind()
|
||||
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()
|
||||
|
||||
presentations = list()
|
||||
for scene in args.scenes:
|
||||
config_file = os.path.join(args.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))
|
||||
|
||||
display = Display(presentations, start_paused=args.start_paused)
|
||||
display.run()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
@ -1,82 +0,0 @@
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
from manim import Scene, config
|
||||
|
||||
class Slide(Scene):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.output_folder = kwargs.pop("output_folder", "./presentation")
|
||||
super(Slide, self).__init__(*args, **kwargs)
|
||||
self.slides = list()
|
||||
self.current_slide = 1
|
||||
self.current_animation = 0
|
||||
self.loop_start_animation = None
|
||||
self.pause_start_animation = 0
|
||||
|
||||
def play(self, *args, **kwargs):
|
||||
super(Slide, self).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.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"
|
||||
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
|
||||
))
|
||||
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):
|
||||
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))
|
||||
shutil.copyfile(src_file, dst_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()
|
2
manim_slides/__init__.py
Normal file
2
manim_slides/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .__version__ import __version__
|
||||
from .slide import Slide, ThreeDSlide
|
1
manim_slides/__version__.py
Normal file
1
manim_slides/__version__.py
Normal file
@ -0,0 +1 @@
|
||||
__version__ = "4.1.1"
|
33
manim_slides/commons.py
Normal file
33
manim_slides/commons.py
Normal file
@ -0,0 +1,33 @@
|
||||
from typing import Callable
|
||||
|
||||
import click
|
||||
|
||||
from .defaults import CONFIG_PATH
|
||||
|
||||
|
||||
def config_path_option(function) -> Callable:
|
||||
"""Wraps a function to add configuration path option."""
|
||||
return click.option(
|
||||
"-c",
|
||||
"--config",
|
||||
"config_path",
|
||||
default=CONFIG_PATH,
|
||||
type=click.Path(dir_okay=False),
|
||||
help="Set path to configuration file.",
|
||||
show_default=True,
|
||||
)(function)
|
||||
|
||||
|
||||
def config_options(function) -> Callable:
|
||||
"""Wraps a function to add configuration options."""
|
||||
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
|
140
manim_slides/config.py
Normal file
140
manim_slides/config.py
Normal file
@ -0,0 +1,140 @@
|
||||
import os
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Set
|
||||
|
||||
from pydantic import BaseModel, 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
|
||||
|
||||
@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):
|
||||
"""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")
|
||||
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")
|
||||
|
||||
@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(
|
||||
"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
|
||||
|
||||
|
||||
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"]:
|
||||
|
||||
if values["start_animation"] == values["end_animation"] == 0:
|
||||
raise ValueError(
|
||||
"You have to play at least one animation (e.g., `self.wait()`) before pausing. If you want to start paused, use the approriate command-line option when presenting."
|
||||
)
|
||||
|
||||
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
|
14
manim_slides/defaults.py
Normal file
14
manim_slides/defaults.py
Normal file
@ -0,0 +1,14 @@
|
||||
import platform
|
||||
|
||||
import cv2
|
||||
|
||||
FONT_ARGS = (cv2.FONT_HERSHEY_SIMPLEX, 1, 255, 1, cv2.LINE_AA)
|
||||
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
|
27
manim_slides/main.py
Normal file
27
manim_slides/main.py
Normal file
@ -0,0 +1,27 @@
|
||||
import click
|
||||
from click_default_group import DefaultGroup
|
||||
|
||||
from . import __version__
|
||||
from .present import list_scenes, 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():
|
||||
"""
|
||||
Manim Slides command-line utilities.
|
||||
|
||||
If not command is specified, defaults to `present`.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
cli.add_command(list_scenes)
|
||||
cli.add_command(present)
|
||||
cli.add_command(wizard)
|
||||
cli.add_command(init)
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
66
manim_slides/manim.py
Normal file
66
manim_slides/manim.py
Normal file
@ -0,0 +1,66 @@
|
||||
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
|
||||
|
||||
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_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 ModuleNotFoundError(
|
||||
"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:
|
||||
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
|
651
manim_slides/present.py
Normal file
651
manim_slides/present.py
Normal file
@ -0,0 +1,651 @@
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import time
|
||||
from enum import IntEnum, auto, unique
|
||||
from typing import List, Tuple
|
||||
|
||||
import click
|
||||
import cv2
|
||||
import numpy as np
|
||||
from pydantic import ValidationError
|
||||
from tqdm import tqdm
|
||||
|
||||
from .commons import config_path_option
|
||||
from .config import Config, PresentationConfig, SlideConfig, SlideType
|
||||
from .defaults import CONFIG_PATH, FOLDER_PATH, FONT_ARGS
|
||||
|
||||
INTERPOLATION_FLAGS = {
|
||||
"nearest": cv2.INTER_NEAREST,
|
||||
"linear": cv2.INTER_LINEAR,
|
||||
"cubic": cv2.INTER_CUBIC,
|
||||
"area": cv2.INTER_AREA,
|
||||
"lanczos4": cv2.INTER_LANCZOS4,
|
||||
"linear-exact": cv2.INTER_LINEAR_EXACT,
|
||||
"nearest-exact": cv2.INTER_NEAREST_EXACT,
|
||||
}
|
||||
|
||||
WINDOW_NAME = "Manim Slides"
|
||||
WINDOW_INFO_NAME = f"{WINDOW_NAME}: Info"
|
||||
WINDOWS = platform.system() == "Windows"
|
||||
|
||||
|
||||
@unique
|
||||
class State(IntEnum):
|
||||
"""Represents all possible states of a slide presentation."""
|
||||
|
||||
PLAYING = auto()
|
||||
PAUSED = auto()
|
||||
WAIT = auto()
|
||||
END = auto()
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name.capitalize()
|
||||
|
||||
|
||||
def now() -> int:
|
||||
"""Returns time.time() in milliseconds."""
|
||||
return round(time.time() * 1000)
|
||||
|
||||
|
||||
def fix_time(t: float) -> float:
|
||||
"""Clips time t such that it is always positive."""
|
||||
return t if t > 0 else 1
|
||||
|
||||
|
||||
class Presentation:
|
||||
"""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.current_file = None
|
||||
|
||||
self.loaded_animation_cap = -1
|
||||
self.cap = None # cap = cv2.VideoCapture
|
||||
|
||||
self.reverse = False
|
||||
self.reversed_animation = -1
|
||||
|
||||
self.lastframe = None
|
||||
|
||||
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.current_file = file
|
||||
|
||||
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 cancel_reverse(self):
|
||||
"""Cancels any effet produced by a reversed slide."""
|
||||
if self.reverse:
|
||||
self.reverse = False
|
||||
self.reversed_animation = -1
|
||||
self.release_cap()
|
||||
|
||||
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.cancel_reverse()
|
||||
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.cancel_reverse()
|
||||
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):
|
||||
"""Add a 'last' slide to the end of slides."""
|
||||
self.slides.append(
|
||||
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_animation_cap(0)
|
||||
self.current_slide_index = 0
|
||||
self.slides[-1].terminated = False
|
||||
|
||||
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:
|
||||
return self.current_animation + 1
|
||||
|
||||
@property
|
||||
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_frame_number(self) -> int:
|
||||
"""Returns current frame number."""
|
||||
return int(self.current_cap.get(cv2.CAP_PROP_POS_FRAMES))
|
||||
|
||||
def update_state(self, state) -> Tuple[np.ndarray, State]:
|
||||
"""
|
||||
Updates the current state given the previous one.
|
||||
|
||||
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 self.lastframe is None:
|
||||
_, self.lastframe = self.current_cap.read()
|
||||
return self.lastframe, state
|
||||
still_playing, frame = self.current_cap.read()
|
||||
if still_playing:
|
||||
self.lastframe = frame
|
||||
elif state in [state.WAIT, state.PAUSED]:
|
||||
return self.lastframe, state
|
||||
elif self.current_slide.is_last() and self.current_slide.terminated:
|
||||
return self.lastframe, State.END
|
||||
else: # not still playing
|
||||
if self.is_last_animation:
|
||||
if self.current_slide.is_slide():
|
||||
state = State.WAIT
|
||||
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.is_last()
|
||||
and self.current_slide.end_animation == self.current_animation
|
||||
):
|
||||
state = State.WAIT
|
||||
else:
|
||||
# Play next video!
|
||||
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)
|
||||
|
||||
return self.lastframe, state
|
||||
|
||||
|
||||
class Display:
|
||||
"""Displays one or more presentations one after each other."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
presentations,
|
||||
config,
|
||||
start_paused=False,
|
||||
fullscreen=False,
|
||||
skip_all=False,
|
||||
resolution=(1980, 1080),
|
||||
interpolation_flag=cv2.INTER_LINEAR,
|
||||
record_to=None,
|
||||
):
|
||||
self.presentations = presentations
|
||||
self.start_paused = start_paused
|
||||
self.config = config
|
||||
self.skip_all = skip_all
|
||||
self.fullscreen = fullscreen
|
||||
self.resolution = resolution
|
||||
self.interpolation_flag = interpolation_flag
|
||||
self.record_to = record_to
|
||||
self.recordings = []
|
||||
self.window_flags = (
|
||||
cv2.WINDOW_GUI_NORMAL | cv2.WINDOW_FREERATIO | cv2.WINDOW_NORMAL
|
||||
)
|
||||
|
||||
self.state = State.PLAYING
|
||||
self.lastframe = None
|
||||
self.current_presentation_index = 0
|
||||
self.exit = False
|
||||
|
||||
self.lag = 0
|
||||
self.last_time = now()
|
||||
|
||||
cv2.namedWindow(
|
||||
WINDOW_INFO_NAME,
|
||||
cv2.WINDOW_GUI_NORMAL | cv2.WINDOW_FREERATIO | cv2.WINDOW_AUTOSIZE,
|
||||
)
|
||||
|
||||
if self.fullscreen:
|
||||
cv2.namedWindow(
|
||||
WINDOW_NAME, cv2.WINDOW_GUI_NORMAL | cv2.WND_PROP_FULLSCREEN
|
||||
)
|
||||
cv2.setWindowProperty(
|
||||
WINDOW_NAME, cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN
|
||||
)
|
||||
else:
|
||||
cv2.namedWindow(WINDOW_NAME, self.window_flags)
|
||||
cv2.resizeWindow(WINDOW_NAME, *self.resolution)
|
||||
|
||||
@property
|
||||
def current_presentation(self) -> Presentation:
|
||||
"""Returns the current presentation."""
|
||||
return self.presentations[self.current_presentation_index]
|
||||
|
||||
def run(self):
|
||||
"""Runs a series of presentations until end or exit."""
|
||||
while not self.exit:
|
||||
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
|
||||
if self.state == State.END:
|
||||
if self.current_presentation_index == len(self.presentations) - 1:
|
||||
self.quit()
|
||||
continue
|
||||
else:
|
||||
self.current_presentation_index += 1
|
||||
self.state = State.PLAYING
|
||||
self.handle_key()
|
||||
if self.exit:
|
||||
continue
|
||||
self.show_video()
|
||||
self.show_info()
|
||||
|
||||
def show_video(self):
|
||||
"""Shows updated video."""
|
||||
self.lag = now() - self.last_time
|
||||
self.last_time = now()
|
||||
|
||||
if not self.record_to is None:
|
||||
pres = self.current_presentation
|
||||
self.recordings.append(
|
||||
(pres.current_file, pres.current_frame_number, pres.fps)
|
||||
)
|
||||
|
||||
frame = self.lastframe
|
||||
|
||||
# If Window was manually closed (impossible in fullscreen),
|
||||
# we reopen it
|
||||
if cv2.getWindowProperty(WINDOW_NAME, cv2.WND_PROP_VISIBLE) < 1:
|
||||
cv2.namedWindow(WINDOW_NAME, self.window_flags)
|
||||
cv2.resizeWindow(WINDOW_NAME, *self.resolution)
|
||||
|
||||
if WINDOWS: # Only resize on Windows
|
||||
_, _, w, h = cv2.getWindowImageRect(WINDOW_NAME)
|
||||
|
||||
if (h, w) != frame.shape[:2]: # Only if shape is different
|
||||
frame = cv2.resize(frame, (w, h), self.interpolation_flag)
|
||||
|
||||
cv2.imshow(WINDOW_NAME, frame)
|
||||
|
||||
def show_info(self):
|
||||
"""Shows updated information about presentations."""
|
||||
info = np.zeros((130, 420), np.uint8)
|
||||
font_args = (FONT_ARGS[0], 0.7, *FONT_ARGS[2:])
|
||||
grid_x = [30, 230]
|
||||
grid_y = [30, 70, 110]
|
||||
|
||||
cv2.putText(
|
||||
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)
|
||||
|
||||
cv2.putText(
|
||||
info,
|
||||
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}",
|
||||
(grid_x[1], grid_y[1]),
|
||||
*font_args,
|
||||
)
|
||||
|
||||
cv2.putText(
|
||||
info,
|
||||
f"Scene {self.current_presentation_index + 1}/{len(self.presentations)}",
|
||||
((grid_x[0] + grid_x[1]) // 2, grid_y[2]),
|
||||
*font_args,
|
||||
)
|
||||
|
||||
cv2.imshow(WINDOW_INFO_NAME, 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))
|
||||
|
||||
if self.config.QUIT.match(key):
|
||||
self.quit()
|
||||
elif self.state == State.PLAYING and self.config.PLAY_PAUSE.match(key):
|
||||
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)
|
||||
):
|
||||
self.current_presentation.load_next_slide()
|
||||
self.state = State.PLAYING
|
||||
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_index == 0:
|
||||
if self.current_presentation_index == 0:
|
||||
self.current_presentation.load_previous_slide()
|
||||
else:
|
||||
self.current_presentation_index -= 1
|
||||
self.current_presentation.load_last_slide()
|
||||
self.state = State.PLAYING
|
||||
else:
|
||||
self.current_presentation.load_previous_slide()
|
||||
self.state = State.PLAYING
|
||||
elif self.config.REVERSE.match(key):
|
||||
self.current_presentation.reverse_current_slide()
|
||||
self.state = State.PLAYING
|
||||
elif self.config.REWIND.match(key):
|
||||
self.current_presentation.cancel_reverse()
|
||||
self.current_presentation.rewind_current_slide()
|
||||
self.state = State.PLAYING
|
||||
|
||||
def quit(self):
|
||||
"""Destroys all windows created by presentations and exits gracefully."""
|
||||
cv2.destroyAllWindows()
|
||||
|
||||
if not self.record_to is None and len(self.recordings) > 0:
|
||||
file, frame_number, fps = self.recordings[0]
|
||||
|
||||
cap = cv2.VideoCapture(file)
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number - 1)
|
||||
_, frame = cap.read()
|
||||
|
||||
w, h = frame.shape[:2]
|
||||
fourcc = cv2.VideoWriter_fourcc(*"XVID")
|
||||
out = cv2.VideoWriter(self.record_to, fourcc, fps, (h, w))
|
||||
|
||||
out.write(frame)
|
||||
|
||||
for _file, frame_number, _ in tqdm(
|
||||
self.recordings[1:], desc="Creating recording file", leave=False
|
||||
):
|
||||
if file != _file:
|
||||
cap.release()
|
||||
file = _file
|
||||
cap = cv2.VideoCapture(_file)
|
||||
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number - 1)
|
||||
_, frame = cap.read()
|
||||
out.write(frame)
|
||||
|
||||
cap.release()
|
||||
out.release()
|
||||
|
||||
self.exit = True
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--folder",
|
||||
default=FOLDER_PATH,
|
||||
type=click.Path(exists=True, file_okay=False),
|
||||
help="Set slides folder.",
|
||||
show_default=True,
|
||||
)
|
||||
@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) -> List[str]:
|
||||
"""Lists available scenes in given directory."""
|
||||
scenes = []
|
||||
|
||||
for file in os.listdir(folder):
|
||||
if file.endswith(".json"):
|
||||
try:
|
||||
filepath = os.path.join(folder, file)
|
||||
_ = PresentationConfig.parse_file(filepath)
|
||||
scenes.append(os.path.basename(file)[:-5])
|
||||
except Exception: # Could not parse this file as a proper presentation config
|
||||
pass
|
||||
|
||||
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.",
|
||||
show_default=True,
|
||||
)
|
||||
@click.option("--start-paused", is_flag=True, help="Start paused.")
|
||||
@click.option("--fullscreen", is_flag=True, help="Fullscreen mode.")
|
||||
@click.option(
|
||||
"--skip-all",
|
||||
is_flag=True,
|
||||
help="Skip all slides, useful the test if slides are working.",
|
||||
)
|
||||
@click.option(
|
||||
"-r",
|
||||
"--resolution",
|
||||
type=(int, int),
|
||||
default=(1920, 1080),
|
||||
help="Window resolution WIDTH HEIGHT used if fullscreen is not set. You may manually resize the window afterward.",
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
"-i",
|
||||
"--interpolation-flag",
|
||||
type=click.Choice(INTERPOLATION_FLAGS.keys(), case_sensitive=False),
|
||||
default="linear",
|
||||
help="Set the interpolation flag to be used when resizing image. See OpenCV cv::InterpolationFlags.",
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
"--record-to",
|
||||
type=click.Path(dir_okay=False),
|
||||
default=None,
|
||||
help="If set, the presentation will be recorded into a AVI video file with given name.",
|
||||
)
|
||||
@click.help_option("-h", "--help")
|
||||
def present(
|
||||
scenes,
|
||||
config_path,
|
||||
folder,
|
||||
start_paused,
|
||||
fullscreen,
|
||||
skip_all,
|
||||
resolution,
|
||||
interpolation_flag,
|
||||
record_to,
|
||||
):
|
||||
"""
|
||||
Present SCENE(s), one at a time, in order.
|
||||
|
||||
Each SCENE parameter must be the name of a Manim scene, with existing SCENE.json config file.
|
||||
|
||||
You can present the same SCENE multiple times by repeating the parameter.
|
||||
|
||||
Use `manim-slide list-scenes` to list all available scenes in a given folder.
|
||||
"""
|
||||
|
||||
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 click.UsageError(
|
||||
f"File {config_file} does not exist, check the scene name and make sure to use Slide as your scene base class"
|
||||
)
|
||||
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):
|
||||
try:
|
||||
config = Config.parse_file(config_path)
|
||||
except ValidationError as e:
|
||||
raise click.UsageError(str(e))
|
||||
else:
|
||||
config = Config()
|
||||
|
||||
if not record_to is None:
|
||||
_, ext = os.path.splitext(record_to)
|
||||
if ext.lower() != ".avi":
|
||||
raise click.UsageError(
|
||||
f"Recording only support '.avi' extension. For other video formats, please convert the resulting '.avi' file afterwards."
|
||||
)
|
||||
|
||||
display = Display(
|
||||
presentations,
|
||||
config=config,
|
||||
start_paused=start_paused,
|
||||
fullscreen=fullscreen,
|
||||
skip_all=skip_all,
|
||||
resolution=resolution,
|
||||
interpolation_flag=INTERPOLATION_FLAGS[interpolation_flag],
|
||||
record_to=record_to,
|
||||
)
|
||||
display.run()
|
211
manim_slides/slide.py
Normal file
211
manim_slides/slide.py
Normal file
@ -0,0 +1,211 @@
|
||||
import json
|
||||
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_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"):
|
||||
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
|
||||
self.current_animation = 0
|
||||
self.loop_start_animation = None
|
||||
self.pause_start_animation = 0
|
||||
|
||||
@property
|
||||
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
|
||||
|
||||
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) -> bool:
|
||||
"""Returns True if progress bar should be displayed."""
|
||||
if MANIMGL:
|
||||
return getattr(super(Scene, self), "show_progress_bar", True)
|
||||
else:
|
||||
return config["progress_bar"] != "none"
|
||||
|
||||
@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(
|
||||
SlideConfig(
|
||||
type=SlideType.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
|
||||
|
||||
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(
|
||||
SlideConfig(
|
||||
type=SlideType.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 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)
|
||||
|
||||
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)
|
||||
|
||||
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 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)
|
||||
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
|
||||
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)
|
||||
|
||||
logger.info(
|
||||
f"Copied {len(files)} animations to '{os.path.abspath(scene_files_folder)}' and generated reversed animations"
|
||||
)
|
||||
|
||||
slide_path = os.path.join(self.output_folder, "%s.json" % (scene_name,))
|
||||
|
||||
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)}'"
|
||||
)
|
||||
|
||||
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):
|
||||
"""
|
||||
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
|
98
manim_slides/wizard.py
Normal file
98
manim_slides/wizard.py
Normal file
@ -0,0 +1,98 @@
|
||||
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, FONT_ARGS
|
||||
|
||||
WINDOW_NAME = "Manim Slides Configuration Wizard"
|
||||
WINDOW_SIZE = (120, 620)
|
||||
|
||||
|
||||
def center_text_horizontally(text, window_size, font_args) -> int:
|
||||
"""Returns centered position for text to be displayed in current window."""
|
||||
_, width = window_size
|
||||
font, scale, _, thickness, _ = font_args
|
||||
(size_in_pixels, _), _ = cv2.getTextSize(text, font, scale, thickness)
|
||||
return (width - size_in_pixels) // 2
|
||||
|
||||
|
||||
def prompt(question: str) -> int:
|
||||
"""Diplays some question in current window and waits for key press."""
|
||||
display = np.zeros(WINDOW_SIZE, np.uint8)
|
||||
|
||||
text = "* Manim Slides Wizard *"
|
||||
text_org = center_text_horizontally(text, WINDOW_SIZE, FONT_ARGS), 33
|
||||
question_org = center_text_horizontally(question, WINDOW_SIZE, FONT_ARGS), 85
|
||||
|
||||
cv2.putText(display, "* Manim Slides Wizard *", text_org, *FONT_ARGS)
|
||||
cv2.putText(display, question, question_org, *FONT_ARGS)
|
||||
|
||||
cv2.imshow(WINDOW_NAME, display)
|
||||
return cv2.waitKeyEx(-1)
|
||||
|
||||
|
||||
@click.command()
|
||||
@config_options
|
||||
@click.help_option("-h", "--help")
|
||||
def wizard(config_path, force, merge):
|
||||
"""Launch configuration wizard."""
|
||||
return _init(config_path, force, merge, skip_interactive=False)
|
||||
|
||||
|
||||
@click.command()
|
||||
@config_options
|
||||
@click.help_option("-h", "--help")
|
||||
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):
|
||||
"""Actual initialization code for configuration file, with optional interactive mode."""
|
||||
|
||||
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:
|
||||
|
||||
cv2.namedWindow(
|
||||
WINDOW_NAME,
|
||||
cv2.WINDOW_GUI_NORMAL | cv2.WINDOW_FREERATIO | cv2.WINDOW_AUTOSIZE,
|
||||
)
|
||||
|
||||
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=2))
|
||||
|
||||
click.echo(f"Configuration file successfully save to `{config_path}`")
|
2
pyproject.toml
Normal file
2
pyproject.toml
Normal file
@ -0,0 +1,2 @@
|
||||
[tool.vulture]
|
||||
paths = ["manim_slides"]
|
49
setup.py
49
setup.py
@ -1,28 +1,53 @@
|
||||
import setuptools
|
||||
import importlib.util
|
||||
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+")
|
||||
|
||||
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_presentation",
|
||||
version="0.1.2",
|
||||
author="Federico A. Galatolo",
|
||||
author_email="federico.galatolo@ing.unipi.it",
|
||||
name="manim-slides",
|
||||
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",
|
||||
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",
|
||||
"tqdm>=4.62.3",
|
||||
],
|
||||
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 |
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)
|
BIN
static/windows_quality_fix.png
Normal file
BIN
static/windows_quality_fix.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 35 KiB |
Reference in New Issue
Block a user