Compare commits
111 Commits
v4.11.0
...
v5.0.0-rc1
Author | SHA1 | Date | |
---|---|---|---|
48614105bd | |||
933afdd465 | |||
599f9f22ae | |||
5490a0a5ef | |||
c875363b40 | |||
bd9bf06876 | |||
4d76f2ccc1 | |||
8cf05ea44d | |||
638616c94f | |||
b321161717 | |||
7363281ff0 | |||
d056d8d8b1 | |||
c95929dc7d | |||
e08edb6fe1 | |||
efc3017df8 | |||
788727ea22 | |||
0bb0285b18 | |||
ce878bece2 | |||
1275e9119f | |||
5555ac2e54 | |||
838de83c75 | |||
f1f7146c7e | |||
fb02764bb7 | |||
f6f2e4090f | |||
146a2f7839 | |||
d282766f2d | |||
dec2f5e724 | |||
4d44166677 | |||
455f104a11 | |||
0f9048a27b | |||
9e1e0f2367 | |||
097ae8ffdd | |||
aace5dea11 | |||
738c54f9a6 | |||
f01f811639 | |||
94265f6842 | |||
6ed76ffd01 | |||
71df62d79b | |||
fc36909688 | |||
580da4a885 | |||
0de877e43a | |||
7313e3e0d4 | |||
1e967894db | |||
98fa5349d3 | |||
0e3ed3f9eb | |||
a45242236d | |||
66451473b8 | |||
72152bd625 | |||
88bffe0f0b | |||
a7719dbb8b | |||
529a6c534f | |||
2b6240c4d3 | |||
d98d41aaa8 | |||
d892a4e77d | |||
8069ab5405 | |||
f9e22fe63c | |||
b195b823ba | |||
fc8717fa9c | |||
dbbd6813ec | |||
a10902eaaa | |||
f4c1c34994 | |||
540c7034c8 | |||
86aeeb861b | |||
979e2c549a | |||
4e4e29380b | |||
caa4c48fe7 | |||
a353de270e | |||
60f284e748 | |||
501af3b658 | |||
f820819896 | |||
9279d2a22a | |||
e1d5fb732c | |||
384af332d8 | |||
c231f4d100 | |||
751eae74e9 | |||
97fe80caa2 | |||
a7eea6fbea | |||
04e2f265f6 | |||
8096636cf1 | |||
c7e38bfb38 | |||
421cad3038 | |||
9edf23856c | |||
62236f5796 | |||
1e28d70c0e | |||
6a96b3ab8c | |||
a1c041db80 | |||
4fd3452f95 | |||
ff2be6851b | |||
95289ee7a5 | |||
f1a026208a | |||
b3fd1d209e | |||
8c38db0989 | |||
6da0c36c96 | |||
3b01efa601 | |||
c9ef5e9a75 | |||
bfad43bd38 | |||
6f2cbc9b19 | |||
5bd88c2fd5 | |||
f0c17b1e2a | |||
fce9546a9b | |||
d6ad56120e | |||
5db0261b01 | |||
8ab33ef71f | |||
4da0e2cc2d | |||
0e82e28313 | |||
8b13106fcc | |||
bce4d8188f | |||
c420b47ad2 | |||
fad13f33dc | |||
d42a7f5ff1 | |||
88d598709a |
@ -1,5 +1,9 @@
|
||||
[bumpversion]
|
||||
current_version = 4.11.0
|
||||
current_version = 5.0.0-rc1
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(-rc(?P<release>\d+))?
|
||||
serialize =
|
||||
{major}.{minor}.{patch}-rc{release}
|
||||
{major}.{minor}.{patch}
|
||||
commit = True
|
||||
message = chore(version): bump {current_version} to {new_version}
|
||||
|
||||
@ -10,3 +14,7 @@ replace = __version__ = "{new_version}"
|
||||
[bumpversion:file:pyproject.toml]
|
||||
search = version = "{current_version}"
|
||||
replace = version = "{new_version}"
|
||||
|
||||
[bumpversion:file:CITATION.cff]
|
||||
search = version: v{current_version}
|
||||
replace = version: v{new_version}
|
||||
|
13
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: daily
|
||||
labels:
|
||||
- dependencies
|
48
.github/workflows/coverage.yml
vendored
Normal file
@ -0,0 +1,48 @@
|
||||
on: [push]
|
||||
|
||||
name: Code Coverage
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
QT_QPA_PLATFORM: offscreen
|
||||
MANIM_SLIDES_VERBOSITY: debug
|
||||
PYTHONFAULTHANDLER: 1
|
||||
DISPLAY: :99
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Poetry
|
||||
run: pipx install poetry
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: poetry
|
||||
|
||||
- name: Install manim dependencies on Ubuntu
|
||||
run: |
|
||||
sudo apt-get install libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev
|
||||
|
||||
- name: Install xvfb on Ubuntu
|
||||
run: |
|
||||
sudo apt-get install xvfb
|
||||
nohup Xvfb $DISPLAY &
|
||||
|
||||
- name: Install Manim Slides
|
||||
run: |
|
||||
poetry install --with test
|
||||
|
||||
- name: Run pytest and coverage
|
||||
run: poetry run pytest --cov-report xml --cov=manim_slides tests/
|
||||
|
||||
- name: Upload to codecov.io
|
||||
uses: codecov/codecov-action@v3
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
fail_ci_if_error: true
|
2
.github/workflows/draft-pdf.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
# This should be the path to the paper within your repo.
|
||||
paper-path: paper/paper.md
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v1
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: paper
|
||||
# This is the output path where Pandoc will write the compiled
|
||||
|
2
.github/workflows/languagetool.yml
vendored
@ -8,7 +8,7 @@ jobs:
|
||||
languagetool_check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v3
|
||||
- uses: reviewdog/action-languagetool@v1
|
||||
with:
|
||||
reporter: github-pr-review
|
||||
|
26
.github/workflows/pages.yml
vendored
@ -41,27 +41,33 @@ jobs:
|
||||
python-version: '3.9'
|
||||
cache: poetry
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v2
|
||||
uses: actions/configure-pages@v3
|
||||
- name: Install Linux Dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev
|
||||
- name: Install Python dependencies
|
||||
run: pip install manim sphinx sphinx_click furo
|
||||
- name: Setup Pandoc
|
||||
uses: nikeee/setup-pandoc@v1
|
||||
- name: Install local Python package
|
||||
run: poetry install --with docs
|
||||
- name: Install IPython kernel
|
||||
run: poetry run ipython kernel install --name "manim-slides" --user
|
||||
- name: Restore cached media
|
||||
id: cache-media-restore
|
||||
uses: actions/cache/restore@v3
|
||||
with:
|
||||
path: media
|
||||
key: ${{ runner.os }}-media
|
||||
- name: Build animation and convert it into HTML slides
|
||||
- name: Build animations
|
||||
run: |
|
||||
poetry run manim example.py ConvertExample BasicExample ThreeDExample
|
||||
poetry run manim-slides convert ConvertExample docs/source/_static/slides.html -ccontrols=true
|
||||
poetry run manim-slides convert BasicExample docs/source/_static/basic_example.html -ccontrols=true
|
||||
poetry run manim-slides convert ThreeDExample docs/source/_static/three_d_example.html -ccontrols=true
|
||||
- name: Convert animations to HTML slides
|
||||
run: |
|
||||
poetry run manim-slides convert -v DEBUG ConvertExample docs/source/_static/slides.html -ccontrols=true
|
||||
poetry run manim-slides convert -v DEBUG BasicExample docs/source/_static/basic_example.html -ccontrols=true
|
||||
poetry run manim-slides convert -v DEBUG ThreeDExample docs/source/_static/three_d_example.html -ccontrols=true
|
||||
- name: Show docs/source/_static/ dir content (video only)
|
||||
run: tree -L 3 docs/source/_static/ -P '*.mp4'
|
||||
- name: Clear cache
|
||||
run: |
|
||||
gh extension install actions/gh-actions-cache
|
||||
@ -78,11 +84,13 @@ jobs:
|
||||
run: cd docs && poetry run make html
|
||||
- name: Upload artifact
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-pages-artifact@v1
|
||||
uses: actions/upload-pages-artifact@v2
|
||||
with:
|
||||
# Upload docs/build/html dir
|
||||
path: docs/build/html/
|
||||
- name: Show docs/build/html/_static/ dir content (video only)
|
||||
run: tree -L 3 docs/build/html/_static/ -P '*.mp4'
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/deploy-pages@v1
|
||||
uses: actions/deploy-pages@v2
|
||||
|
50
.github/workflows/python-publish.yml
vendored
@ -1,4 +1,3 @@
|
||||
# Modified from: https://github.com/pypa/cibuildwheel
|
||||
name: Upload Python Package
|
||||
|
||||
on:
|
||||
@ -8,41 +7,28 @@ on:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build_wheels:
|
||||
name: Build wheels on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
build_and_release:
|
||||
name: Build and release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-python@v2
|
||||
- name: Install Poetry
|
||||
run: pipx install poetry
|
||||
|
||||
- name: Install build package
|
||||
run: python -m pip install -U build
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: poetry
|
||||
|
||||
- name: Build wheels
|
||||
run: python -m build --sdist
|
||||
run: poetry build
|
||||
|
||||
- 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 }}
|
||||
- name: Publish to PyPI
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
env:
|
||||
POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }}
|
||||
run: poetry publish
|
||||
|
@ -1,19 +1,53 @@
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.py'
|
||||
- .github/workflows/test_examples.yml
|
||||
workflow_dispatch:
|
||||
|
||||
name: Test Examples
|
||||
|
||||
env:
|
||||
QT_QPA_PLATFORM: offscreen
|
||||
MANIM_SLIDES_VERBOSITY: debug
|
||||
PYTHONFAULTHANDLER: 1
|
||||
DISPLAY: :99
|
||||
name: Tests
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
pyversion: ['3.8', '3.9', '3.10', '3.11']
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
QT_QPA_PLATFORM: offscreen
|
||||
MANIM_SLIDES_VERBOSITY: debug
|
||||
PYTHONFAULTHANDLER: 1
|
||||
DISPLAY: :99
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Poetry
|
||||
run: pipx install poetry
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.pyversion }}
|
||||
cache: poetry
|
||||
|
||||
- name: Run apt-get update on Ubuntu
|
||||
run: sudo apt-get update
|
||||
|
||||
- name: Install manim dependencies on Ubuntu
|
||||
run: |
|
||||
sudo apt-get install libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev
|
||||
|
||||
- name: Install xvfb on Ubuntu
|
||||
run: |
|
||||
sudo apt-get install xvfb
|
||||
nohup Xvfb $DISPLAY &
|
||||
|
||||
- name: Install Manim Slides
|
||||
run: |
|
||||
poetry install --with test
|
||||
|
||||
- name: Run pytest
|
||||
run: poetry run pytest -x -n auto
|
||||
|
||||
build-examples:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@ -43,11 +77,18 @@ jobs:
|
||||
pyversion: '3.10'
|
||||
manim: manim
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
QT_QPA_PLATFORM: offscreen
|
||||
MANIM_SLIDES_VERBOSITY: debug
|
||||
PYTHONFAULTHANDLER: 1
|
||||
DISPLAY: :99
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Poetry
|
||||
run: pipx install poetry
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
@ -60,9 +101,11 @@ jobs:
|
||||
run: |
|
||||
echo "${HOME}/.local/bin" >> $GITHUB_PATH
|
||||
echo "/Users/runner/Library/Python/${{ matrix.pyversion }}/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Append to Path on Ubuntu
|
||||
if: 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
|
||||
@ -71,25 +114,31 @@ jobs:
|
||||
- name: Install manim dependencies on MacOs
|
||||
if: matrix.os == 'macos-latest' && matrix.manim == 'manim'
|
||||
run: brew install ffmpeg py3cairo
|
||||
|
||||
- name: Install manimgl dependencies on MacOS
|
||||
if: matrix.os == 'macos-latest' && matrix.manim == 'manimgl'
|
||||
run: brew install ffmpeg
|
||||
|
||||
- name: Run apt-get update on Ubuntu
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: sudo apt-get update
|
||||
|
||||
- name: Install manim dependencies on Ubuntu
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.manim == 'manim'
|
||||
run: |
|
||||
sudo apt-get install libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev
|
||||
|
||||
- name: Install manimgl dependencies on Ubuntu
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.manim == 'manimgl'
|
||||
run: |
|
||||
sudo apt-get install libpango1.0-dev ffmpeg freeglut3-dev
|
||||
|
||||
- name: Install xvfb on Ubuntu
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.manim == 'manimgl'
|
||||
run: |
|
||||
sudo apt-get install xvfb
|
||||
nohup Xvfb $DISPLAY &
|
||||
|
||||
- name: Install Windows dependencies
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: choco install ffmpeg
|
||||
@ -97,13 +146,13 @@ jobs:
|
||||
# Install Manim Slides
|
||||
- name: Install Manim Slides
|
||||
run: |
|
||||
poetry config experimental.new-installer false
|
||||
poetry install --with test
|
||||
poetry install --extras ${{ matrix.manim }}
|
||||
|
||||
# Render slides
|
||||
- name: Render slides
|
||||
if: matrix.manim == 'manim'
|
||||
run: poetry run manim -ql example.py BasicExample ThreeDExample
|
||||
|
||||
- name: Render slides
|
||||
if: matrix.manim == 'manimgl'
|
||||
run: poetry run -v manimgl -l example.py BasicExample ThreeDExample
|
37
.gitignore
vendored
@ -1,33 +1,33 @@
|
||||
# Python files
|
||||
__pycache__/
|
||||
/env
|
||||
/tests
|
||||
/build
|
||||
/dist
|
||||
*.egg-info/
|
||||
|
||||
# Manim files
|
||||
images/
|
||||
/media
|
||||
/presentation
|
||||
|
||||
/.vscode
|
||||
|
||||
slides/
|
||||
|
||||
.manim-slides.json
|
||||
docs/source/media/
|
||||
|
||||
# ManimGL files
|
||||
videos/
|
||||
|
||||
images/
|
||||
# Manim Slides files
|
||||
.manim-slides.toml
|
||||
|
||||
docs/build/
|
||||
|
||||
docs/source/_static/slides_assets/
|
||||
|
||||
docs/source/_static/slides.html
|
||||
slides/
|
||||
!tests/data/slides/
|
||||
|
||||
slides_assets/
|
||||
|
||||
# Docs
|
||||
docs/build/
|
||||
|
||||
slides.html
|
||||
|
||||
docs/source/reference/.ipynb_checkpoints/
|
||||
|
||||
docs/source/_static/basic_example_assets/
|
||||
|
||||
docs/source/_static/basic_example.html
|
||||
@ -36,8 +36,11 @@ docs/source/_static/three_d_example.html
|
||||
|
||||
docs/source/_static/three_d_example_assets/
|
||||
|
||||
docs/source/reference/media/
|
||||
|
||||
# JOSE Paper
|
||||
paper/paper.pdf
|
||||
paper/media/
|
||||
|
||||
*.jats
|
||||
|
||||
paper/paper.pdf
|
||||
# Others
|
||||
coverage.xml
|
||||
|
@ -12,7 +12,7 @@ repos:
|
||||
- id: isort
|
||||
name: isort (python)
|
||||
- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
|
||||
rev: v2.7.0
|
||||
rev: v2.10.0
|
||||
hooks:
|
||||
- id: pretty-format-yaml
|
||||
args: [--autofix]
|
||||
@ -20,15 +20,16 @@ repos:
|
||||
exclude: poetry.lock
|
||||
args: [--autofix]
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.1.0
|
||||
rev: 23.7.0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||
rev: v0.0.255
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.0.284
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.1.1
|
||||
rev: v1.5.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies: [types-requests, types-setuptools]
|
||||
|
60
CHANGELOG.md
Normal file
@ -0,0 +1,60 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
<!-- start changelog -->
|
||||
|
||||
## [v5 (Unreleased)](https://github.com/jeertmans/languagetool-rust/compare/v4.16.0...HEAD)
|
||||
|
||||
Prior to v5, there was no real CHANGELOG other than the GitHub releases,
|
||||
with most of the content automatically generated by GitHub from merged
|
||||
pull requests.
|
||||
|
||||
In an effort to better document changes, this CHANGELOG document is now created.
|
||||
|
||||
### Added
|
||||
|
||||
- Added the following option aliases to `manim-slides present`:
|
||||
`-F` and `--full-screen` for `fullscreen`,
|
||||
`-H` for `--hide-mouse`,
|
||||
and `-S` for `--screen-number`.
|
||||
[#243](https://github.com/jeertmans/manim-slides/pull/243)
|
||||
- Added a full screen key binding (defaults to <kbd>F</kbd>) in the
|
||||
presenter.
|
||||
[#243](https://github.com/jeertmans/manim-slides/pull/243)
|
||||
|
||||
### Changed
|
||||
|
||||
- Automatically concatenate all animations from a slide into one.
|
||||
This is a **breaking change** because the config file format is
|
||||
different from the previous one. For migration help, see associated PR.
|
||||
[#242](https://github.com/jeertmans/manim-slides/pull/242)
|
||||
- Changed the player interface to only use PySide6, and not a combination of
|
||||
PySide6 and OpenCV. A few features have been removed (see removed section),
|
||||
but the new player should be much easier to maintain and more performant,
|
||||
than its predecessor.
|
||||
[#243](https://github.com/jeertmans/manim-slides/pull/243)
|
||||
- Changed the slide config format to exclude unecessary information.
|
||||
`StypeType` is removed in favor to one boolean `loop` field. This is
|
||||
a **breaking change** and one should re-render the slides to apply changes.
|
||||
[#243](https://github.com/jeertmans/manim-slides/pull/243)
|
||||
- Renamed key bindings in the config. This is a **breaking change** and one
|
||||
should either manually rename them (see list below) or re-init a config.
|
||||
List of changes: `CONTINUE` to `NEXT`, `BACK` to `PREVIOUS`, and
|
||||
`REWIND` to `REPLAY`.
|
||||
[#243](https://github.com/jeertmans/manim-slides/pull/243)
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed `--start-at-animation-number` option from `manim-slides present`.
|
||||
[#242](https://github.com/jeertmans/manim-slides/pull/242)
|
||||
- Removed the following options from `manim-slides present`:
|
||||
`--resolution`, `--record-to`, `--resize-mode`, and `--background-color`.
|
||||
[#243](https://github.com/jeertmans/manim-slides/pull/243)
|
||||
- Removed `PERF` verbosity level because not used anymore.
|
||||
[#245](https://github.com/jeertmans/manim-slides/pull/245)
|
||||
|
||||
<!-- end changelog -->
|
32
CITATION.cff
Normal file
@ -0,0 +1,32 @@
|
||||
# This CITATION.cff file was generated with cffinit.
|
||||
# Visit https://bit.ly/cffinit to generate yours today!
|
||||
|
||||
cff-version: 1.2.0
|
||||
title: Manim Slides
|
||||
message: A Python package for presenting Manim content anywhere
|
||||
type: software
|
||||
authors:
|
||||
- name: Jérome Eertmans
|
||||
orcid: 'https://orcid.org/0000-0002-5579-5360'
|
||||
website: 'https://eertmans.be'
|
||||
identifiers:
|
||||
- type: doi
|
||||
value: 10.21105/jose.00206
|
||||
description: The paper presenting the software.
|
||||
repository-code: 'https://github.com/jeertmans/manim-slides'
|
||||
url: 'https://eertmans.be/manim-slides'
|
||||
abstract: >-
|
||||
Manim Slides is a Python package that makes presenting
|
||||
Manim animations straightforward. With minimal changes
|
||||
required to pre-existing code, one can slide through
|
||||
|
||||
animations in a PowerPoint-like manner, or share its
|
||||
slides online using ReavealJS’ power.
|
||||
keywords:
|
||||
- Education
|
||||
- Math Animations
|
||||
- Presentation Tool
|
||||
- PowerPoint
|
||||
- Python
|
||||
license: MIT
|
||||
version: v5.0.0-rc1
|
73
README.md
@ -8,6 +8,10 @@
|
||||
[![Python version][pypi-python-version-badge]][pypi-version-url]
|
||||
[![PyPI - Downloads][pypi-download-badge]][pypi-version-url]
|
||||
[![Documentation][documentation-badge]][documentation-url]
|
||||
[![DOI][doi-badge]][doi-url]
|
||||
[![JOSE Paper][jose-badge]][jose-url]
|
||||
[![codecov][codecov-badge]][codecov-url]
|
||||
|
||||
# Manim Slides
|
||||
|
||||
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!
|
||||
@ -27,6 +31,9 @@ Tool for live presentations using either [Manim (community edition)](https://www
|
||||
- [F.A.Q](#faq)
|
||||
* [How to increase quality on Windows](#how-to-increase-quality-on-windows)
|
||||
- [Contributing](#contributing)
|
||||
* [Reporting an Issue](#reporting-an-issue)
|
||||
* [Seeking for Help](#seeking-for-help)
|
||||
* [Contact](#contact)
|
||||
|
||||
## Installation
|
||||
|
||||
@ -54,6 +61,16 @@ The recommended way to install the latest release is to use pip:
|
||||
pip install manim-slides
|
||||
```
|
||||
|
||||
Optionally, you can also install Manim or ManimGL using extras[^1]:
|
||||
|
||||
```bash
|
||||
pip install manim-slides[manim] # For Manim
|
||||
# or
|
||||
pip install manim-slides[manimgl] # For ManimGL
|
||||
```
|
||||
|
||||
[^1]: NOTE: you still need to have Manim or ManimGL platform-specific dependencies installed on your computer.
|
||||
|
||||
### Install From Repository
|
||||
|
||||
An alternative way to install Manim Slides is to clone the git repository, and install from there: read the [contributing guide](https://eertmans.be/manim-slides/contributing/workflow.html) to know how.
|
||||
@ -190,9 +207,65 @@ in *Settings*->*Display*.
|
||||
|
||||
Contributions are more than welcome! Please read through [our contributing section](https://eertmans.be/manim-slides/contributing/index.html).
|
||||
|
||||
### Reporting an Issue
|
||||
|
||||
<!-- start reporting-an-issue -->
|
||||
|
||||
If you think you found a bug,
|
||||
an error in the documentation,
|
||||
or wish there was some feature that is currently missing,
|
||||
we would love to hear from you!
|
||||
|
||||
The best way to reach us is via the
|
||||
[GitHub issues](https://github.com/jeertmans/manim-slides/issues).
|
||||
If your problem is not covered by an already existing (closed or open) issue,
|
||||
then we suggest you create a
|
||||
[new issue](https://github.com/jeertmans/manim-slides/issues/new/choose).
|
||||
You can choose from a list of templates, or open a
|
||||
[blank issue](https://github.com/jeertmans/manim-slides/issues/new)
|
||||
if your issue does not fit one of the proposed topics.
|
||||
|
||||
The more precise you are in the description of your problem, the faster we will
|
||||
be able to help you!
|
||||
|
||||
<!-- end reporting-an-issue -->
|
||||
|
||||
### Seeking for help
|
||||
|
||||
<!-- start seeking-for-help -->
|
||||
|
||||
Sometimes, you may have a question about Manim Slides,
|
||||
not necessarily an issue.
|
||||
|
||||
There are two ways you can reach us for questions:
|
||||
|
||||
- via the `Question/Help/Support` topic when
|
||||
[choosing an issue template](https://github.com/jeertmans/manim-slides/issues/new/choose);
|
||||
- or via
|
||||
[GitHub discussions](https://github.com/jeertmans/manim-slides/discussions).
|
||||
|
||||
<!-- end seeking-for-help -->
|
||||
|
||||
### Contact
|
||||
|
||||
<!-- start contact -->
|
||||
|
||||
Finally, if you do not have any GitHub account,
|
||||
or just wish to contact the author of Manim Slides,
|
||||
you can do so at: [jeertmans@icloud.com](mailto:jeertmans@icloud.com).
|
||||
|
||||
<!-- end contact -->
|
||||
|
||||
[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
|
||||
[pypi-download-badge]: https://img.shields.io/pypi/dm/manim-slides
|
||||
[documentation-badge]: https://img.shields.io/website?down_color=lightgrey&down_message=offline&label=documentation&up_color=green&up_message=online&url=https%3A%2F%2Feertmans.be%2Fmanim-slides%2F
|
||||
[documentation-url]: https://eertmans.be/manim-slides/
|
||||
[doi-badge]: https://zenodo.org/badge/DOI/10.5281/zenodo.8215167.svg
|
||||
[doi-url]: https://doi.org/10.5281/zenodo.8215167
|
||||
[jose-badge]: https://jose.theoj.org/papers/10.21105/jose.00206/status.svg
|
||||
[jose-url]: https://doi.org/10.21105/jose.00206
|
||||
|
||||
[codecov-badge]: https://codecov.io/gh/jeertmans/manim-slides/branch/main/graph/badge.svg?token=8P4DY9JCE4
|
||||
[codecov-url]: https://codecov.io/gh/jeertmans/manim-slides
|
||||
|
@ -1,3 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
Changes between releases are listed in Manim Slides' [Github releases](https://github.com/jeertmans/manim-slides/releases). You can read the [latest release here](https://github.com/jeertmans/manim-slides/releases).
|
||||
```{include} ../../CHANGELOG.md
|
||||
:start-after: <!-- start changelog -->
|
||||
:end-before: <!-- end changelog -->
|
||||
```
|
||||
|
@ -15,15 +15,24 @@ author = "Jérome Eertmans"
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||
|
||||
extensions = [
|
||||
# Built-in
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.intersphinx",
|
||||
"sphinx.ext.viewcode",
|
||||
# Additional
|
||||
"nbsphinx",
|
||||
"myst_parser",
|
||||
"sphinxext.opengraph",
|
||||
"sphinx_click",
|
||||
"myst_parser",
|
||||
"sphinx_copybutton",
|
||||
# Custom
|
||||
"manim_slides.docs.manim_slides_directive",
|
||||
]
|
||||
|
||||
typehints_defaults = "comma"
|
||||
typehints_use_signature = True
|
||||
typehints_use_signature_return = True
|
||||
|
||||
myst_enable_extensions = [
|
||||
"colon_fence",
|
||||
"html_admonition",
|
||||
|
@ -2,10 +2,14 @@
|
||||
|
||||
Thank you for your interest in Manim Slides! ✨
|
||||
|
||||
Manim Slides is an open source project, first created as a fork of [manim-presentation](https://github.com/galatolofederico/manim-presentation) (now deprecated in favor to Manim Slides), and we welcome contributions of all forms.
|
||||
|
||||
This section is here to help fist-time contributors know how they can help this project grow. Whether you are already familiar with Manim or GitHub, it is worth taking a few minutes to read those documents!
|
||||
Manim Slides is an open source project, first created as a fork of
|
||||
[manim-presentation](https://github.com/galatolofederico/manim-presentation)
|
||||
(now deprecated in favor to Manim Slides),
|
||||
and we welcome contributions of all forms.
|
||||
|
||||
This section is here to help fist-time contributors know how they can help this
|
||||
project grow. Whether you are already familiar with Manim or GitHub,
|
||||
it is worth taking a few minutes to read those documents!
|
||||
|
||||
```{toctree}
|
||||
:hidden:
|
||||
@ -19,3 +23,24 @@ internals
|
||||
|
||||
[Internals](./internals)
|
||||
: how Manim Slides is built and how the various parts of it work.
|
||||
|
||||
## Reporting an Issue
|
||||
|
||||
```{include} ../../../README.md
|
||||
:start-after: <!-- start reporting-an-issue -->
|
||||
:end-before: <!-- end reporting-an-issue -->
|
||||
```
|
||||
|
||||
## Seeking for Help
|
||||
|
||||
```{include} ../../../README.md
|
||||
:start-after: <!-- start seeking-for-help -->
|
||||
:end-before: <!-- end seeking-for-help -->
|
||||
```
|
||||
|
||||
## Contact
|
||||
|
||||
```{include} ../../../README.md
|
||||
:start-after: <!-- start contact -->
|
||||
:end-before: <!-- end contact -->
|
||||
```
|
||||
|
@ -11,7 +11,7 @@ This document is there to help you recreate a working environment for Manim Slid
|
||||
|
||||
## Forking the repository and cloning it locally
|
||||
|
||||
We used GitHub to host Manim Slides' repository, and we encourage contributors to use git.
|
||||
We use GitHub to host Manim Slides' repository, and we encourage contributors to use git.
|
||||
|
||||
Useful links:
|
||||
|
||||
@ -30,6 +30,32 @@ With Poetry, installation becomes straightforward:
|
||||
poetry install
|
||||
```
|
||||
|
||||
This, however, only installs the minimal set of dependencies to run the package.
|
||||
|
||||
If you would like to install Manim or ManimGL, as documented in the [quickstart](../quickstart),
|
||||
you can use the `--extras` option:
|
||||
|
||||
```bash
|
||||
poetry install --extras manim # For Manim
|
||||
# or
|
||||
poetry install --extras manimgl # For ManimGL
|
||||
```
|
||||
|
||||
Additionnally, Manim Slides comes with group dependencies for development purposes:
|
||||
|
||||
```bash
|
||||
poetry install --with dev # For linters and formatters
|
||||
# or
|
||||
poetry install --with docs # To build the documentation locally
|
||||
```
|
||||
|
||||
Another group is `test`, but it is only used for
|
||||
[GitHub actions](https://github.com/jeertmans/manim-slides/blob/main/.github/workflows/test_examples.yml).
|
||||
|
||||
:::{note}
|
||||
You can combine any number of groups or extras when installing the package locally.
|
||||
:::
|
||||
|
||||
## Running commands
|
||||
|
||||
As modules were installed in a new Python environment, you cannot use them directly in the shell.
|
||||
|
@ -6,21 +6,21 @@ The following summarizes the different presentation features Manim Slides offers
|
||||
:widths: auto
|
||||
:align: center
|
||||
|
||||
| Feature / Constraint | [`present`](reference/cli.md) | [`convert --to=html`](reference/cli.md) | [`convert --to=pptx`](reference/cli.md) |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| Basic navigation through slides | Yes | Yes | Yes |
|
||||
| Replay slide | Yes | No | No |
|
||||
| Pause animation | Yes | No | No |
|
||||
| Play slide in reverse | Yes | No | No |
|
||||
| Slide count | Yes | Yes (optional) | Yes (optional) |
|
||||
| Animation count | Yes | No | No |
|
||||
| Needs Python with Manim Slides installed | Yes | No | No |
|
||||
| Requires internet access | No | Yes | No |
|
||||
| Auto. play slides | Yes | Yes | Yes |
|
||||
| Loops support | Yes | Yes | Yes |
|
||||
| Fully customizable | No | Yes (`--use-template` option) | No |
|
||||
| Other dependencies | None | A modern web browser | PowerPoint or LibreOffice Impress[^1]
|
||||
| Works cross-platforms | Yes | Yes | Partly[^1][^2] |
|
||||
| Feature / Constraint | [`present`](reference/cli.md) | [`convert --to=html`](reference/cli.md) | [`convert --to=pptx`](reference/cli.md) | [`convert --to=pdf`](reference/cli.md)
|
||||
| :--- | :---: | :---: | :---: | :---: |
|
||||
| Basic navigation through slides | Yes | Yes | Yes | Yes (static image) |
|
||||
| Replay slide | Yes | No | No | N/A |
|
||||
| Pause animation | Yes | No | No | N/A |
|
||||
| Play slide in reverse | Yes | No | No | N/A |
|
||||
| Slide count | Yes | Yes (optional) | Yes (optional) | N/A |
|
||||
| Animation count | Yes | No | No | N/A |
|
||||
| Needs Python with Manim Slides installed | Yes | No | No | No
|
||||
| Requires internet access | No | Yes | No | No |
|
||||
| Auto. play slides | Yes | Yes | Yes | N/A |
|
||||
| Loops support | Yes | Yes | Yes | N/A |
|
||||
| Fully customizable | No | Yes (`--use-template` option) | No | No |
|
||||
| Other dependencies | None | A modern web browser | PowerPoint or LibreOffice Impress[^1] | None |
|
||||
| Works cross-platforms | Yes | Yes | Partly[^1][^2] | Yes |
|
||||
:::
|
||||
|
||||
[^1]: If you encounter a problem where slides do not automatically play or loops do not work, please [file an issue on GitHub](https://github.com/jeertmans/manim-slides/issues/new/choose).
|
||||
|
@ -26,7 +26,7 @@ Manim Slides makes creating slides with Manim super easy!
|
||||
In a [very few steps](./quickstart), you can create slides and present them either using the GUI, or your browser.
|
||||
|
||||
|
||||
Slide through the demo below to get a quick glimpse on what you can do with Manin Slides.
|
||||
Slide through the demo below to get a quick glimpse on what you can do with Manim Slides.
|
||||
|
||||
|
||||
<!-- From: https://faq.dailymotion.com/hc/en-us/articles/360022841393-How-to-preserve-the-player-aspect-ratio-on-a-responsive-page -->
|
||||
|
@ -1,12 +1,26 @@
|
||||
# Application Programming Interface
|
||||
|
||||
Manim Slides' API is very limited: it simply consists in two classes, `Slide` and `ThreeDSlide`, which are subclasses of `Scene` and `ThreeDScene` from Manim.
|
||||
Manim Slides' API is very limited: it simply consists of two classes, `Slide`
|
||||
and `ThreeDSlide`, which are subclasses of `Scene` and `ThreeDScene` from Manim.
|
||||
|
||||
Thefore, we only document here the methods we think the end-user will ever use, not the methods used internally when rendering.
|
||||
Therefore, we only document here the methods we think the end-user will ever
|
||||
use, not the methods used internally when rendering.
|
||||
|
||||
```{eval-rst}
|
||||
.. autoclass:: manim_slides.Slide
|
||||
:members: start_loop, end_loop, pause, next_slide
|
||||
:members:
|
||||
add_to_canvas,
|
||||
canvas,
|
||||
canvas_mobjects,
|
||||
end_loop,
|
||||
mobjects_without_canvas,
|
||||
next_slide,
|
||||
pause,
|
||||
remove_from_canvas,
|
||||
start_loop,
|
||||
wait_time_between_slides,
|
||||
wipe,
|
||||
zoom,
|
||||
|
||||
.. autoclass:: manim_slides.ThreeDSlide
|
||||
:members:
|
||||
|
@ -66,6 +66,56 @@ Example using 3D camera. As Manim and ManimGL handle 3D differently, definitions
|
||||
:end-before: [manimgl-3d]
|
||||
```
|
||||
|
||||
## Subclass Custom Scenes
|
||||
|
||||
For compatibility reasons, Manim Slides only provides subclasses for
|
||||
`Scene` and `ThreeDScene`.
|
||||
However, subclassing other scene classes is totally possible,
|
||||
and very simple to do actually!
|
||||
|
||||
[For example](https://github.com/jeertmans/manim-slides/discussions/185),
|
||||
you can subclass the `MovingCameraScene` class from `manim`
|
||||
with the following code:
|
||||
|
||||
```{code-block} python
|
||||
:linenos:
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
|
||||
class MovingCameraSlide(Slide, MovingCameraScene):
|
||||
pass
|
||||
```
|
||||
|
||||
And later use this class anywhere in your code:
|
||||
|
||||
|
||||
```{code-block} python
|
||||
:linenos:
|
||||
|
||||
class SubclassExample(MovingCameraSlide):
|
||||
def construct(self):
|
||||
eq1 = MathTex("x", "=", "1")
|
||||
eq2 = MathTex("x", "=", "2")
|
||||
|
||||
self.play(Write(eq1))
|
||||
|
||||
self.next_slide()
|
||||
|
||||
self.play(
|
||||
TransformMatchingTex(eq1, eq2),
|
||||
self.camera.frame.animate.scale(0.5)
|
||||
)
|
||||
|
||||
self.wait()
|
||||
```
|
||||
|
||||
:::{note}
|
||||
If you do not plan to reuse `MovingCameraSlide` more than once, then you can
|
||||
directly write the `construct` method in the body of `MovingCameraSlide`.
|
||||
:::
|
||||
|
||||
## Advanced Example
|
||||
|
||||
A more advanced example is `ConvertExample`, which is used as demo slide and tutorial.
|
||||
|
@ -4,7 +4,7 @@ Manim Slides allows you to convert presentations into one HTML file, with
|
||||
[RevealJS](https://revealjs.com/). This file can then be opened with any modern
|
||||
web browser, allowing for a nice portability of your presentations.
|
||||
|
||||
As for every command with Manim Slides, converting slides' fragments into one
|
||||
As with every command with Manim Slides, converting slides' fragments into one
|
||||
HTML file (and its assets) can be done in one command:
|
||||
|
||||
```bash
|
||||
|
@ -10,7 +10,9 @@ cli
|
||||
examples
|
||||
gui
|
||||
html
|
||||
IPython magic <ipython_magic>
|
||||
sharing
|
||||
Sphinx Extension <sphinx_extension>
|
||||
```
|
||||
|
||||
[Application Programming Interface](./api): list of classes and methods that may
|
||||
@ -23,6 +25,13 @@ Slides' executable.
|
||||
|
||||
[Graphical User Interface](./gui): details about the main Manim Slide' feature.
|
||||
|
||||
[HTML Presenetation](./html): an alternative way of presenting your animations.
|
||||
[HTML Presentation](./html): an alternative way of presenting your animations.
|
||||
|
||||
[IPython Magic](./ipython_magic): a magic to render and display Manim Slides inside notebooks.
|
||||
|
||||
+ [Example](./magic_example): example notebook using the magics.
|
||||
|
||||
[Sharing](./sharing): how to share your presentation with others.
|
||||
|
||||
|
||||
[Sphinx Extension](./sphinx_extension): a Sphinx extension for diplaying Manim Slides animations within your documentation.
|
||||
|
6
docs/source/reference/ipython_magic.md
Normal file
@ -0,0 +1,6 @@
|
||||
# Manim Slides' IPython magic
|
||||
|
||||
```{eval-rst}
|
||||
.. automodule:: manim_slides.ipython.ipython_magic
|
||||
:members: ManimSlidesMagic
|
||||
```
|
100
docs/source/reference/magic_example.ipynb
Normal file
@ -0,0 +1,100 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "6896875b-34ce-4fc5-809c-669c295067e7",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Jupyter Magic Example\n",
|
||||
"\n",
|
||||
"This small example shows how to use the Manim Slides cell (`%%manim_slides`) and line (`%manim_slides`) magics:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "a73f1c06-c7f8-4f19-a90e-e283bfb8c7c5",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from manim import *\n",
|
||||
"from manim_slides import *"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "68dda1a0-74ff-4d9e-9575-5b25a98f21e7",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"%%manim_slides -v WARNING --progress_bar None MySlide --manim-slides controls=true\n",
|
||||
"\n",
|
||||
"config.media_embed = True\n",
|
||||
"\n",
|
||||
"class MySlide(Slide):\n",
|
||||
" def construct(self):\n",
|
||||
" square = Square()\n",
|
||||
" circle = Circle()\n",
|
||||
" \n",
|
||||
" self.play(Create(square))\n",
|
||||
" self.next_slide()\n",
|
||||
" self.play(Transform(square, circle))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "54fa2d3e-bfee-417d-b64b-f3f30a8749ea",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"class MyOtherSlide(Slide):\n",
|
||||
" def construct(self):\n",
|
||||
" text = VGroup(\n",
|
||||
" Text(\"Press\"),\n",
|
||||
" Text(\"and\"),\n",
|
||||
" Text(\"loop\"),\n",
|
||||
" ).arrange(DOWN, buff=1.)\n",
|
||||
" \n",
|
||||
" self.play(Write(text))\n",
|
||||
" self.next_slide()\n",
|
||||
" self.start_loop()\n",
|
||||
" self.play(Indicate(text[-1], scale_factor=2., run_time=.5))\n",
|
||||
" self.end_loop()\n",
|
||||
" self.play(FadeOut(text))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "7d8ad450-1487-4ca7-8d89-bf8ac344e1fa",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"%manim_slides -v WARNING --progress_bar None MyOtherSlide --manim-slides controls=true"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "manim-slides",
|
||||
"language": "python",
|
||||
"name": "manim-slides"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.10.6"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
@ -67,7 +67,7 @@ and the corresponding tree:
|
||||
## Without Manim Slides installed on the target machine
|
||||
|
||||
An alternative to `manim-slides present` is `manim-slides convert`.
|
||||
Currently, only HTML conversion is available, but do not hesitate to propose
|
||||
Currently, HTML and PPTX conversion are available, but do not hesitate to propose
|
||||
other formats by creating a
|
||||
[Feature Request](https://github.com/jeertmans/manim-slides/issues/new/choose),
|
||||
or directly proposing a
|
||||
@ -150,7 +150,10 @@ reason.
|
||||
|
||||
### With PowerPoint (*EXPERIMENTAL*)
|
||||
|
||||
A recent conversion feature is to the PowerPoint format, thanks to the `python-pptx` package. Even though it is fully working, it is still considered in an *EXPERIMENTAL* status because we do not exactly know what versions of PowerPoint (or LibreOffice Impress) are supported.
|
||||
A recent conversion feature is to the PowerPoint format, thanks to the
|
||||
`python-pptx` package. Even though it is fully working,
|
||||
it is still considered in an *EXPERIMENTAL* status because we do not
|
||||
exactly know what versions of PowerPoint (or LibreOffice Impress) are supported.
|
||||
|
||||
Basically, you can create a PowerPoint in a single command:
|
||||
|
||||
@ -158,6 +161,24 @@ Basically, you can create a PowerPoint in a single command:
|
||||
manim-slides convert --to=pptx BasicExample basic_example.pptx
|
||||
```
|
||||
|
||||
All the videos and necessary files will be contained inside the `.pptx` file, so you can safely share it with anyone. By default, the `poster_frame_image`, i.e., what is displayed by PowerPoint when the video is not playing, is the first frame of each slide. This allows for smooth transitions.
|
||||
All the videos and necessary files will be contained inside the `.pptx` file, so
|
||||
you can safely share it with anyone. By default, the `poster_frame_image`, i.e.,
|
||||
what is displayed by PowerPoint when the video is not playing, is the first
|
||||
frame of each slide. This allows for smooth transitions.
|
||||
|
||||
In the future, we hope to provide more features to this format, so feel free to suggest new features too!
|
||||
In the future, we hope to provide more features to this format,
|
||||
so feel free to suggest new features too!
|
||||
|
||||
### Static PDF presentation
|
||||
|
||||
If you ever need backup slides, that are only made of PDF pages
|
||||
with static images, you can generate such a PDF with the following command:
|
||||
|
||||
```bash
|
||||
manim-slides convert --to=pdf BasicExample basic_example.pdf
|
||||
```
|
||||
|
||||
Note that you will lose all the benefits from animated slides. Therefore,
|
||||
this is only recommended to be used as a backup plan. By default, the last frame
|
||||
of each slide will be printed. This can be changed to be the first one with
|
||||
`-cframe_index=first`.
|
||||
|
6
docs/source/reference/sphinx_extension.md
Normal file
@ -0,0 +1,6 @@
|
||||
# Manim Slides' Sphinx directive
|
||||
|
||||
```{eval-rst}
|
||||
.. automodule:: manim_slides.docs.manim_slides_directive
|
||||
:members: ManimSlidesDirective
|
||||
```
|
34
example.py
@ -2,16 +2,14 @@
|
||||
# type: ignore
|
||||
import sys
|
||||
|
||||
if "manim" in sys.modules:
|
||||
from manim import *
|
||||
|
||||
MANIMGL = False
|
||||
elif "manimlib" in sys.modules:
|
||||
if "manimlib" in sys.modules:
|
||||
from manimlib import *
|
||||
|
||||
MANIMGL = True
|
||||
else:
|
||||
raise ImportError("This script must be run with either `manim` or `manimgl`")
|
||||
from manim import *
|
||||
|
||||
MANIMGL = False
|
||||
|
||||
from manim_slides import Slide, ThreeDSlide
|
||||
|
||||
@ -72,10 +70,9 @@ class TestFileTooLong(Slide):
|
||||
class ConvertExample(Slide):
|
||||
"""WARNING: this example does not seem to work with ManimGL."""
|
||||
|
||||
def tinywait(self):
|
||||
self.wait(0.1)
|
||||
|
||||
def construct(self):
|
||||
self.wait_time_between_slides = 0.1
|
||||
|
||||
title = VGroup(
|
||||
Text("From Manim animations", t2c={"From": BLUE}),
|
||||
Text("to slides presentation", t2c={"to": BLUE}),
|
||||
@ -210,41 +207,32 @@ class Example(Slide):
|
||||
language="console",
|
||||
).shift(DOWN)
|
||||
|
||||
self.clear()
|
||||
|
||||
self.play(FadeIn(code))
|
||||
self.tinywait()
|
||||
self.play(self.wipe(title, code))
|
||||
self.next_slide()
|
||||
|
||||
self.play(FadeIn(step, shift=RIGHT))
|
||||
self.play(Transform(code, code_step_1))
|
||||
self.tinywait()
|
||||
self.next_slide()
|
||||
|
||||
self.play(Transform(step, step_2))
|
||||
self.play(Transform(code, code_step_2))
|
||||
self.tinywait()
|
||||
self.next_slide()
|
||||
|
||||
self.play(Transform(step, step_3))
|
||||
self.play(Transform(code, code_step_3))
|
||||
self.tinywait()
|
||||
self.next_slide()
|
||||
|
||||
self.play(Transform(step, step_4))
|
||||
self.play(Transform(code, code_step_4))
|
||||
self.tinywait()
|
||||
self.next_slide()
|
||||
|
||||
self.play(Transform(step, step_5))
|
||||
self.play(Transform(code, code_step_5))
|
||||
self.tinywait()
|
||||
self.next_slide()
|
||||
|
||||
self.play(Transform(step, step_6))
|
||||
self.play(Transform(code, code_step_6))
|
||||
self.play(code.animate.shift(UP), FadeIn(code_step_7), FadeIn(or_text))
|
||||
self.tinywait()
|
||||
self.next_slide()
|
||||
|
||||
watch_text = Text("Watch result on next slides!").shift(2 * DOWN).scale(0.5)
|
||||
@ -264,10 +252,8 @@ class Example(Slide):
|
||||
self.play(Transform(dot, square))
|
||||
self.remove(dot)
|
||||
self.add(square)
|
||||
self.tinywait()
|
||||
self.next_slide()
|
||||
self.play(Rotate(square, angle=PI / 4))
|
||||
self.tinywait()
|
||||
self.next_slide()
|
||||
|
||||
learn_more_text = (
|
||||
@ -280,7 +266,6 @@ class Example(Slide):
|
||||
)
|
||||
|
||||
self.play(Transform(square, learn_more_text))
|
||||
self.tinywait()
|
||||
|
||||
|
||||
# For ThreeDExample, things are different
|
||||
@ -346,7 +331,10 @@ else:
|
||||
)
|
||||
|
||||
self.play(GrowFromCenter(circle))
|
||||
updater = lambda m, dt: m.increment_theta((75 * DEGREES / 4) * dt)
|
||||
|
||||
def updater(m, dt):
|
||||
return m.increment_theta((75 * DEGREES / 4) * dt)
|
||||
|
||||
frame.add_updater(updater)
|
||||
|
||||
self.next_slide()
|
||||
|
@ -13,6 +13,20 @@ class module(ModuleType):
|
||||
"manim_slides.slide", None, None, ["Slide", "ThreeDSlide"]
|
||||
)
|
||||
return getattr(module, name)
|
||||
elif name == "ManimSlidesMagic":
|
||||
module = __import__(
|
||||
"manim_slides.ipython.ipython_magic", None, None, ["ManimSlidesMagic"]
|
||||
)
|
||||
magic = getattr(module, name)
|
||||
|
||||
from IPython import get_ipython
|
||||
|
||||
ipy = get_ipython()
|
||||
|
||||
if ipy is not None:
|
||||
ipy.register_magics(magic)
|
||||
|
||||
return magic
|
||||
|
||||
return ModuleType.__getattribute__(self, name)
|
||||
|
||||
@ -43,6 +57,6 @@ new_module.__dict__.update(
|
||||
"__path__": __path__,
|
||||
"__doc__": __doc__,
|
||||
"__version__": __version__,
|
||||
"__all__": ("__version__", "Slides", "ThreeDSlide"),
|
||||
"__all__": ("__version__", "ManimSlidesMagic", "Slide", "ThreeDSlide"),
|
||||
}
|
||||
)
|
||||
|
@ -1 +1 @@
|
||||
__version__ = "4.11.0"
|
||||
__version__ = "5.0.0-rc1"
|
||||
|
@ -44,7 +44,7 @@ def config_options(function: F) -> F:
|
||||
def verbosity_option(function: F) -> F:
|
||||
"""Wraps a function to add verbosity option."""
|
||||
|
||||
def callback(ctx: Context, param: Parameter, value: bool) -> None:
|
||||
def callback(ctx: Context, param: Parameter, value: str) -> None:
|
||||
if not value or ctx.resilient_parsing:
|
||||
return
|
||||
|
||||
@ -57,7 +57,7 @@ def verbosity_option(function: F) -> F:
|
||||
["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
||||
case_sensitive=False,
|
||||
),
|
||||
help="Verbosity of CLI output",
|
||||
help="Verbosity of CLI output.",
|
||||
default=None,
|
||||
expose_value=False,
|
||||
envvar="MANIM_SLIDES_VERBOSITY",
|
||||
|
@ -1,53 +1,57 @@
|
||||
import hashlib
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set, Tuple, Union
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from pydantic import BaseModel, FilePath, PositiveInt, root_validator, validator
|
||||
import rtoml
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Field,
|
||||
FilePath,
|
||||
PositiveInt,
|
||||
PrivateAttr,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from pydantic_extra_types.color import Color
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from .defaults import FFMPEG_BIN
|
||||
from .logger import logger
|
||||
|
||||
|
||||
def merge_basenames(files: List[FilePath]) -> Path:
|
||||
"""
|
||||
Merge multiple filenames by concatenating basenames.
|
||||
"""
|
||||
logger.info(f"Generating a new filename for animations: {files}")
|
||||
|
||||
dirname: Path = files[0].parent
|
||||
ext = files[0].suffix
|
||||
|
||||
basenames = (file.stem for file in files)
|
||||
|
||||
basenames_str = ",".join(f"{len(b)}:{b}" for b in basenames)
|
||||
|
||||
# We use hashes to prevent too-long filenames, see issue #123:
|
||||
# https://github.com/jeertmans/manim-slides/issues/123
|
||||
basename = hashlib.sha256(basenames_str.encode()).hexdigest()
|
||||
|
||||
return dirname.joinpath(basename + ext)
|
||||
Receiver = Callable[..., Any]
|
||||
|
||||
|
||||
class Key(BaseModel): # type: ignore
|
||||
class Signal(BaseModel): # type: ignore[misc]
|
||||
__receivers: List[Receiver] = PrivateAttr(default_factory=list)
|
||||
|
||||
def connect(self, receiver: Receiver) -> None:
|
||||
self.__receivers.append(receiver)
|
||||
|
||||
def disconnect(self, receiver: Receiver) -> None:
|
||||
self.__receivers.remove(receiver)
|
||||
|
||||
def emit(self, *args: Any) -> None:
|
||||
for receiver in self.__receivers:
|
||||
receiver(*args)
|
||||
|
||||
|
||||
class Key(BaseModel): # type: ignore[misc]
|
||||
"""Represents a list of key codes, with optionally a name."""
|
||||
|
||||
ids: Set[int]
|
||||
ids: List[PositiveInt] = Field(unique=True)
|
||||
name: Optional[str] = None
|
||||
|
||||
def set_ids(self, *ids: int) -> None:
|
||||
self.ids = set(ids)
|
||||
__signal: Signal = PrivateAttr(default_factory=Signal)
|
||||
|
||||
@validator("ids", each_item=True)
|
||||
def id_is_posint(cls, v: int) -> int:
|
||||
if v < 0:
|
||||
raise ValueError("Key ids cannot be negative integers")
|
||||
return v
|
||||
@field_validator("ids")
|
||||
@classmethod
|
||||
def ids_is_non_empty_set(cls, ids: Set[Any]) -> Set[Any]:
|
||||
if len(ids) <= 0:
|
||||
raise ValueError("Key's ids must be a non-empty set")
|
||||
return ids
|
||||
|
||||
def set_ids(self, *ids: int) -> None:
|
||||
self.ids = list(set(ids))
|
||||
|
||||
def match(self, key_id: int) -> bool:
|
||||
m = key_id in self.ids
|
||||
@ -57,71 +61,96 @@ class Key(BaseModel): # type: ignore
|
||||
|
||||
return m
|
||||
|
||||
@property
|
||||
def signal(self) -> Signal:
|
||||
return self.__signal
|
||||
|
||||
class Config(BaseModel): # type: ignore
|
||||
"""General Manim Slides config"""
|
||||
def connect(self, function: Receiver) -> None:
|
||||
self.__signal.connect(function)
|
||||
|
||||
|
||||
class Keys(BaseModel): # type: ignore[misc]
|
||||
QUIT: Key = Key(ids=[Qt.Key_Q], name="QUIT")
|
||||
CONTINUE: Key = Key(ids=[Qt.Key_Right], name="CONTINUE / NEXT")
|
||||
BACK: Key = Key(ids=[Qt.Key_Left], name="BACK")
|
||||
REVERSE: Key = Key(ids=[Qt.Key_V], name="REVERSE")
|
||||
REWIND: Key = Key(ids=[Qt.Key_R], name="REWIND")
|
||||
PLAY_PAUSE: Key = Key(ids=[Qt.Key_Space], name="PLAY / PAUSE")
|
||||
NEXT: Key = Key(ids=[Qt.Key_Right], name="NEXT")
|
||||
PREVIOUS: Key = Key(ids=[Qt.Key_Left], name="PREVIOUS")
|
||||
REVERSE: Key = Key(ids=[Qt.Key_V], name="REVERSE")
|
||||
REPLAY: Key = Key(ids=[Qt.Key_R], name="REPLAY")
|
||||
FULL_SCREEN: Key = Key(ids=[Qt.Key_F], name="TOGGLE FULL SCREEN")
|
||||
HIDE_MOUSE: Key = Key(ids=[Qt.Key_H], name="HIDE / SHOW MOUSE")
|
||||
|
||||
@root_validator
|
||||
@model_validator(mode="before")
|
||||
def ids_are_unique_across_keys(cls, values: Dict[str, Key]) -> Dict[str, Key]:
|
||||
ids: Set[int] = set()
|
||||
|
||||
for key in values.values():
|
||||
if len(ids.intersection(key.ids)) != 0:
|
||||
if len(ids.intersection(key["ids"])) != 0:
|
||||
raise ValueError(
|
||||
"Two or more keys share a common key code: please make sure each key has distinct key codes"
|
||||
)
|
||||
ids.update(key.ids)
|
||||
ids.update(key["ids"])
|
||||
|
||||
return values
|
||||
|
||||
def merge_with(self, other: "Config") -> "Config":
|
||||
def merge_with(self, other: "Keys") -> "Keys":
|
||||
for key_name, key in self:
|
||||
other_key = getattr(other, key_name)
|
||||
key.ids.update(other_key.ids)
|
||||
key.ids = list(set(key.ids).union(other_key.ids))
|
||||
key.name = other_key.name or key.name
|
||||
|
||||
return self
|
||||
|
||||
def dispatch_key_function(self) -> Callable[[PositiveInt], None]:
|
||||
_dispatch = {}
|
||||
|
||||
class SlideType(str, Enum):
|
||||
slide = "slide"
|
||||
loop = "loop"
|
||||
last = "last"
|
||||
for _, key in self:
|
||||
for _id in key.ids:
|
||||
_dispatch[_id] = key.signal
|
||||
|
||||
def dispatch(key: PositiveInt) -> None:
|
||||
if signal := _dispatch.get(key, None):
|
||||
signal.emit()
|
||||
|
||||
return dispatch
|
||||
|
||||
|
||||
class SlideConfig(BaseModel): # type: ignore
|
||||
type: SlideType
|
||||
class Config(BaseModel): # type: ignore[misc]
|
||||
"""General Manim Slides config"""
|
||||
|
||||
keys: Keys = Keys()
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: Path) -> "Config":
|
||||
"""Reads a configuration from a file."""
|
||||
return cls.model_validate(rtoml.load(path)) # type: ignore
|
||||
|
||||
def to_file(self, path: Path) -> None:
|
||||
"""Dumps the configuration to a file."""
|
||||
rtoml.dump(self.model_dump(), path, pretty=True)
|
||||
|
||||
def merge_with(self, other: "Config") -> "Config":
|
||||
self.keys = self.keys.merge_with(other.keys)
|
||||
return self
|
||||
|
||||
|
||||
class PreSlideConfig(BaseModel): # type: ignore
|
||||
start_animation: int
|
||||
end_animation: int
|
||||
number: int
|
||||
terminated: bool = False
|
||||
loop: bool = False
|
||||
|
||||
@validator("start_animation", "end_animation")
|
||||
@field_validator("start_animation", "end_animation")
|
||||
@classmethod
|
||||
def index_is_posint(cls, v: int) -> 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) -> int:
|
||||
if v <= 0:
|
||||
raise ValueError("Slide number cannot be negative or zero")
|
||||
return v
|
||||
|
||||
@root_validator
|
||||
@model_validator(mode="after")
|
||||
def start_animation_is_before_end(
|
||||
cls, values: Dict[str, Union[SlideType, int, bool]]
|
||||
) -> Dict[str, Union[SlideType, int, bool]]:
|
||||
if values["start_animation"] >= values["end_animation"]: # type: ignore
|
||||
if values["start_animation"] == values["end_animation"] == 0:
|
||||
cls, pre_slide_config: "PreSlideConfig"
|
||||
) -> "PreSlideConfig":
|
||||
if pre_slide_config.start_animation >= pre_slide_config.end_animation:
|
||||
if pre_slide_config.start_animation == pre_slide_config.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. IMPORTANT: when using ManimGL, `self.wait()` is not considered to be an animation, so prefer to directly use `self.play(...)`."
|
||||
)
|
||||
@ -130,128 +159,72 @@ class SlideConfig(BaseModel): # type: ignore
|
||||
"Start animation index must be strictly lower than end animation index"
|
||||
)
|
||||
|
||||
return values
|
||||
|
||||
def is_slide(self) -> bool:
|
||||
return self.type == SlideType.slide
|
||||
|
||||
def is_loop(self) -> bool:
|
||||
return self.type == SlideType.loop
|
||||
|
||||
def is_last(self) -> bool:
|
||||
return self.type == SlideType.last
|
||||
return pre_slide_config
|
||||
|
||||
@property
|
||||
def slides_slice(self) -> slice:
|
||||
return slice(self.start_animation, self.end_animation)
|
||||
|
||||
|
||||
class PresentationConfig(BaseModel): # type: ignore
|
||||
slides: List[SlideConfig]
|
||||
files: List[FilePath]
|
||||
class SlideConfig(BaseModel): # type: ignore[misc]
|
||||
file: FilePath
|
||||
rev_file: FilePath
|
||||
loop: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_pre_slide_config_and_files(
|
||||
cls, pre_slide_config: PreSlideConfig, file: Path, rev_file: Path
|
||||
) -> "SlideConfig":
|
||||
return cls(file=file, rev_file=rev_file, loop=pre_slide_config.loop)
|
||||
|
||||
|
||||
class PresentationConfig(BaseModel): # type: ignore[misc]
|
||||
slides: List[SlideConfig] = Field(min_length=1)
|
||||
resolution: Tuple[PositiveInt, PositiveInt] = (1920, 1080)
|
||||
background_color: Color = "black"
|
||||
|
||||
@root_validator
|
||||
def animation_indices_match_files(
|
||||
cls, values: Dict[str, Union[List[SlideConfig], List[FilePath]]]
|
||||
) -> Dict[str, Union[List[SlideConfig], List[FilePath]]]:
|
||||
files: List[FilePath] = values.get("files") # type: ignore
|
||||
slides: List[SlideConfig] = values.get("slides") # type: ignore
|
||||
@classmethod
|
||||
def from_file(cls, path: Path) -> "PresentationConfig":
|
||||
"""Reads a presentation configuration from a file."""
|
||||
with open(path, "r") as f:
|
||||
obj = json.load(f)
|
||||
|
||||
if files is None or slides is None:
|
||||
return values
|
||||
slides = obj.setdefault("slides", [])
|
||||
parent = path.parent.parent # Never fails, but parents[1] can fail
|
||||
|
||||
n_files = len(files)
|
||||
for slide in slides:
|
||||
if file := slide.get("file", None):
|
||||
slide["file"] = parent / file
|
||||
|
||||
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}"
|
||||
)
|
||||
if rev_file := slide.get("rev_file", None):
|
||||
slide["rev_file"] = parent / rev_file
|
||||
|
||||
return values
|
||||
return cls.model_validate(obj) # type: ignore
|
||||
|
||||
def copy_to(self, dest: Path, use_cached: bool = True) -> "PresentationConfig":
|
||||
def to_file(self, path: Path) -> None:
|
||||
"""Dumps the presentation configuration to a file."""
|
||||
with open(path, "w") as f:
|
||||
f.write(self.model_dump_json(indent=2))
|
||||
|
||||
def copy_to(self, folder: Path, use_cached: bool = True) -> "PresentationConfig":
|
||||
"""
|
||||
Copy the files to a given directory.
|
||||
"""
|
||||
n = len(self.files)
|
||||
for i in range(n):
|
||||
file = self.files[i]
|
||||
dest_path = dest / self.files[i].name
|
||||
self.files[i] = dest_path
|
||||
if use_cached and dest_path.exists():
|
||||
logger.debug(f"Skipping copy of {file}, using cached copy")
|
||||
continue
|
||||
logger.debug(f"Copying {file} to {dest_path}")
|
||||
shutil.copy(file, dest_path)
|
||||
for slide_config in self.slides:
|
||||
file = slide_config.file
|
||||
rev_file = slide_config.rev_file
|
||||
|
||||
return self
|
||||
dest = folder / file.name
|
||||
rev_dest = folder / rev_file.name
|
||||
|
||||
def concat_animations(
|
||||
self, dest: Optional[Path] = None, use_cached: bool = True
|
||||
) -> "PresentationConfig":
|
||||
"""
|
||||
Concatenate animations such that each slide contains one animation.
|
||||
"""
|
||||
slide_config.file = dest
|
||||
slide_config.rev_file = rev_dest
|
||||
|
||||
dest_paths = []
|
||||
if not use_cached or not dest.exists():
|
||||
shutil.copy(file, dest)
|
||||
|
||||
for i, slide_config in enumerate(self.slides):
|
||||
files = self.files[slide_config.slides_slice]
|
||||
|
||||
slide_config.start_animation = i
|
||||
slide_config.end_animation = i + 1
|
||||
|
||||
if len(files) > 1:
|
||||
dest_path = merge_basenames(files)
|
||||
dest_paths.append(dest_path)
|
||||
|
||||
if use_cached and dest_path.exists():
|
||||
logger.debug(f"Concatenated animations already exist for slide {i}")
|
||||
continue
|
||||
|
||||
f = tempfile.NamedTemporaryFile(mode="w", delete=False)
|
||||
f.writelines(f"file '{os.path.abspath(path)}'\n" for path in files)
|
||||
f.close()
|
||||
|
||||
command: List[str] = [
|
||||
FFMPEG_BIN,
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
f.name,
|
||||
"-c",
|
||||
"copy",
|
||||
str(dest_path),
|
||||
"-y",
|
||||
]
|
||||
logger.debug(" ".join(command))
|
||||
process = subprocess.Popen(
|
||||
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
output, error = process.communicate()
|
||||
|
||||
if output:
|
||||
logger.debug(output.decode())
|
||||
|
||||
if error:
|
||||
logger.debug(error.decode())
|
||||
|
||||
if not dest_path.exists():
|
||||
raise ValueError(
|
||||
"could not properly concatenate animations, use `-v INFO` for more details"
|
||||
)
|
||||
|
||||
else:
|
||||
dest_paths.append(files[0])
|
||||
|
||||
self.files = dest_paths
|
||||
|
||||
if dest:
|
||||
return self.copy_to(dest)
|
||||
if not use_cached or not rev_dest.exists():
|
||||
shutil.copy(rev_file, rev_dest)
|
||||
|
||||
return self
|
||||
|
||||
|
@ -1,9 +1,11 @@
|
||||
import mimetypes
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import webbrowser
|
||||
from base64 import b64encode
|
||||
from enum import Enum
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
@ -14,7 +16,17 @@ import cv2
|
||||
import pptx
|
||||
from click import Context, Parameter
|
||||
from lxml import etree
|
||||
from pydantic import BaseModel, FilePath, PositiveInt, ValidationError
|
||||
from PIL import Image
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
FilePath,
|
||||
GetCoreSchemaHandler,
|
||||
PositiveFloat,
|
||||
PositiveInt,
|
||||
ValidationError,
|
||||
)
|
||||
from pydantic_core import CoreSchema, core_schema
|
||||
from tqdm import tqdm
|
||||
|
||||
from . import data
|
||||
@ -23,6 +35,29 @@ from .config import PresentationConfig
|
||||
from .logger import logger
|
||||
from .present import get_scenes_presentation_config
|
||||
|
||||
DATA_URI_FIX = r"""
|
||||
// Fix found by @t-fritsch on GitHub
|
||||
// see: https://github.com/hakimel/reveal.js/discussions/3362#discussioncomment-6651475.
|
||||
function fixBase64VideoBackground(event) {
|
||||
// event.previousSlide, event.currentSlide, event.indexh, event.indexv
|
||||
if (event.currentSlide.getAttribute('data-background-video')) {
|
||||
const background = Reveal.getSlideBackground(event.indexh, event.indexv),
|
||||
video = background.querySelector('video'),
|
||||
sources = video.querySelectorAll('source');
|
||||
|
||||
sources.forEach((source, i) => {
|
||||
const src = source.getAttribute('src');
|
||||
if(src.match(/^data:video.*;base64$/)){
|
||||
const nextSrc = sources[i+1]?.getAttribute('src');
|
||||
video.setAttribute('src', `${src},${nextSrc}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reveal.on( 'ready', fixBase64VideoBackground );
|
||||
Reveal.on( 'slidechanged', fixBase64VideoBackground );
|
||||
"""
|
||||
|
||||
|
||||
def open_with_default(file: Path) -> None:
|
||||
system = platform.system()
|
||||
@ -51,10 +86,20 @@ def validate_config_option(
|
||||
return config
|
||||
|
||||
|
||||
def data_uri(file: Path) -> str:
|
||||
"""
|
||||
Reads a video and returns the corresponding data-uri.
|
||||
"""
|
||||
b64 = b64encode(file.read_bytes()).decode("ascii")
|
||||
mime_type = mimetypes.guess_type(file)[0] or "video/mp4"
|
||||
|
||||
return f"data:{mime_type};base64,{b64}"
|
||||
|
||||
|
||||
class Converter(BaseModel): # type: ignore
|
||||
presentation_configs: List[PresentationConfig] = []
|
||||
assets_dir: str = "{basename}_assets"
|
||||
template: Optional[str] = None
|
||||
template: Optional[Path] = None
|
||||
|
||||
def convert_to(self, dest: Path) -> None:
|
||||
"""Converts self, i.e., a list of presentations, into a given format."""
|
||||
@ -75,6 +120,7 @@ class Converter(BaseModel): # type: ignore
|
||||
"""Returns the appropriate converter from a string name."""
|
||||
return {
|
||||
"html": RevealJS,
|
||||
"pdf": PDF,
|
||||
"pptx": PowerPoint,
|
||||
}[s]
|
||||
|
||||
@ -85,6 +131,12 @@ class Str(str):
|
||||
# This fixes pickling issue on Python 3.8
|
||||
__reduce_ex__ = str.__reduce_ex__
|
||||
|
||||
@classmethod
|
||||
def __get_pydantic_core_schema__(
|
||||
cls, source_type: Any, handler: GetCoreSchemaHandler
|
||||
) -> CoreSchema:
|
||||
return core_schema.str_schema()
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Ensures that the string is correctly quoted."""
|
||||
if self in ["true", "false", "null"]:
|
||||
@ -221,6 +273,8 @@ class RevealTheme(str, Enum):
|
||||
|
||||
|
||||
class RevealJS(Converter):
|
||||
# Export option: use data-uri
|
||||
data_uri: bool = False
|
||||
# Presentation size options from RevealJS
|
||||
width: Union[Str, int] = Str("100%")
|
||||
height: Union[Str, int] = Str("100%")
|
||||
@ -302,17 +356,20 @@ class RevealJS(Converter):
|
||||
reveal_version: str = "4.4.0"
|
||||
reveal_theme: RevealTheme = RevealTheme.black
|
||||
title: str = "Manim Slides"
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
extra = "forbid"
|
||||
model_config = ConfigDict(use_enum_values=True, extra="forbid")
|
||||
|
||||
def get_sections_iter(self, assets_dir: Path) -> Generator[str, None, None]:
|
||||
"""Generates a sequence of sections, one per slide, that will be included into the html template."""
|
||||
for presentation_config in self.presentation_configs:
|
||||
for slide_config in presentation_config.slides:
|
||||
file = presentation_config.files[slide_config.start_animation]
|
||||
file = assets_dir / file.name
|
||||
file = slide_config.file
|
||||
|
||||
logger.debug(f"Writing video section with file {file}")
|
||||
|
||||
if self.data_uri:
|
||||
file = data_uri(file)
|
||||
else:
|
||||
file = assets_dir / file.name
|
||||
|
||||
# TODO: document this
|
||||
# Videos are muted because, otherwise, the first slide never plays correctly.
|
||||
@ -320,16 +377,15 @@ class RevealJS(Converter):
|
||||
# Later, this might be useful to only mute the first video, or to make it optional.
|
||||
# Read more about this:
|
||||
# https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide#autoplay_and_autoplay_blocking
|
||||
if slide_config.is_loop():
|
||||
yield f'<section data-background-size={self.background_size.value} data-background-video="{file}" data-background-video-muted data-background-video-loop></section>'
|
||||
if slide_config.loop:
|
||||
yield f'<section data-background-size={self.background_size.value} data-background-color="{presentation_config.background_color}" data-background-video="{file}" data-background-video-muted data-background-video-loop></section>'
|
||||
else:
|
||||
yield f'<section data-background-size={self.background_size.value} data-background-video="{file}" data-background-video-muted></section>'
|
||||
yield f'<section data-background-size={self.background_size.value} data-background-color="{presentation_config.background_color}" data-background-video="{file}" data-background-video-muted></section>'
|
||||
|
||||
def load_template(self) -> str:
|
||||
"""Returns the RevealJS HTML template as a string."""
|
||||
if isinstance(self.template, str):
|
||||
with open(self.template, "r") as f:
|
||||
return f.read()
|
||||
if isinstance(self.template, Path):
|
||||
return self.template.read_text()
|
||||
|
||||
if sys.version_info < (3, 9):
|
||||
return resources.read_text(data, "revealjs_template.html")
|
||||
@ -341,29 +397,94 @@ class RevealJS(Converter):
|
||||
|
||||
def convert_to(self, dest: Path) -> None:
|
||||
"""Converts this configuration into a RevealJS HTML presentation, saved to DEST."""
|
||||
dirname = dest.parent
|
||||
basename = dest.stem
|
||||
ext = dest.suffix
|
||||
if self.data_uri:
|
||||
assets_dir = Path("") # Actually we won't care.
|
||||
else:
|
||||
dirname = dest.parent
|
||||
basename = dest.stem
|
||||
ext = dest.suffix
|
||||
|
||||
assets_dir = Path(
|
||||
self.assets_dir.format(dirname=dirname, basename=basename, ext=ext)
|
||||
)
|
||||
full_assets_dir = dirname / assets_dir
|
||||
assets_dir = Path(
|
||||
self.assets_dir.format(dirname=dirname, basename=basename, ext=ext)
|
||||
)
|
||||
full_assets_dir = dirname / assets_dir
|
||||
|
||||
os.makedirs(full_assets_dir, exist_ok=True)
|
||||
logger.debug(f"Assets will be saved to: {full_assets_dir}")
|
||||
|
||||
for presentation_config in self.presentation_configs:
|
||||
presentation_config.concat_animations().copy_to(full_assets_dir)
|
||||
full_assets_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for presentation_config in self.presentation_configs:
|
||||
presentation_config.copy_to(full_assets_dir)
|
||||
|
||||
with open(dest, "w") as f:
|
||||
sections = "".join(self.get_sections_iter(assets_dir))
|
||||
|
||||
revealjs_template = self.load_template()
|
||||
content = revealjs_template.format(sections=sections, **self.dict())
|
||||
|
||||
if self.data_uri:
|
||||
data_uri_fix = DATA_URI_FIX
|
||||
else:
|
||||
data_uri_fix = ""
|
||||
|
||||
content = revealjs_template.format(
|
||||
sections=sections, data_uri_fix=data_uri_fix, **self.dict()
|
||||
)
|
||||
|
||||
f.write(content)
|
||||
|
||||
|
||||
class FrameIndex(str, Enum):
|
||||
first = "first"
|
||||
last = "last"
|
||||
|
||||
|
||||
class PDF(Converter):
|
||||
frame_index: FrameIndex = FrameIndex.last
|
||||
resolution: PositiveFloat = 100.0
|
||||
model_config = ConfigDict(use_enum_values=True, extra="forbid")
|
||||
|
||||
def open(self, file: Path) -> None:
|
||||
return open_with_default(file)
|
||||
|
||||
def convert_to(self, dest: Path) -> None:
|
||||
"""Converts this configuration into a PDF presentation, saved to DEST."""
|
||||
|
||||
def read_image_from_video_file(file: Path, frame_index: FrameIndex) -> Image:
|
||||
cap = cv2.VideoCapture(str(file))
|
||||
|
||||
if frame_index == FrameIndex.last:
|
||||
index = cap.get(cv2.CAP_PROP_FRAME_COUNT)
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, index - 1)
|
||||
|
||||
ret, frame = cap.read()
|
||||
|
||||
if ret:
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
return Image.fromarray(frame)
|
||||
else:
|
||||
raise ValueError("Failed to read {image_index} image from video file")
|
||||
|
||||
images = []
|
||||
|
||||
for i, presentation_config in enumerate(self.presentation_configs):
|
||||
for slide_config in tqdm(
|
||||
presentation_config.slides,
|
||||
desc=f"Generating video slides for config {i + 1}",
|
||||
leave=False,
|
||||
):
|
||||
images.append(
|
||||
read_image_from_video_file(slide_config.file, self.frame_index)
|
||||
)
|
||||
|
||||
images[0].save(
|
||||
dest,
|
||||
"PDF",
|
||||
resolution=self.resolution,
|
||||
save_all=True,
|
||||
append_images=images[1:],
|
||||
)
|
||||
|
||||
|
||||
class PowerPoint(Converter):
|
||||
left: PositiveInt = 0
|
||||
top: PositiveInt = 0
|
||||
@ -371,10 +492,7 @@ class PowerPoint(Converter):
|
||||
height: PositiveInt = 720
|
||||
auto_play_media: bool = True
|
||||
poster_frame_image: Optional[FilePath] = None
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
extra = "forbid"
|
||||
model_config = ConfigDict(use_enum_values=True, extra="forbid")
|
||||
|
||||
def open(self, file: Path) -> None:
|
||||
return open_with_default(file)
|
||||
@ -409,7 +527,7 @@ class PowerPoint(Converter):
|
||||
return etree.ElementBase.xpath(el, query, namespaces=nsmap)
|
||||
|
||||
def save_first_image_from_video_file(file: Path) -> Optional[str]:
|
||||
cap = cv2.VideoCapture(str(file))
|
||||
cap = cv2.VideoCapture(file.as_posix())
|
||||
ret, frame = cap.read()
|
||||
|
||||
if ret:
|
||||
@ -421,13 +539,14 @@ class PowerPoint(Converter):
|
||||
return None
|
||||
|
||||
for i, presentation_config in enumerate(self.presentation_configs):
|
||||
presentation_config.concat_animations()
|
||||
for slide_config in tqdm(
|
||||
presentation_config.slides,
|
||||
desc=f"Generating video slides for config {i + 1}",
|
||||
leave=False,
|
||||
):
|
||||
file = presentation_config.files[slide_config.start_animation]
|
||||
file = slide_config.file
|
||||
|
||||
mime_type = mimetypes.guess_type(file)[0]
|
||||
|
||||
if self.poster_frame_image is None:
|
||||
poster_frame_image = save_first_image_from_video_file(file)
|
||||
@ -442,7 +561,7 @@ class PowerPoint(Converter):
|
||||
self.width * 9525,
|
||||
self.height * 9525,
|
||||
poster_frame_image=poster_frame_image,
|
||||
mime_type="video/mp4",
|
||||
mime_type=mime_type,
|
||||
)
|
||||
if self.auto_play_media:
|
||||
auto_play_media(movie, loop=slide_config.is_loop())
|
||||
@ -510,7 +629,7 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@click.argument("dest", type=click.Path(dir_okay=False, path_type=Path))
|
||||
@click.option(
|
||||
"--to",
|
||||
type=click.Choice(["html", "pptx"], case_sensitive=False),
|
||||
type=click.Choice(["html", "pdf", "pptx"], case_sensitive=False),
|
||||
default="html",
|
||||
show_default=True,
|
||||
help="Set the conversion format to use.",
|
||||
|
@ -30,261 +30,260 @@
|
||||
|
||||
<!-- <script src="index.js"></script> -->
|
||||
<script>
|
||||
Reveal.initialize({{
|
||||
Reveal.initialize({{
|
||||
// The "normal" size of the presentation, aspect ratio will
|
||||
// be preserved when the presentation is scaled to fit different
|
||||
// resolutions. Can be specified using percentage units.
|
||||
width: {width},
|
||||
height: {height},
|
||||
|
||||
// The "normal" size of the presentation, aspect ratio will
|
||||
// be preserved when the presentation is scaled to fit different
|
||||
// resolutions. Can be specified using percentage units.
|
||||
width: {width},
|
||||
height: {height},
|
||||
// Factor of the display size that should remain empty around
|
||||
// the content
|
||||
margin: {margin},
|
||||
|
||||
// Factor of the display size that should remain empty around
|
||||
// the content
|
||||
margin: {margin},
|
||||
// Bounds for smallest/largest possible scale to apply to content
|
||||
minScale: {min_scale},
|
||||
maxScale: {max_scale},
|
||||
|
||||
// Bounds for smallest/largest possible scale to apply to content
|
||||
minScale: {min_scale},
|
||||
maxScale: {max_scale},
|
||||
// Display presentation control arrows
|
||||
controls: {controls},
|
||||
|
||||
// Display presentation control arrows
|
||||
controls: {controls},
|
||||
// Help the user learn the controls by providing hints, for example by
|
||||
// bouncing the down arrow when they first encounter a vertical slide
|
||||
controlsTutorial: {controls_tutorial},
|
||||
|
||||
// Help the user learn the controls by providing hints, for example by
|
||||
// bouncing the down arrow when they first encounter a vertical slide
|
||||
controlsTutorial: {controls_tutorial},
|
||||
// Determines where controls appear, "edges" or "bottom-right"
|
||||
controlsLayout: {controls_layout},
|
||||
|
||||
// Determines where controls appear, "edges" or "bottom-right"
|
||||
controlsLayout: {controls_layout},
|
||||
// Visibility rule for backwards navigation arrows; "faded", "hidden"
|
||||
// or "visible"
|
||||
controlsBackArrows: {controls_back_arrows},
|
||||
|
||||
// Visibility rule for backwards navigation arrows; "faded", "hidden"
|
||||
// or "visible"
|
||||
controlsBackArrows: {controls_back_arrows},
|
||||
// Display a presentation progress bar
|
||||
progress: {progress},
|
||||
|
||||
// Display a presentation progress bar
|
||||
progress: {progress},
|
||||
// Display the page number of the current slide
|
||||
// - true: Show slide number
|
||||
// - false: Hide slide number
|
||||
//
|
||||
// Can optionally be set as a string that specifies the number formatting:
|
||||
// - "h.v": Horizontal . vertical slide number (default)
|
||||
// - "h/v": Horizontal / vertical slide number
|
||||
// - "c": Flattened slide number
|
||||
// - "c/t": Flattened slide number / total slides
|
||||
//
|
||||
// Alternatively, you can provide a function that returns the slide
|
||||
// number for the current slide. The function should take in a slide
|
||||
// object and return an array with one string [slideNumber] or
|
||||
// three strings [n1,delimiter,n2]. See #formatSlideNumber().
|
||||
slideNumber: {slide_number},
|
||||
|
||||
// Display the page number of the current slide
|
||||
// - true: Show slide number
|
||||
// - false: Hide slide number
|
||||
//
|
||||
// Can optionally be set as a string that specifies the number formatting:
|
||||
// - "h.v": Horizontal . vertical slide number (default)
|
||||
// - "h/v": Horizontal / vertical slide number
|
||||
// - "c": Flattened slide number
|
||||
// - "c/t": Flattened slide number / total slides
|
||||
//
|
||||
// Alternatively, you can provide a function that returns the slide
|
||||
// number for the current slide. The function should take in a slide
|
||||
// object and return an array with one string [slideNumber] or
|
||||
// three strings [n1,delimiter,n2]. See #formatSlideNumber().
|
||||
slideNumber: {slide_number},
|
||||
// Can be used to limit the contexts in which the slide number appears
|
||||
// - "all": Always show the slide number
|
||||
// - "print": Only when printing to PDF
|
||||
// - "speaker": Only in the speaker view
|
||||
showSlideNumber: {show_slide_number},
|
||||
|
||||
// Can be used to limit the contexts in which the slide number appears
|
||||
// - "all": Always show the slide number
|
||||
// - "print": Only when printing to PDF
|
||||
// - "speaker": Only in the speaker view
|
||||
showSlideNumber: {show_slide_number},
|
||||
// Use 1 based indexing for # links to match slide number (default is zero
|
||||
// based)
|
||||
hashOneBasedIndex: {hash_one_based_index},
|
||||
|
||||
// Use 1 based indexing for # links to match slide number (default is zero
|
||||
// based)
|
||||
hashOneBasedIndex: {hash_one_based_index},
|
||||
// Add the current slide number to the URL hash so that reloading the
|
||||
// page/copying the URL will return you to the same slide
|
||||
hash: {hash},
|
||||
|
||||
// Add the current slide number to the URL hash so that reloading the
|
||||
// page/copying the URL will return you to the same slide
|
||||
hash: {hash},
|
||||
// Flags if we should monitor the hash and change slides accordingly
|
||||
respondToHashChanges: {respond_to_hash_changes},
|
||||
|
||||
// Flags if we should monitor the hash and change slides accordingly
|
||||
respondToHashChanges: {respond_to_hash_changes},
|
||||
// Push each slide change to the browser history. Implies `hash: true`
|
||||
history: {history},
|
||||
|
||||
// Push each slide change to the browser history. Implies `hash: true`
|
||||
history: {history},
|
||||
// Enable keyboard shortcuts for navigation
|
||||
keyboard: {keyboard},
|
||||
|
||||
// Enable keyboard shortcuts for navigation
|
||||
keyboard: {keyboard},
|
||||
// Optional function that blocks keyboard events when retuning false
|
||||
//
|
||||
// If you set this to 'focused', we will only capture keyboard events
|
||||
// for embedded decks when they are in focus
|
||||
keyboardCondition: {keyboard_condition},
|
||||
|
||||
// Optional function that blocks keyboard events when retuning false
|
||||
//
|
||||
// If you set this to 'focused', we will only capture keyboard events
|
||||
// for embedded decks when they are in focus
|
||||
keyboardCondition: {keyboard_condition},
|
||||
// Disables the default reveal.js slide layout (scaling and centering)
|
||||
// so that you can use custom CSS layout
|
||||
disableLayout: {disable_layout},
|
||||
|
||||
// Disables the default reveal.js slide layout (scaling and centering)
|
||||
// so that you can use custom CSS layout
|
||||
disableLayout: {disable_layout},
|
||||
// Enable the slide overview mode
|
||||
overview: {overview},
|
||||
|
||||
// Enable the slide overview mode
|
||||
overview: {overview},
|
||||
// Vertical centering of slides
|
||||
center: {center},
|
||||
|
||||
// Vertical centering of slides
|
||||
center: {center},
|
||||
// Enables touch navigation on devices with touch input
|
||||
touch: {touch},
|
||||
|
||||
// Enables touch navigation on devices with touch input
|
||||
touch: {touch},
|
||||
// Loop the presentation
|
||||
loop: {loop},
|
||||
|
||||
// Loop the presentation
|
||||
loop: {loop},
|
||||
// Change the presentation direction to be RTL
|
||||
rtl: {rtl},
|
||||
|
||||
// Change the presentation direction to be RTL
|
||||
rtl: {rtl},
|
||||
// Changes the behavior of our navigation directions.
|
||||
//
|
||||
// "default"
|
||||
// Left/right arrow keys step between horizontal slides, up/down
|
||||
// arrow keys step between vertical slides. Space key steps through
|
||||
// all slides (both horizontal and vertical).
|
||||
//
|
||||
// "linear"
|
||||
// Removes the up/down arrows. Left/right arrows step through all
|
||||
// slides (both horizontal and vertical).
|
||||
//
|
||||
// "grid"
|
||||
// When this is enabled, stepping left/right from a vertical stack
|
||||
// to an adjacent vertical stack will land you at the same vertical
|
||||
// index.
|
||||
//
|
||||
// Consider a deck with six slides ordered in two vertical stacks:
|
||||
// 1.1 2.1
|
||||
// 1.2 2.2
|
||||
// 1.3 2.3
|
||||
//
|
||||
// If you're on slide 1.3 and navigate right, you will normally move
|
||||
// from 1.3 -> 2.1. If "grid" is used, the same navigation takes you
|
||||
// from 1.3 -> 2.3.
|
||||
navigationMode: {navigation_mode},
|
||||
|
||||
// Changes the behavior of our navigation directions.
|
||||
//
|
||||
// "default"
|
||||
// Left/right arrow keys step between horizontal slides, up/down
|
||||
// arrow keys step between vertical slides. Space key steps through
|
||||
// all slides (both horizontal and vertical).
|
||||
//
|
||||
// "linear"
|
||||
// Removes the up/down arrows. Left/right arrows step through all
|
||||
// slides (both horizontal and vertical).
|
||||
//
|
||||
// "grid"
|
||||
// When this is enabled, stepping left/right from a vertical stack
|
||||
// to an adjacent vertical stack will land you at the same vertical
|
||||
// index.
|
||||
//
|
||||
// Consider a deck with six slides ordered in two vertical stacks:
|
||||
// 1.1 2.1
|
||||
// 1.2 2.2
|
||||
// 1.3 2.3
|
||||
//
|
||||
// If you're on slide 1.3 and navigate right, you will normally move
|
||||
// from 1.3 -> 2.1. If "grid" is used, the same navigation takes you
|
||||
// from 1.3 -> 2.3.
|
||||
navigationMode: {navigation_mode},
|
||||
// Randomizes the order of slides each time the presentation loads
|
||||
shuffle: {shuffle},
|
||||
|
||||
// Randomizes the order of slides each time the presentation loads
|
||||
shuffle: {shuffle},
|
||||
// Turns fragments on and off globally
|
||||
fragments: {fragments},
|
||||
|
||||
// Turns fragments on and off globally
|
||||
fragments: {fragments},
|
||||
// Flags whether to include the current fragment in the URL,
|
||||
// so that reloading brings you to the same fragment position
|
||||
fragmentInURL: {fragment_in_url},
|
||||
|
||||
// Flags whether to include the current fragment in the URL,
|
||||
// so that reloading brings you to the same fragment position
|
||||
fragmentInURL: {fragment_in_url},
|
||||
// Flags if the presentation is running in an embedded mode,
|
||||
// i.e. contained within a limited portion of the screen
|
||||
embedded: {embedded},
|
||||
|
||||
// Flags if the presentation is running in an embedded mode,
|
||||
// i.e. contained within a limited portion of the screen
|
||||
embedded: {embedded},
|
||||
// Flags if we should show a help overlay when the question-mark
|
||||
// key is pressed
|
||||
help: {help},
|
||||
|
||||
// Flags if we should show a help overlay when the question-mark
|
||||
// key is pressed
|
||||
help: {help},
|
||||
// Flags if it should be possible to pause the presentation (blackout)
|
||||
pause: {pause},
|
||||
|
||||
// Flags if it should be possible to pause the presentation (blackout)
|
||||
pause: {pause},
|
||||
// Flags if speaker notes should be visible to all viewers
|
||||
showNotes: {show_notes},
|
||||
|
||||
// Flags if speaker notes should be visible to all viewers
|
||||
showNotes: {show_notes},
|
||||
// Global override for autolaying embedded media (video/audio/iframe)
|
||||
// - null: Media will only autoplay if data-autoplay is present
|
||||
// - true: All media will autoplay, regardless of individual setting
|
||||
// - false: No media will autoplay, regardless of individual setting
|
||||
autoPlayMedia: {auto_play_media},
|
||||
|
||||
// Global override for autolaying embedded media (video/audio/iframe)
|
||||
// - null: Media will only autoplay if data-autoplay is present
|
||||
// - true: All media will autoplay, regardless of individual setting
|
||||
// - false: No media will autoplay, regardless of individual setting
|
||||
autoPlayMedia: {auto_play_media},
|
||||
// Global override for preloading lazy-loaded iframes
|
||||
// - null: Iframes with data-src AND data-preload will be loaded when within
|
||||
// the viewDistance, iframes with only data-src will be loaded when visible
|
||||
// - true: All iframes with data-src will be loaded when within the viewDistance
|
||||
// - false: All iframes with data-src will be loaded only when visible
|
||||
preloadIframes: {preload_iframes},
|
||||
|
||||
// Global override for preloading lazy-loaded iframes
|
||||
// - null: Iframes with data-src AND data-preload will be loaded when within
|
||||
// the viewDistance, iframes with only data-src will be loaded when visible
|
||||
// - true: All iframes with data-src will be loaded when within the viewDistance
|
||||
// - false: All iframes with data-src will be loaded only when visible
|
||||
preloadIframes: {preload_iframes},
|
||||
// Can be used to globally disable auto-animation
|
||||
autoAnimate: {auto_animate},
|
||||
|
||||
// Can be used to globally disable auto-animation
|
||||
autoAnimate: {auto_animate},
|
||||
// Optionally provide a custom element matcher that will be
|
||||
// used to dictate which elements we can animate between.
|
||||
autoAnimateMatcher: {auto_animate_matcher},
|
||||
|
||||
// Optionally provide a custom element matcher that will be
|
||||
// used to dictate which elements we can animate between.
|
||||
autoAnimateMatcher: {auto_animate_matcher},
|
||||
// Default settings for our auto-animate transitions, can be
|
||||
// overridden per-slide or per-element via data arguments
|
||||
autoAnimateEasing: {auto_animate_easing},
|
||||
autoAnimateDuration: {auto_animate_duration},
|
||||
autoAnimateUnmatched: {auto_animate_unmatched},
|
||||
|
||||
// Default settings for our auto-animate transitions, can be
|
||||
// overridden per-slide or per-element via data arguments
|
||||
autoAnimateEasing: {auto_animate_easing},
|
||||
autoAnimateDuration: {auto_animate_duration},
|
||||
autoAnimateUnmatched: {auto_animate_unmatched},
|
||||
// CSS properties that can be auto-animated. Position & scale
|
||||
// is matched separately so there's no need to include styles
|
||||
// like top/right/bottom/left, width/height or margin.
|
||||
autoAnimateStyles: {auto_animate_styles},
|
||||
|
||||
// CSS properties that can be auto-animated. Position & scale
|
||||
// is matched separately so there's no need to include styles
|
||||
// like top/right/bottom/left, width/height or margin.
|
||||
autoAnimateStyles: {auto_animate_styles},
|
||||
// Controls automatic progression to the next slide
|
||||
// - 0: Auto-sliding only happens if the data-autoslide HTML attribute
|
||||
// is present on the current slide or fragment
|
||||
// - 1+: All slides will progress automatically at the given interval
|
||||
// - false: No auto-sliding, even if data-autoslide is present
|
||||
autoSlide: {auto_slide},
|
||||
|
||||
// Controls automatic progression to the next slide
|
||||
// - 0: Auto-sliding only happens if the data-autoslide HTML attribute
|
||||
// is present on the current slide or fragment
|
||||
// - 1+: All slides will progress automatically at the given interval
|
||||
// - false: No auto-sliding, even if data-autoslide is present
|
||||
autoSlide: {auto_slide},
|
||||
// Stop auto-sliding after user input
|
||||
autoSlideStoppable: {auto_slide_stoppable},
|
||||
|
||||
// Stop auto-sliding after user input
|
||||
autoSlideStoppable: {auto_slide_stoppable},
|
||||
// Use this method for navigation when auto-sliding (defaults to navigateNext)
|
||||
autoSlideMethod: {auto_slide_method},
|
||||
|
||||
// Use this method for navigation when auto-sliding (defaults to navigateNext)
|
||||
autoSlideMethod: {auto_slide_method},
|
||||
// Specify the average time in seconds that you think you will spend
|
||||
// presenting each slide. This is used to show a pacing timer in the
|
||||
// speaker view
|
||||
defaultTiming: {default_timing},
|
||||
|
||||
// Specify the average time in seconds that you think you will spend
|
||||
// presenting each slide. This is used to show a pacing timer in the
|
||||
// speaker view
|
||||
defaultTiming: {default_timing},
|
||||
// Enable slide navigation via mouse wheel
|
||||
mouseWheel: {mouse_wheel},
|
||||
|
||||
// Enable slide navigation via mouse wheel
|
||||
mouseWheel: {mouse_wheel},
|
||||
// Opens links in an iframe preview overlay
|
||||
// Add `data-preview-link` and `data-preview-link="false"` to customise each link
|
||||
// individually
|
||||
previewLinks: {preview_links},
|
||||
|
||||
// Opens links in an iframe preview overlay
|
||||
// Add `data-preview-link` and `data-preview-link="false"` to customise each link
|
||||
// individually
|
||||
previewLinks: {preview_links},
|
||||
// Exposes the reveal.js API through window.postMessage
|
||||
postMessage: {post_message},
|
||||
|
||||
// Exposes the reveal.js API through window.postMessage
|
||||
postMessage: {post_message},
|
||||
// Dispatches all reveal.js events to the parent window through postMessage
|
||||
postMessageEvents: {post_message_events},
|
||||
|
||||
// Dispatches all reveal.js events to the parent window through postMessage
|
||||
postMessageEvents: {post_message_events},
|
||||
// Focuses body when page changes visibility to ensure keyboard shortcuts work
|
||||
focusBodyOnPageVisibilityChange: {focus_body_on_page_visibility_change},
|
||||
|
||||
// Focuses body when page changes visibility to ensure keyboard shortcuts work
|
||||
focusBodyOnPageVisibilityChange: {focus_body_on_page_visibility_change},
|
||||
// Transition style
|
||||
transition: {transition}, // none/fade/slide/convex/concave/zoom
|
||||
|
||||
// Transition style
|
||||
transition: {transition}, // none/fade/slide/convex/concave/zoom
|
||||
// Transition speed
|
||||
transitionSpeed: {transition_speed}, // default/fast/slow
|
||||
|
||||
// Transition speed
|
||||
transitionSpeed: {transition_speed}, // default/fast/slow
|
||||
// Transition style for full page slide backgrounds
|
||||
backgroundTransition: {background_transition}, // none/fade/slide/convex/concave/zoom
|
||||
|
||||
// Transition style for full page slide backgrounds
|
||||
backgroundTransition: {background_transition}, // none/fade/slide/convex/concave/zoom
|
||||
// The maximum number of pages a single slide can expand onto when printing
|
||||
// to PDF, unlimited by default
|
||||
pdfMaxPagesPerSlide: {pdf_max_pages_per_slide},
|
||||
|
||||
// The maximum number of pages a single slide can expand onto when printing
|
||||
// to PDF, unlimited by default
|
||||
pdfMaxPagesPerSlide: {pdf_max_pages_per_slide},
|
||||
// Prints each fragment on a separate slide
|
||||
pdfSeparateFragments: {pdf_separate_fragments},
|
||||
|
||||
// Prints each fragment on a separate slide
|
||||
pdfSeparateFragments: {pdf_separate_fragments},
|
||||
// Offset used to reduce the height of content within exported PDF pages.
|
||||
// This exists to account for environment differences based on how you
|
||||
// print to PDF. CLI printing options, like phantomjs and wkpdf, can end
|
||||
// on precisely the total height of the document whereas in-browser
|
||||
// printing has to end one pixel before.
|
||||
pdfPageHeightOffset: {pdf_page_height_offset},
|
||||
|
||||
// Offset used to reduce the height of content within exported PDF pages.
|
||||
// This exists to account for environment differences based on how you
|
||||
// print to PDF. CLI printing options, like phantomjs and wkpdf, can end
|
||||
// on precisely the total height of the document whereas in-browser
|
||||
// printing has to end one pixel before.
|
||||
pdfPageHeightOffset: {pdf_page_height_offset},
|
||||
// Number of slides away from the current that are visible
|
||||
viewDistance: {view_distance},
|
||||
|
||||
// Number of slides away from the current that are visible
|
||||
viewDistance: {view_distance},
|
||||
// Number of slides away from the current that are visible on mobile
|
||||
// devices. It is advisable to set this to a lower number than
|
||||
// viewDistance in order to save resources.
|
||||
mobileViewDistance: {mobile_view_distance},
|
||||
|
||||
// Number of slides away from the current that are visible on mobile
|
||||
// devices. It is advisable to set this to a lower number than
|
||||
// viewDistance in order to save resources.
|
||||
mobileViewDistance: {mobile_view_distance},
|
||||
// The display mode that will be used to show slides
|
||||
display: {display},
|
||||
|
||||
// The display mode that will be used to show slides
|
||||
display: {display},
|
||||
// Hide cursor if inactive
|
||||
hideInactiveCursor: {hide_inactive_cursor},
|
||||
|
||||
// Hide cursor if inactive
|
||||
hideInactiveCursor: {hide_inactive_cursor},
|
||||
// Time before the cursor is hidden (in ms)
|
||||
hideCursorTime: {hide_cursor_time}
|
||||
}});
|
||||
|
||||
// Time before the cursor is hidden (in ms)
|
||||
hideCursorTime: {hide_cursor_time}
|
||||
|
||||
|
||||
}});
|
||||
{data_uri_fix}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
|
@ -1,3 +1,5 @@
|
||||
FOLDER_PATH: str = "./slides"
|
||||
CONFIG_PATH: str = ".manim-slides.json"
|
||||
FFMPEG_BIN: str = "ffmpeg"
|
||||
from pathlib import Path
|
||||
|
||||
FOLDER_PATH: Path = Path("./slides")
|
||||
CONFIG_PATH: Path = Path(".manim-slides.toml")
|
||||
FFMPEG_BIN: Path = Path("ffmpeg")
|
||||
|
0
manim_slides/docs/__init__.py
Normal file
426
manim_slides/docs/manim_slides_directive.py
Normal file
@ -0,0 +1,426 @@
|
||||
# type: ignore
|
||||
r"""
|
||||
A directive for including Manim slides in a Sphinx document
|
||||
===========================================================
|
||||
|
||||
.. warning::
|
||||
|
||||
This Sphinx extension requires Manim to be installed,
|
||||
and won't probably work on ManimGL examples.
|
||||
|
||||
.. note::
|
||||
|
||||
The current implementation is highly inspired from Manim's own
|
||||
sphinx directive, from v0.17.3.
|
||||
|
||||
When rendering the HTML documentation, the ``.. manim-slides::``
|
||||
directive implemented here allows to include rendered videos.
|
||||
|
||||
This directive requires three additional dependencies:
|
||||
``manim``, ``docutils`` and ``jinja2``. The last two are usually bundled
|
||||
with Sphinx.
|
||||
You can install them manually, or with the extra keyword:
|
||||
|
||||
pip install manim-slides[sphinx-directive]
|
||||
|
||||
Note that you will still need to install Manim's platform-specific dependencies,
|
||||
see
|
||||
`their installation page <https://docs.manim.community/en/stable/installation.html>`_.
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
||||
First, you must include the directive in the Sphinx configuration file:
|
||||
|
||||
.. code-block:: python
|
||||
:caption: Sphinx configuration file (usually :code:`docs/source/conf.py`).
|
||||
:emphasize-lines: 3
|
||||
|
||||
extensions = [
|
||||
# ...
|
||||
"manim_slides.docs.manim_slides_directive",
|
||||
]
|
||||
|
||||
Its basic usage that allows processing **inline content**
|
||||
looks as follows::
|
||||
|
||||
.. manim-slides:: MySlide
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class MySlide(Slide):
|
||||
def construct(self):
|
||||
...
|
||||
|
||||
It is required to pass the name of the class representing the
|
||||
scene to be rendered to the directive.
|
||||
|
||||
As a second application, the directive can also be used to
|
||||
render scenes that are defined within doctests, for example::
|
||||
|
||||
.. manim-slides:: DirectiveDoctestExample
|
||||
:ref_classes: Dot
|
||||
|
||||
>>> from manim import Create, Dot, RED
|
||||
>>> from manim_slides import Slide
|
||||
>>> dot = Dot(color=RED)
|
||||
>>> dot.color
|
||||
<Color #fc6255>
|
||||
>>> class DirectiveDoctestExample(Slide):
|
||||
... def construct(self):
|
||||
... self.play(Create(dot))
|
||||
|
||||
Options
|
||||
-------
|
||||
|
||||
Options can be passed as follows::
|
||||
|
||||
.. manim-slides:: <Class name>
|
||||
:<option name>: <value>
|
||||
|
||||
The following configuration options are supported by the
|
||||
directive:
|
||||
|
||||
hide_source
|
||||
If this flag is present without argument,
|
||||
the source code is not displayed above the rendered video.
|
||||
|
||||
quality : {'low', 'medium', 'high', 'fourk'}
|
||||
Controls render quality of the video, in analogy to
|
||||
the corresponding command line flags.
|
||||
|
||||
ref_classes
|
||||
A list of classes, separated by spaces, that is
|
||||
rendered in a reference block after the source code.
|
||||
|
||||
ref_functions
|
||||
A list of functions, separated by spaces,
|
||||
that is rendered in a reference block after the source code.
|
||||
|
||||
ref_methods
|
||||
A list of methods, separated by spaces,
|
||||
that is rendered in a reference block after the source code.
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import itertools as it
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from timeit import timeit
|
||||
|
||||
import jinja2
|
||||
from docutils import nodes
|
||||
from docutils.parsers.rst import Directive, directives
|
||||
from docutils.statemachine import StringList
|
||||
from manim import QUALITIES
|
||||
|
||||
from ..convert import RevealJS
|
||||
from ..present import get_scenes_presentation_config
|
||||
|
||||
classnamedict = {}
|
||||
|
||||
|
||||
class SkipManimNode(nodes.Admonition, nodes.Element):
|
||||
"""Auxiliary node class that is used when the ``skip-manim-slides`` tag is
|
||||
present or ``.pot`` files are being built.
|
||||
|
||||
Skips rendering the manim-slides directive and outputs a placeholder instead.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def visit(self, node, name=""):
|
||||
self.visit_admonition(node, name)
|
||||
if not isinstance(node[0], nodes.title):
|
||||
node.insert(0, nodes.title("skip-manim-slides", "Example Placeholder"))
|
||||
|
||||
|
||||
def depart(self, node):
|
||||
self.depart_admonition(node)
|
||||
|
||||
|
||||
def process_name_list(option_input: str, reference_type: str) -> list[str]:
|
||||
r"""Reformats a string of space separated class names
|
||||
as a list of strings containing valid Sphinx references.
|
||||
|
||||
Tests
|
||||
-----
|
||||
|
||||
::
|
||||
|
||||
>>> process_name_list("Tex TexTemplate", "class")
|
||||
[':class:`~.Tex`', ':class:`~.TexTemplate`']
|
||||
>>> process_name_list("Scene.play Mobject.rotate", "func")
|
||||
[':func:`~.Scene.play`', ':func:`~.Mobject.rotate`']
|
||||
"""
|
||||
return [f":{reference_type}:`~.{name}`" for name in option_input.split()]
|
||||
|
||||
|
||||
class ManimSlidesDirective(Directive):
|
||||
r"""The manim-slides directive, rendering videos while building
|
||||
the documentation.
|
||||
|
||||
See the module docstring for documentation.
|
||||
"""
|
||||
has_content = True
|
||||
required_arguments = 1
|
||||
optional_arguments = 0
|
||||
option_spec = {
|
||||
"hide_source": bool,
|
||||
"quality": lambda arg: directives.choice(
|
||||
arg,
|
||||
("low", "medium", "high", "fourk"),
|
||||
),
|
||||
"ref_modules": lambda arg: process_name_list(arg, "mod"),
|
||||
"ref_classes": lambda arg: process_name_list(arg, "class"),
|
||||
"ref_functions": lambda arg: process_name_list(arg, "func"),
|
||||
"ref_methods": lambda arg: process_name_list(arg, "meth"),
|
||||
}
|
||||
final_argument_whitespace = True
|
||||
|
||||
def run(self):
|
||||
# Rendering is skipped if the tag skip-manim is present,
|
||||
# or if we are making the pot-files
|
||||
should_skip = (
|
||||
"skip-manim-slides"
|
||||
in self.state.document.settings.env.app.builder.tags.tags
|
||||
or self.state.document.settings.env.app.builder.name == "gettext"
|
||||
)
|
||||
if should_skip:
|
||||
node = SkipManimNode()
|
||||
self.state.nested_parse(
|
||||
StringList(
|
||||
[
|
||||
f"Placeholder block for ``{self.arguments[0]}``.",
|
||||
"",
|
||||
".. code-block:: python",
|
||||
"",
|
||||
]
|
||||
+ [" " + line for line in self.content]
|
||||
),
|
||||
self.content_offset,
|
||||
node,
|
||||
)
|
||||
return [node]
|
||||
|
||||
from manim import config, tempconfig
|
||||
|
||||
global classnamedict
|
||||
|
||||
clsname = self.arguments[0]
|
||||
if clsname not in classnamedict:
|
||||
classnamedict[clsname] = 1
|
||||
else:
|
||||
classnamedict[clsname] += 1
|
||||
|
||||
hide_source = "hide_source" in self.options
|
||||
|
||||
ref_content = (
|
||||
self.options.get("ref_modules", [])
|
||||
+ self.options.get("ref_classes", [])
|
||||
+ self.options.get("ref_functions", [])
|
||||
+ self.options.get("ref_methods", [])
|
||||
)
|
||||
if ref_content:
|
||||
ref_block = "References: " + " ".join(ref_content)
|
||||
|
||||
else:
|
||||
ref_block = ""
|
||||
|
||||
if "quality" in self.options:
|
||||
quality = f'{self.options["quality"]}_quality'
|
||||
else:
|
||||
quality = "example_quality"
|
||||
frame_rate = QUALITIES[quality]["frame_rate"]
|
||||
pixel_height = QUALITIES[quality]["pixel_height"]
|
||||
pixel_width = QUALITIES[quality]["pixel_width"]
|
||||
|
||||
state_machine = self.state_machine
|
||||
document = state_machine.document
|
||||
|
||||
source_file_name = Path(document.attributes["source"])
|
||||
source_rel_name = source_file_name.relative_to(setup.confdir)
|
||||
source_rel_dir = source_rel_name.parents[0]
|
||||
dest_dir = Path(setup.app.builder.outdir, source_rel_dir).absolute()
|
||||
if not dest_dir.exists():
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
source_block = [
|
||||
".. code-block:: python",
|
||||
"",
|
||||
*(" " + line for line in self.content),
|
||||
]
|
||||
source_block = "\n".join(source_block)
|
||||
|
||||
config.media_dir = (Path(setup.confdir) / "media").absolute()
|
||||
config.images_dir = "{media_dir}/images"
|
||||
config.video_dir = "{media_dir}/videos/{quality}"
|
||||
output_file = f"{clsname}-{classnamedict[clsname]}"
|
||||
config.assets_dir = Path("_static")
|
||||
config.progress_bar = "none"
|
||||
config.verbosity = "WARNING"
|
||||
|
||||
example_config = {
|
||||
"frame_rate": frame_rate,
|
||||
"pixel_height": pixel_height,
|
||||
"pixel_width": pixel_width,
|
||||
"output_file": output_file,
|
||||
}
|
||||
|
||||
user_code = self.content
|
||||
if user_code[0].startswith(">>> "): # check whether block comes from doctest
|
||||
user_code = [
|
||||
line[4:] for line in user_code if line.startswith((">>> ", "... "))
|
||||
]
|
||||
|
||||
code = [
|
||||
"from manim import *",
|
||||
*user_code,
|
||||
f"{clsname}().render()",
|
||||
]
|
||||
|
||||
try:
|
||||
with tempconfig(example_config):
|
||||
run_time = timeit(lambda: exec("\n".join(code), globals()), number=1)
|
||||
video_dir = config.get_dir("video_dir")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Error while rendering example {clsname}") from e
|
||||
|
||||
_write_rendering_stats(
|
||||
clsname,
|
||||
run_time,
|
||||
self.state.document.settings.env.docname,
|
||||
)
|
||||
|
||||
# copy video file to output directory
|
||||
filename = f"{output_file}.html"
|
||||
filesrc = video_dir / filename
|
||||
destfile = Path(dest_dir, filename)
|
||||
presentation_configs = get_scenes_presentation_config(
|
||||
[clsname], Path("./slides")
|
||||
)
|
||||
RevealJS(presentation_configs=presentation_configs, controls="true").convert_to(
|
||||
destfile
|
||||
)
|
||||
# shutil.copyfile(filesrc, destfile)
|
||||
|
||||
print("CLASS NAME:", clsname)
|
||||
|
||||
rendered_template = jinja2.Template(TEMPLATE).render(
|
||||
clsname=clsname,
|
||||
clsname_lowercase=clsname.lower(),
|
||||
hide_source=hide_source,
|
||||
filesrc_rel=Path(filesrc).relative_to(setup.confdir).as_posix(),
|
||||
output_file=output_file,
|
||||
source_block=source_block,
|
||||
ref_block=ref_block,
|
||||
)
|
||||
state_machine.insert_input(
|
||||
rendered_template.split("\n"),
|
||||
source=document.attributes["source"],
|
||||
)
|
||||
|
||||
return []
|
||||
|
||||
|
||||
rendering_times_file_path = Path("../rendering_times.csv")
|
||||
|
||||
|
||||
def _write_rendering_stats(scene_name, run_time, file_name):
|
||||
with rendering_times_file_path.open("a") as file:
|
||||
csv.writer(file).writerow(
|
||||
[
|
||||
re.sub(r"^(reference\/)|(manim\.)", "", file_name),
|
||||
scene_name,
|
||||
"%.3f" % run_time,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _log_rendering_times(*args):
|
||||
if rendering_times_file_path.exists():
|
||||
with rendering_times_file_path.open() as file:
|
||||
data = list(csv.reader(file))
|
||||
if len(data) == 0:
|
||||
sys.exit()
|
||||
|
||||
print("\nRendering Summary\n-----------------\n")
|
||||
|
||||
max_file_length = max(len(row[0]) for row in data)
|
||||
for key, group in it.groupby(data, key=lambda row: row[0]):
|
||||
key = key.ljust(max_file_length + 1, ".")
|
||||
group = list(group)
|
||||
if len(group) == 1:
|
||||
row = group[0]
|
||||
print(f"{key}{row[2].rjust(7, '.')}s {row[1]}")
|
||||
continue
|
||||
time_sum = sum(float(row[2]) for row in group)
|
||||
print(
|
||||
f"{key}{f'{time_sum:.3f}'.rjust(7, '.')}s => {len(group)} EXAMPLES",
|
||||
)
|
||||
for row in group:
|
||||
print(f"{' '*(max_file_length)} {row[2].rjust(7)}s {row[1]}")
|
||||
print("")
|
||||
|
||||
|
||||
def _delete_rendering_times(*args):
|
||||
if rendering_times_file_path.exists():
|
||||
rendering_times_file_path.unlink()
|
||||
|
||||
|
||||
def setup(app):
|
||||
app.add_node(SkipManimNode, html=(visit, depart))
|
||||
|
||||
setup.app = app
|
||||
setup.config = app.config
|
||||
setup.confdir = app.confdir
|
||||
|
||||
app.add_directive("manim-slides", ManimSlidesDirective)
|
||||
|
||||
app.connect("builder-inited", _delete_rendering_times)
|
||||
app.connect("build-finished", _log_rendering_times)
|
||||
|
||||
metadata = {"parallel_read_safe": False, "parallel_write_safe": True}
|
||||
return metadata
|
||||
|
||||
|
||||
TEMPLATE = r"""
|
||||
{% if not hide_source %}
|
||||
.. raw:: html
|
||||
|
||||
<div id="{{ clsname_lowercase }}" class="admonition admonition-manim-example">
|
||||
<p class="admonition-title">Example: {{ clsname }} <a class="headerlink" href="#{{ clsname_lowercase }}">¶</a></p>
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
.. raw:: html
|
||||
|
||||
|
||||
<div style="position:relative;padding-bottom:56.25%;">
|
||||
<iframe
|
||||
style="width:100%;height:100%;position:absolute;left:0px;top:0px;"
|
||||
frameborder="0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
allowfullscreen
|
||||
allow="autoplay"
|
||||
src="./{{ output_file }}.html">
|
||||
</iframe>
|
||||
</div>
|
||||
|
||||
{% if not hide_source %}
|
||||
{{ source_block }}
|
||||
|
||||
{{ ref_block }}
|
||||
|
||||
.. raw:: html
|
||||
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
"""
|
265
manim_slides/ipython/ipython_magic.py
Normal file
@ -0,0 +1,265 @@
|
||||
"""
|
||||
Utilities for using Manim Slides with IPython (in particular: Jupyter notebooks).
|
||||
=================================================================================
|
||||
|
||||
.. toctree::
|
||||
:hidden:
|
||||
|
||||
magic_example
|
||||
|
||||
|
||||
.. note::
|
||||
|
||||
The current implementation is highly inspired from Manim's own
|
||||
IPython magics, from v0.17.3.
|
||||
|
||||
This magic requires two additional dependencies: ``manim`` and ``IPython``.
|
||||
You can install them manually, or with the extra keyword:
|
||||
|
||||
pip install manim-slides[magic]
|
||||
|
||||
Note that you will still need to install Manim's platform-specific dependencies,
|
||||
see
|
||||
`their installation page <https://docs.manim.community/en/stable/installation.html>`_.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from IPython import get_ipython
|
||||
from IPython.core.interactiveshell import InteractiveShell
|
||||
from IPython.core.magic import Magics, line_cell_magic, magics_class, needs_local_scope
|
||||
from IPython.display import HTML, display
|
||||
from manim import config, logger, tempconfig
|
||||
from manim.__main__ import main
|
||||
from manim.constants import RendererType
|
||||
from manim.renderer.shader import shader_program_cache
|
||||
|
||||
from ..convert import RevealJS
|
||||
from ..present import get_scenes_presentation_config
|
||||
|
||||
|
||||
@magics_class
|
||||
class ManimSlidesMagic(Magics): # type: ignore
|
||||
def __init__(self, shell: InteractiveShell) -> None:
|
||||
super().__init__(shell)
|
||||
self.rendered_files: Dict[Path, Path] = {}
|
||||
|
||||
@needs_local_scope
|
||||
@line_cell_magic
|
||||
def manim_slides(
|
||||
self,
|
||||
line: str,
|
||||
cell: Optional[str] = None,
|
||||
local_ns: Dict[str, Any] = {},
|
||||
) -> None:
|
||||
r"""Render Manim Slides contained in IPython cells.
|
||||
Works as a line or cell magic.
|
||||
|
||||
.. note::
|
||||
|
||||
This magic works pretty much like the one from Manim, except that it
|
||||
will render Manim Slides using RevealJS. For passing arguments to
|
||||
Manim Slides' convert module, use ``-manim-slides key=value``.
|
||||
|
||||
Everything that is after ``--manim-slides`` will be send to
|
||||
Manim Slides' command. E.g., use ``--manim-slides controls=true``
|
||||
to display control buttons.
|
||||
|
||||
.. hint::
|
||||
|
||||
This line and cell magic works best when used in a JupyterLab
|
||||
environment: while all of the functionality is available for
|
||||
classic Jupyter notebooks as well, it is possible that videos
|
||||
sometimes don't update on repeated execution of the same cell
|
||||
if the scene name stays the same.
|
||||
|
||||
This problem does not occur when using JupyterLab.
|
||||
|
||||
Please refer to `<https://jupyter.org/>`_ for more information about JupyterLab
|
||||
and Jupyter notebooks.
|
||||
|
||||
Usage in line mode::
|
||||
|
||||
%manim_slides [CLI options] MyAwesomeSlide
|
||||
|
||||
Usage in cell mode::
|
||||
|
||||
%%manim_slides [CLI options] MyAwesomeSlide
|
||||
|
||||
class MyAweseomeSlide(Slide):
|
||||
def construct(self):
|
||||
...
|
||||
|
||||
Run ``%manim_slides --help`` and ``%manim_slides render --help``
|
||||
for possible command line interface options.
|
||||
|
||||
.. note::
|
||||
|
||||
The maximal width of the rendered videos that are displayed in the notebook can be
|
||||
configured via the ``media_width`` configuration option. The default is set to ``25vw``,
|
||||
which is 25% of your current viewport width. To allow the output to become as large
|
||||
as possible, set ``config.media_width = "100%"``.
|
||||
|
||||
The ``media_embed`` option will embed the image/video output in the notebook. This is
|
||||
generally undesirable as it makes the notebooks very large, but is required on some
|
||||
platforms (notably Google's CoLab, where it is automatically enabled unless suppressed
|
||||
by ``config.embed = False``) and needed in cases when the notebook (or converted HTML
|
||||
file) will be moved relative to the video locations. Use-cases include building
|
||||
documentation with Sphinx and JupyterBook. See also the
|
||||
:mod:`Manim Slides directive for Sphinx
|
||||
<manim_slides.docs.manim_slides_directive>`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
First make sure to put ``from manim_slides import ManimSlidesMagic``,
|
||||
or even ``from manim_slides import *``
|
||||
in a cell and evaluate it. Then, a typical Jupyter notebook cell for Manim Slides
|
||||
could look as follows::
|
||||
|
||||
%%manim_slides -v WARNING --progress_bar None MySlide --manim-slides controls=true data_uri=true
|
||||
|
||||
class MySlide(Slide):
|
||||
def construct(self):
|
||||
square = Square()
|
||||
circle = Circle()
|
||||
|
||||
self.play(Create(square))
|
||||
self.next_slide()
|
||||
self.play(Transform(square, circle))
|
||||
|
||||
Evaluating this cell will render and display the ``MySlide`` slide
|
||||
defined in the body of the cell.
|
||||
|
||||
.. note::
|
||||
|
||||
In case you want to hide the red box containing the output progress bar, the ``progress_bar`` config
|
||||
option should be set to ``None``. This can also be done by passing ``--progress_bar None`` as a
|
||||
CLI flag.
|
||||
|
||||
"""
|
||||
if cell:
|
||||
exec(cell, local_ns)
|
||||
|
||||
split_args = line.split("--manim-slides", 2)
|
||||
manim_args = split_args[0].split()
|
||||
|
||||
if len(split_args) == 2:
|
||||
manim_slides_args = split_args[1].split()
|
||||
else:
|
||||
manim_slides_args = []
|
||||
|
||||
args = manim_args
|
||||
if not len(args) or "-h" in args or "--help" in args or "--version" in args:
|
||||
main(args, standalone_mode=False, prog_name="manim")
|
||||
return
|
||||
|
||||
modified_args = self.add_additional_args(args)
|
||||
args = main(modified_args, standalone_mode=False, prog_name="manim")
|
||||
with tempconfig(local_ns.get("config", {})):
|
||||
config.digest_args(args)
|
||||
logging.getLogger("manim-slides").setLevel(logging.getLogger("manim").level)
|
||||
|
||||
renderer = None
|
||||
if config.renderer == RendererType.OPENGL:
|
||||
from manim.renderer.opengl_renderer import OpenGLRenderer
|
||||
|
||||
renderer = OpenGLRenderer()
|
||||
|
||||
try:
|
||||
SceneClass = local_ns[config["scene_names"][0]]
|
||||
scene = SceneClass(renderer=renderer)
|
||||
scene.render()
|
||||
finally:
|
||||
# Shader cache becomes invalid as the context is destroyed
|
||||
shader_program_cache.clear()
|
||||
|
||||
# Close OpenGL window here instead of waiting for the main thread to
|
||||
# finish causing the window to stay open and freeze
|
||||
if renderer is not None and renderer.window is not None:
|
||||
renderer.window.close()
|
||||
|
||||
if config["output_file"] is None:
|
||||
logger.info("No output file produced")
|
||||
return
|
||||
|
||||
local_path = Path(config["output_file"]).relative_to(Path.cwd())
|
||||
tmpfile = (
|
||||
Path(config["media_dir"]) / "jupyter" / f"{_generate_file_name()}.html"
|
||||
)
|
||||
|
||||
if local_path in self.rendered_files:
|
||||
self.rendered_files[local_path].unlink()
|
||||
pass
|
||||
self.rendered_files[local_path] = tmpfile
|
||||
tmpfile.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(local_path, tmpfile)
|
||||
|
||||
file_type = mimetypes.guess_type(config["output_file"])[0] or "video/mp4"
|
||||
embed = config["media_embed"]
|
||||
if embed is None:
|
||||
# videos need to be embedded when running in google colab.
|
||||
# do this automatically in case config.media_embed has not been
|
||||
# set explicitly.
|
||||
embed = "google.colab" in str(get_ipython())
|
||||
|
||||
if not file_type.startswith("video"):
|
||||
raise ValueError(
|
||||
f"Manim Slides only supports video files, not {file_type}"
|
||||
)
|
||||
|
||||
clsname = config["scene_names"][0]
|
||||
|
||||
kwargs = dict(arg.split("=", 1) for arg in manim_slides_args)
|
||||
|
||||
if embed: # Embedding implies data-uri
|
||||
kwargs["data_uri"] = "true"
|
||||
|
||||
# TODO: FIXME
|
||||
# Seems like files are blocked so date-uri is the only working option...
|
||||
if kwargs.get("data_uri", "false").lower().strip() == "false":
|
||||
logger.warn(
|
||||
"data_uri option is currently automatically enabled, "
|
||||
"because using local video files does not seem to work properly."
|
||||
)
|
||||
kwargs["data_uri"] = "true"
|
||||
|
||||
presentation_configs = get_scenes_presentation_config(
|
||||
[clsname], Path("./slides")
|
||||
)
|
||||
RevealJS(presentation_configs=presentation_configs, **kwargs).convert_to(
|
||||
tmpfile
|
||||
)
|
||||
|
||||
if embed:
|
||||
result = HTML(
|
||||
"""<div style="position:relative;padding-bottom:56.25%;"><iframe style="width:100%;height:100%;position:absolute;left:0px;top:0px;" frameborder="0" width="100%" height="100%" allowfullscreen allow="autoplay" srcdoc="{srcdoc}"></iframe></div>""".format(
|
||||
srcdoc=tmpfile.read_text().replace('"', "'")
|
||||
)
|
||||
)
|
||||
else:
|
||||
result = HTML(
|
||||
"""<div style="position:relative;padding-bottom:56.25%;"><iframe style="width:100%;height:100%;position:absolute;left:0px;top:0px;" frameborder="0" width="100%" height="100%" allowfullscreen allow="autoplay" src="{src}"></iframe></div>""".format(
|
||||
src=tmpfile.as_posix()
|
||||
)
|
||||
)
|
||||
|
||||
display(result)
|
||||
|
||||
def add_additional_args(self, args: list[str]) -> list[str]:
|
||||
additional_args = ["--jupyter"]
|
||||
# Use webm to support transparency
|
||||
if "-t" in args and "--format" not in args:
|
||||
additional_args += ["--format", "webm"]
|
||||
return additional_args + args[:-1] + [""] + [args[-1]]
|
||||
|
||||
|
||||
def _generate_file_name() -> str:
|
||||
return config["scene_names"][0] + "@" + datetime.now().strftime("%Y-%m-%d@%H-%M-%S") # type: ignore
|
@ -5,6 +5,7 @@ https://github.com/ManimCommunity/manim/blob/d5b65b844b8ce8ff5151a2f56f9dc98cebb
|
||||
|
||||
import logging
|
||||
|
||||
from rich.console import Console
|
||||
from rich.logging import RichHandler
|
||||
|
||||
__all__ = ["logger", "make_logger"]
|
||||
@ -23,6 +24,7 @@ HIGHLIGHTED_KEYWORDS = [ # these keywords are highlighted specially
|
||||
"File",
|
||||
"Rendering",
|
||||
"Rendered",
|
||||
"Pressed key",
|
||||
]
|
||||
|
||||
|
||||
@ -33,9 +35,12 @@ def make_logger() -> logging.Logger:
|
||||
RichHandler.KEYWORDS = HIGHLIGHTED_KEYWORDS
|
||||
rich_handler = RichHandler(
|
||||
show_time=True,
|
||||
console=Console(),
|
||||
)
|
||||
logger = logging.getLogger("manim-slides")
|
||||
logger.setLevel(logging.getLogger("manim").level)
|
||||
logger.addHandler(rich_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
|
@ -1,10 +1,10 @@
|
||||
import os
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from importlib.util import find_spec
|
||||
from typing import Iterator
|
||||
|
||||
__all__ = [
|
||||
# Constants
|
||||
"FFMPEG_BIN",
|
||||
"LEFT",
|
||||
"MANIM",
|
||||
"MANIM_PACKAGE_NAME",
|
||||
"MANIM_AVAILABLE",
|
||||
@ -13,25 +13,19 @@ __all__ = [
|
||||
"MANIMGL_PACKAGE_NAME",
|
||||
"MANIMGL_AVAILABLE",
|
||||
"MANIMGL_IMPORTED",
|
||||
"logger",
|
||||
# Classes
|
||||
"AnimationGroup",
|
||||
"FadeIn",
|
||||
"FadeOut",
|
||||
"Mobject",
|
||||
"Scene",
|
||||
"ThreeDScene",
|
||||
# Objects
|
||||
"logger",
|
||||
"config",
|
||||
"FFMPEG_BIN",
|
||||
]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def suppress_stdout() -> Iterator[None]:
|
||||
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
|
||||
@ -43,8 +37,8 @@ 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"
|
||||
logger.warning(
|
||||
"Both manim and manimgl are imported, therefore `manim-slide` needs to know 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
|
||||
@ -67,15 +61,33 @@ else:
|
||||
|
||||
|
||||
if MANIMGL:
|
||||
from manimlib import Scene, ThreeDScene, config
|
||||
from manimlib import (
|
||||
LEFT,
|
||||
AnimationGroup,
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
Mobject,
|
||||
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
|
||||
from manim import (
|
||||
LEFT,
|
||||
AnimationGroup,
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
Mobject,
|
||||
Scene,
|
||||
ThreeDScene,
|
||||
config,
|
||||
logger,
|
||||
)
|
||||
|
||||
try: # For manim<v0.16.0.post0
|
||||
from manim.constants import FFMPEG_BIN
|
||||
except ImportError:
|
||||
FFMPEG_BIN = config.ffmpeg_executable
|
||||
try: # For manim<v0.16.0.post0
|
||||
from manim.constants import FFMPEG_BIN
|
||||
except ImportError:
|
||||
FFMPEG_BIN = config.ffmpeg_executable
|
||||
|
303
manim_slides/present/__init__.py
Normal file
@ -0,0 +1,303 @@
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import click
|
||||
from click import Context, Parameter
|
||||
from pydantic import ValidationError
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from ..commons import config_path_option, folder_path_option, verbosity_option
|
||||
from ..config import Config, PresentationConfig
|
||||
from ..logger import logger
|
||||
from .player import Player
|
||||
|
||||
ASPECT_RATIO_MODES = {
|
||||
"keep": Qt.KeepAspectRatio,
|
||||
"ignore": Qt.IgnoreAspectRatio,
|
||||
}
|
||||
|
||||
|
||||
@click.command()
|
||||
@folder_path_option
|
||||
@click.help_option("-h", "--help")
|
||||
@verbosity_option
|
||||
def list_scenes(folder: Path) -> None:
|
||||
"""List available scenes."""
|
||||
|
||||
for i, scene in enumerate(_list_scenes(folder), start=1):
|
||||
click.secho(f"{i}: {scene}", fg="green")
|
||||
|
||||
|
||||
def _list_scenes(folder: Path) -> List[str]:
|
||||
"""Lists available scenes in given directory."""
|
||||
scenes = []
|
||||
|
||||
for filepath in folder.glob("*.json"):
|
||||
try:
|
||||
_ = PresentationConfig.from_file(filepath)
|
||||
scenes.append(filepath.stem)
|
||||
except (
|
||||
Exception
|
||||
) as e: # Could not parse this file as a proper presentation config
|
||||
logger.warn(
|
||||
f"Something went wrong with parsing presentation config `{filepath}`: {e}"
|
||||
)
|
||||
|
||||
logger.debug(f"Found {len(scenes)} valid scene configuration files in `{folder}`.")
|
||||
|
||||
return scenes
|
||||
|
||||
|
||||
def prompt_for_scenes(folder: Path) -> List[str]:
|
||||
"""Prompts the user to select scenes within a given folder."""
|
||||
|
||||
scene_choices = dict(enumerate(_list_scenes(folder), 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: Optional[str]) -> List[str]:
|
||||
indices = list(map(int, (value or "").strip().replace(" ", "").split(",")))
|
||||
|
||||
if not all(0 < i <= len(scene_choices) for i in 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)
|
||||
return scenes # type: ignore
|
||||
except ValueError as e:
|
||||
raise click.UsageError(str(e))
|
||||
|
||||
|
||||
def get_scenes_presentation_config(
|
||||
scenes: List[str], folder: Path
|
||||
) -> List[PresentationConfig]:
|
||||
"""Returns a list of presentation configurations based on the user input."""
|
||||
|
||||
if len(scenes) == 0:
|
||||
scenes = prompt_for_scenes(folder)
|
||||
|
||||
presentation_configs = []
|
||||
for scene in scenes:
|
||||
config_file = folder / f"{scene}.json"
|
||||
if not config_file.exists():
|
||||
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:
|
||||
presentation_configs.append(PresentationConfig.from_file(config_file))
|
||||
except ValidationError as e:
|
||||
raise click.UsageError(str(e))
|
||||
|
||||
return presentation_configs
|
||||
|
||||
|
||||
def start_at_callback(
|
||||
ctx: Context, param: Parameter, values: str
|
||||
) -> Tuple[Optional[int], ...]:
|
||||
if values == "(None, None)":
|
||||
return (None, None)
|
||||
|
||||
def str_to_int_or_none(value: str) -> Optional[int]:
|
||||
if value.lower().strip() == "":
|
||||
return None
|
||||
else:
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
raise click.BadParameter(
|
||||
f"start index can only be an integer or an empty string, not `{value}`",
|
||||
ctx=ctx,
|
||||
param=param,
|
||||
)
|
||||
|
||||
values_tuple = values.split(",")
|
||||
n_values = len(values_tuple)
|
||||
if n_values == 2:
|
||||
return tuple(map(str_to_int_or_none, values_tuple))
|
||||
|
||||
raise click.BadParameter(
|
||||
f"exactly 2 arguments are expected but you gave {n_values}, please use commas to separate them",
|
||||
ctx=ctx,
|
||||
param=param,
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("scenes", nargs=-1)
|
||||
@config_path_option
|
||||
@folder_path_option
|
||||
@click.option("--start-paused", is_flag=True, help="Start paused.")
|
||||
@click.option(
|
||||
"-F",
|
||||
"--full-screen",
|
||||
"--fullscreen",
|
||||
"full_screen",
|
||||
is_flag=True,
|
||||
help="Toggle full screen mode.",
|
||||
)
|
||||
@click.option(
|
||||
"-s",
|
||||
"--skip-all",
|
||||
is_flag=True,
|
||||
help="Skip all slides, useful the test if slides are working. "
|
||||
"Automatically sets `--exit-after-last-slide` to True.",
|
||||
)
|
||||
@click.option(
|
||||
"--exit-after-last-slide",
|
||||
is_flag=True,
|
||||
help="At the end of last slide, the application will be exited.",
|
||||
)
|
||||
@click.option(
|
||||
"-H",
|
||||
"--hide-mouse",
|
||||
is_flag=True,
|
||||
help="Hide mouse cursor.",
|
||||
)
|
||||
@click.option(
|
||||
"--aspect-ratio",
|
||||
type=click.Choice(["keep", "ignore"], case_sensitive=False),
|
||||
default="keep",
|
||||
help="Set the aspect ratio mode to be used when rescaling the video.",
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
"--sa",
|
||||
"--start-at",
|
||||
"start_at",
|
||||
metavar="<SCENE,SLIDE>",
|
||||
type=str,
|
||||
callback=start_at_callback,
|
||||
default=(None, None),
|
||||
help="Start presenting at (x, y), equivalent to --sacn x --sasn y, "
|
||||
"and overrides values if not None.",
|
||||
)
|
||||
@click.option(
|
||||
"--sacn",
|
||||
"--start-at-scene-number",
|
||||
"start_at_scene_number",
|
||||
metavar="INDEX",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Start presenting at a given scene number (0 is first, -1 is last).",
|
||||
)
|
||||
@click.option(
|
||||
"--sasn",
|
||||
"--start-at-slide-number",
|
||||
"start_at_slide_number",
|
||||
metavar="INDEX",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Start presenting at a given slide number (0 is first, -1 is last).",
|
||||
)
|
||||
@click.option(
|
||||
"-S",
|
||||
"--screen",
|
||||
"screen_number",
|
||||
metavar="NUMBER",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Presents content on the given screen (a.k.a. display).",
|
||||
)
|
||||
@click.help_option("-h", "--help")
|
||||
@verbosity_option
|
||||
def present(
|
||||
scenes: List[str],
|
||||
config_path: Path,
|
||||
folder: Path,
|
||||
start_paused: bool,
|
||||
full_screen: bool,
|
||||
skip_all: bool,
|
||||
exit_after_last_slide: bool,
|
||||
hide_mouse: bool,
|
||||
aspect_ratio: str,
|
||||
start_at: Tuple[Optional[int], Optional[int], Optional[int]],
|
||||
start_at_scene_number: int,
|
||||
start_at_slide_number: int,
|
||||
screen_number: Optional[int] = None,
|
||||
) -> None:
|
||||
"""
|
||||
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 skip_all:
|
||||
exit_after_last_slide = True
|
||||
|
||||
presentation_configs = get_scenes_presentation_config(scenes, folder)
|
||||
|
||||
if config_path.exists():
|
||||
try:
|
||||
config = Config.from_file(config_path)
|
||||
except ValidationError as e:
|
||||
raise click.UsageError(str(e))
|
||||
else:
|
||||
logger.debug("No configuration file found, using default configuration.")
|
||||
config = Config()
|
||||
|
||||
if start_at[0]:
|
||||
start_at_scene_number = start_at[0]
|
||||
|
||||
if start_at[1]:
|
||||
start_at_scene_number = start_at[1]
|
||||
|
||||
if maybe_app := QApplication.instance():
|
||||
app = maybe_app
|
||||
else:
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
app.setApplicationName("Manim Slides")
|
||||
|
||||
if screen_number is not None:
|
||||
try:
|
||||
screen = app.screens()[screen_number]
|
||||
except IndexError:
|
||||
logger.error(
|
||||
f"Invalid screen number {screen_number}, "
|
||||
f"allowed values are from 0 to {len(app.screens())-1} (incl.)"
|
||||
)
|
||||
screen = None
|
||||
else:
|
||||
screen = None
|
||||
|
||||
player = Player(
|
||||
config,
|
||||
presentation_configs,
|
||||
start_paused=start_paused,
|
||||
full_screen=full_screen,
|
||||
skip_all=skip_all,
|
||||
exit_after_last_slide=exit_after_last_slide,
|
||||
hide_mouse=hide_mouse,
|
||||
aspect_ratio_mode=ASPECT_RATIO_MODES[aspect_ratio],
|
||||
presentation_index=start_at_scene_number,
|
||||
slide_index=start_at_slide_number,
|
||||
screen=screen,
|
||||
)
|
||||
|
||||
player.show()
|
||||
|
||||
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
||||
sys.exit(app.exec_())
|
346
manim_slides/present/player.py
Normal file
@ -0,0 +1,346 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QUrl, Signal, Slot
|
||||
from PySide6.QtGui import QCloseEvent, QIcon, QKeyEvent, QScreen
|
||||
from PySide6.QtMultimedia import QMediaPlayer
|
||||
from PySide6.QtMultimediaWidgets import QVideoWidget
|
||||
from PySide6.QtWidgets import QDialog, QGridLayout, QLabel, QMainWindow
|
||||
|
||||
from ..config import Config, PresentationConfig, SlideConfig
|
||||
from ..logger import logger
|
||||
from ..resources import * # noqa: F401, F403
|
||||
|
||||
WINDOW_NAME = "Manim Slides"
|
||||
|
||||
|
||||
class Info(QDialog): # type: ignore[misc]
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
layout = QGridLayout()
|
||||
self.scene_label = QLabel()
|
||||
self.slide_label = QLabel()
|
||||
|
||||
layout.addWidget(QLabel("Scene:"), 1, 1)
|
||||
layout.addWidget(QLabel("Slide:"), 2, 1)
|
||||
layout.addWidget(self.scene_label, 1, 2)
|
||||
layout.addWidget(self.slide_label, 2, 2)
|
||||
self.setLayout(layout)
|
||||
self.setFixedWidth(150)
|
||||
self.setFixedHeight(80)
|
||||
|
||||
if parent := self.parent():
|
||||
self.closeEvent = parent.closeEvent
|
||||
self.keyPressEvent = parent.keyPressEvent
|
||||
|
||||
|
||||
class Player(QMainWindow): # type: ignore[misc]
|
||||
presentation_changed: Signal = Signal()
|
||||
slide_changed: Signal = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Config,
|
||||
presentation_configs: List[PresentationConfig],
|
||||
*,
|
||||
start_paused: bool = False,
|
||||
full_screen: bool = False,
|
||||
skip_all: bool = False,
|
||||
exit_after_last_slide: bool = False,
|
||||
hide_mouse: bool = False,
|
||||
aspect_ratio_mode: Qt.AspectRatioMode = Qt.KeepAspectRatio,
|
||||
presentation_index: int = 0,
|
||||
slide_index: int = 0,
|
||||
screen: Optional[QScreen] = None,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
# Wizard's config
|
||||
|
||||
self.config = config
|
||||
|
||||
# Presentation configs
|
||||
|
||||
self.presentation_configs = presentation_configs
|
||||
self.__current_presentation_index = 0
|
||||
self.__current_slide_index = 0
|
||||
self.__current_file: Path = self.current_slide_config.file
|
||||
|
||||
self.current_presentation_index = presentation_index
|
||||
self.current_slide_index = slide_index
|
||||
|
||||
self.__playing_reversed_slide = False
|
||||
|
||||
# Widgets
|
||||
|
||||
if screen:
|
||||
self.setScreen(screen)
|
||||
self.move(screen.geometry().topLeft())
|
||||
|
||||
if full_screen:
|
||||
self.setWindowState(Qt.WindowFullScreen)
|
||||
else:
|
||||
w, h = self.current_presentation_config.resolution
|
||||
geometry = self.geometry()
|
||||
geometry.setWidth(w)
|
||||
geometry.setHeight(h)
|
||||
self.setGeometry(geometry)
|
||||
|
||||
if hide_mouse:
|
||||
self.setCursor(Qt.BlankCursor)
|
||||
|
||||
self.setWindowTitle(WINDOW_NAME)
|
||||
self.icon = QIcon(":/icon.png")
|
||||
self.setWindowIcon(self.icon)
|
||||
|
||||
self.video_widget = QVideoWidget()
|
||||
self.video_widget.setAspectRatioMode(aspect_ratio_mode)
|
||||
self.setCentralWidget(self.video_widget)
|
||||
|
||||
self.media_player = QMediaPlayer(self)
|
||||
self.media_player.setVideoOutput(self.video_widget)
|
||||
|
||||
self.presentation_changed.connect(self.presentation_changed_callback)
|
||||
self.slide_changed.connect(self.slide_changed_callback)
|
||||
|
||||
self.info = Info(parent=self)
|
||||
|
||||
# Connecting key callbacks
|
||||
|
||||
self.config.keys.QUIT.connect(self.quit)
|
||||
self.config.keys.PLAY_PAUSE.connect(self.play_pause)
|
||||
self.config.keys.NEXT.connect(self.next)
|
||||
self.config.keys.PREVIOUS.connect(self.previous)
|
||||
self.config.keys.REVERSE.connect(self.reverse)
|
||||
self.config.keys.REPLAY.connect(self.replay)
|
||||
self.config.keys.FULL_SCREEN.connect(self.full_screen)
|
||||
self.config.keys.HIDE_MOUSE.connect(self.hide_mouse)
|
||||
|
||||
self.dispatch = self.config.keys.dispatch_key_function()
|
||||
|
||||
# Misc
|
||||
|
||||
self.exit_after_last_slide = exit_after_last_slide
|
||||
|
||||
# Setting-up everything
|
||||
|
||||
if skip_all:
|
||||
|
||||
def media_status_changed(status: QMediaPlayer.MediaStatus) -> None:
|
||||
self.media_player.setLoops(1) # Otherwise looping slides never end
|
||||
if status == QMediaPlayer.EndOfMedia:
|
||||
self.load_next_slide()
|
||||
|
||||
self.media_player.mediaStatusChanged.connect(media_status_changed)
|
||||
|
||||
if self.current_slide_config.loop:
|
||||
self.media_player.setLoops(-1)
|
||||
|
||||
self.load_current_media(start_paused=start_paused)
|
||||
|
||||
self.presentation_changed.emit()
|
||||
self.slide_changed.emit()
|
||||
|
||||
"""
|
||||
Properties
|
||||
"""
|
||||
|
||||
@property
|
||||
def presentations_count(self) -> int:
|
||||
return len(self.presentation_configs)
|
||||
|
||||
@property
|
||||
def current_presentation_index(self) -> int:
|
||||
return self.__current_presentation_index
|
||||
|
||||
@current_presentation_index.setter
|
||||
def current_presentation_index(self, index: int) -> None:
|
||||
if 0 <= index < self.presentations_count:
|
||||
self.__current_presentation_index = index
|
||||
elif -self.presentations_count <= index < 0:
|
||||
self.__current_presentation_index = index + self.presentations_count
|
||||
else:
|
||||
logger.warn(f"Could not set presentation index to {index}")
|
||||
return
|
||||
|
||||
self.presentation_changed.emit()
|
||||
|
||||
@property
|
||||
def current_presentation_config(self) -> PresentationConfig:
|
||||
return self.presentation_configs[self.current_presentation_index]
|
||||
|
||||
@property
|
||||
def current_slides_count(self) -> int:
|
||||
return len(self.current_presentation_config.slides)
|
||||
|
||||
@property
|
||||
def current_slide_index(self) -> int:
|
||||
return self.__current_slide_index
|
||||
|
||||
@current_slide_index.setter
|
||||
def current_slide_index(self, index: int) -> None:
|
||||
if 0 <= index < self.current_slides_count:
|
||||
self.__current_slide_index = index
|
||||
elif -self.current_slides_count <= index < 0:
|
||||
self.__current_slide_index = index + self.current_slides_count
|
||||
else:
|
||||
logger.warn(f"Could not set slide index to {index}")
|
||||
return
|
||||
|
||||
self.slide_changed.emit()
|
||||
|
||||
@property
|
||||
def current_slide_config(self) -> SlideConfig:
|
||||
return self.current_presentation_config.slides[self.current_slide_index]
|
||||
|
||||
@property
|
||||
def current_file(self) -> Path:
|
||||
return self.__current_file
|
||||
|
||||
@current_file.setter
|
||||
def current_file(self, file: Path) -> None:
|
||||
self.__current_file = file
|
||||
|
||||
@property
|
||||
def playing_reversed_slide(self) -> bool:
|
||||
return self.__playing_reversed_slide
|
||||
|
||||
@playing_reversed_slide.setter
|
||||
def playing_reversed_slide(self, playing_reversed_slide: bool) -> None:
|
||||
self.__playing_reversed_slide = playing_reversed_slide
|
||||
|
||||
"""
|
||||
Loading slides
|
||||
"""
|
||||
|
||||
def load_current_media(self, start_paused: bool = False) -> None:
|
||||
url = QUrl.fromLocalFile(self.current_file)
|
||||
self.media_player.setSource(url)
|
||||
|
||||
if start_paused:
|
||||
self.media_player.pause()
|
||||
else:
|
||||
self.media_player.play()
|
||||
|
||||
def load_current_slide(self) -> None:
|
||||
slide_config = self.current_slide_config
|
||||
self.current_file = slide_config.file
|
||||
|
||||
if slide_config.loop:
|
||||
self.media_player.setLoops(-1)
|
||||
else:
|
||||
self.media_player.setLoops(1)
|
||||
|
||||
self.load_current_media()
|
||||
|
||||
def load_previous_slide(self) -> None:
|
||||
self.playing_reversed_slide = False
|
||||
|
||||
if self.current_slide_index > 0:
|
||||
self.current_slide_index -= 1
|
||||
elif self.current_presentation_index > 0:
|
||||
self.current_presentation_index -= 1
|
||||
self.current_slide_index = self.current_slides_count - 1
|
||||
else:
|
||||
logger.info("No previous slide.")
|
||||
return
|
||||
|
||||
self.load_current_slide()
|
||||
|
||||
def load_next_slide(self) -> None:
|
||||
if self.playing_reversed_slide:
|
||||
self.playing_reversed_slide = False
|
||||
elif self.current_slide_index < self.current_slides_count - 1:
|
||||
self.current_slide_index += 1
|
||||
elif self.current_presentation_index < self.presentations_count - 1:
|
||||
self.current_presentation_index += 1
|
||||
self.current_slide_index = 0
|
||||
elif self.exit_after_last_slide:
|
||||
self.quit()
|
||||
else:
|
||||
logger.info("No more slide to play.")
|
||||
return
|
||||
|
||||
self.load_current_slide()
|
||||
|
||||
def load_reversed_slide(self) -> None:
|
||||
self.playing_reversed_slide = True
|
||||
self.current_file = self.current_slide_config.rev_file
|
||||
self.load_current_media()
|
||||
|
||||
"""
|
||||
Key callbacks and slots
|
||||
"""
|
||||
|
||||
@Slot()
|
||||
def presentation_changed_callback(self) -> None:
|
||||
index = self.current_presentation_index
|
||||
count = self.presentations_count
|
||||
self.info.scene_label.setText(f"{index+1:4d}/{count:4<d}")
|
||||
|
||||
@Slot()
|
||||
def slide_changed_callback(self) -> None:
|
||||
index = self.current_slide_index
|
||||
count = self.current_slides_count
|
||||
self.info.slide_label.setText(f"{index+1:4d}/{count:4<d}")
|
||||
|
||||
def show(self) -> None:
|
||||
super().show()
|
||||
self.info.show()
|
||||
|
||||
@Slot()
|
||||
def quit(self) -> None:
|
||||
logger.info("Closing gracefully...")
|
||||
self.info.deleteLater()
|
||||
self.deleteLater()
|
||||
|
||||
@Slot()
|
||||
def next(self) -> None:
|
||||
if self.media_player.playbackState() == QMediaPlayer.PausedState:
|
||||
self.media_player.play()
|
||||
else:
|
||||
self.load_next_slide()
|
||||
|
||||
@Slot()
|
||||
def previous(self) -> None:
|
||||
self.load_previous_slide()
|
||||
|
||||
@Slot()
|
||||
def reverse(self) -> None:
|
||||
self.load_reversed_slide()
|
||||
|
||||
@Slot()
|
||||
def replay(self) -> None:
|
||||
self.media_player.setPosition(0)
|
||||
self.media_player.play()
|
||||
|
||||
@Slot()
|
||||
def play_pause(self) -> None:
|
||||
state = self.media_player.playbackState()
|
||||
if state == QMediaPlayer.PausedState:
|
||||
self.media_player.play()
|
||||
elif state == QMediaPlayer.PlayingState:
|
||||
self.media_player.pause()
|
||||
|
||||
@Slot()
|
||||
def full_screen(self) -> None:
|
||||
if self.windowState() == Qt.WindowFullScreen:
|
||||
self.setWindowState(Qt.WindowNoState)
|
||||
else:
|
||||
self.setWindowState(Qt.WindowFullScreen)
|
||||
|
||||
@Slot()
|
||||
def hide_mouse(self) -> None:
|
||||
if self.cursor().shape() == Qt.BlankCursor:
|
||||
self.setCursor(Qt.ArrowCursor)
|
||||
else:
|
||||
self.setCursor(Qt.BlankCursor)
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None:
|
||||
self.quit()
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None:
|
||||
key = event.key()
|
||||
self.dispatch(key)
|
||||
event.accept()
|
@ -1,42 +1,47 @@
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Any, List, Optional, Tuple
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
Any,
|
||||
List,
|
||||
Mapping,
|
||||
MutableMapping,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
ValuesView,
|
||||
)
|
||||
from warnings import warn
|
||||
|
||||
import numpy as np
|
||||
from tqdm import tqdm
|
||||
|
||||
from .config import PresentationConfig, SlideConfig, SlideType
|
||||
from .config import PresentationConfig, PreSlideConfig, SlideConfig
|
||||
from .defaults import FOLDER_PATH
|
||||
from .manim import FFMPEG_BIN, MANIMGL, Scene, ThreeDScene, config, logger
|
||||
|
||||
|
||||
def reverse_video_file(src: str, dst: str) -> None:
|
||||
"""Reverses a video file, writting the result to `dst`."""
|
||||
command = [FFMPEG_BIN, "-i", src, "-vf", "reverse", dst]
|
||||
logger.debug(" ".join(command))
|
||||
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
output, error = process.communicate()
|
||||
|
||||
if output:
|
||||
logger.debug(output.decode())
|
||||
|
||||
if error:
|
||||
logger.debug(error.decode())
|
||||
from .manim import (
|
||||
LEFT,
|
||||
MANIMGL,
|
||||
AnimationGroup,
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
Mobject,
|
||||
Scene,
|
||||
ThreeDScene,
|
||||
config,
|
||||
logger,
|
||||
)
|
||||
from .utils import concatenate_video_files, merge_basenames, reverse_video_file
|
||||
|
||||
|
||||
class Slide(Scene): # type:ignore
|
||||
"""
|
||||
Inherits from :class:`manim.scene.scene.Scene` or :class:`manimlib.scene.scene.Scene` and provide necessary tools for slides rendering.
|
||||
Inherits from :class:`Scene<manim.scene.scene.Scene>` and provide necessary tools for slides rendering.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, *args: Any, output_folder: str = FOLDER_PATH, **kwargs: Any
|
||||
self, *args: Any, output_folder: Path = FOLDER_PATH, **kwargs: Any
|
||||
) -> None:
|
||||
if MANIMGL:
|
||||
if not os.path.isdir("videos"):
|
||||
os.mkdir("videos")
|
||||
Path("videos").mkdir(exist_ok=True)
|
||||
kwargs["file_writer_config"] = {
|
||||
"break_into_partial_movies": True,
|
||||
"output_directory": "",
|
||||
@ -47,12 +52,38 @@ class Slide(Scene): # type:ignore
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.__output_folder = output_folder
|
||||
self.__slides: List[SlideConfig] = []
|
||||
self.__output_folder: Path = output_folder
|
||||
self.__slides: List[PreSlideConfig] = []
|
||||
self.__current_slide = 1
|
||||
self.__current_animation = 0
|
||||
self.__loop_start_animation: Optional[int] = None
|
||||
self.__pause_start_animation = 0
|
||||
self.__canvas: MutableMapping[str, Mobject] = {}
|
||||
self.__wait_time_between_slides = 0.0
|
||||
|
||||
@property
|
||||
def __frame_height(self) -> float:
|
||||
"""Returns the scene's frame height."""
|
||||
if MANIMGL:
|
||||
return self.frame_height # type: ignore
|
||||
else:
|
||||
return config["frame_height"] # type: ignore
|
||||
|
||||
@property
|
||||
def __frame_width(self) -> float:
|
||||
"""Returns the scene's frame width."""
|
||||
if MANIMGL:
|
||||
return self.frame_width # type: ignore
|
||||
else:
|
||||
return config["frame_width"] # type: ignore
|
||||
|
||||
@property
|
||||
def __background_color(self) -> str:
|
||||
"""Returns the scene's background color."""
|
||||
if MANIMGL:
|
||||
return self.camera_config["background_color"].hex # type: ignore
|
||||
else:
|
||||
return config["background_color"].hex # type: ignore
|
||||
|
||||
@property
|
||||
def __resolution(self) -> Tuple[int, int]:
|
||||
@ -63,7 +94,7 @@ class Slide(Scene): # type:ignore
|
||||
return config["pixel_width"], config["pixel_height"]
|
||||
|
||||
@property
|
||||
def __partial_movie_files(self) -> List[str]:
|
||||
def __partial_movie_files(self) -> List[Path]:
|
||||
"""Returns a list of partial movie files, a.k.a animations."""
|
||||
if MANIMGL:
|
||||
from manimlib.utils.file_ops import get_sorted_integer_files
|
||||
@ -72,11 +103,13 @@ class Slide(Scene): # type:ignore
|
||||
"remove_non_integer_files": True,
|
||||
"extension": self.file_writer.movie_file_extension,
|
||||
}
|
||||
return get_sorted_integer_files( # type: ignore
|
||||
files = get_sorted_integer_files(
|
||||
self.file_writer.partial_movie_directory, **kwargs
|
||||
)
|
||||
else:
|
||||
return self.renderer.file_writer.partial_movie_files # type: ignore
|
||||
files = self.renderer.file_writer.partial_movie_files
|
||||
|
||||
return [Path(file) for file in files]
|
||||
|
||||
@property
|
||||
def __show_progress_bar(self) -> bool:
|
||||
@ -101,6 +134,172 @@ class Slide(Scene): # type:ignore
|
||||
else:
|
||||
return config["from_animation_number"] # type: ignore
|
||||
|
||||
@property
|
||||
def canvas(self) -> MutableMapping[str, Mobject]:
|
||||
"""
|
||||
Returns the canvas associated to the current slide.
|
||||
|
||||
The canvas is a mapping between names and Mobjects,
|
||||
for objects that are assumed to stay in multiple slides.
|
||||
|
||||
For example, a section title or a slide number.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
.. manim-slides:: CanvasExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class CanvasExample(Slide):
|
||||
def update_canvas(self):
|
||||
self.counter += 1
|
||||
old_slide_number = self.canvas["slide_number"]
|
||||
new_slide_number = Text(f"{self.counter}").move_to(old_slide_number)
|
||||
self.play(Transform(old_slide_number, new_slide_number))
|
||||
|
||||
def construct(self):
|
||||
title = Text("My Title").to_corner(UL)
|
||||
|
||||
self.counter = 1
|
||||
slide_number = Text("1").to_corner(DL)
|
||||
|
||||
self.add_to_canvas(title=title, slide_number=slide_number)
|
||||
|
||||
self.play(FadeIn(title), FadeIn(slide_number))
|
||||
self.next_slide()
|
||||
|
||||
circle = Circle(radius=2)
|
||||
dot = Dot()
|
||||
|
||||
self.update_canvas()
|
||||
self.play(Create(circle))
|
||||
self.play(MoveAlongPath(dot, circle))
|
||||
|
||||
self.next_slide()
|
||||
self.update_canvas()
|
||||
|
||||
square = Square()
|
||||
|
||||
self.play(self.wipe(self.mobjects_without_canvas, square))
|
||||
self.next_slide()
|
||||
|
||||
self.update_canvas()
|
||||
self.play(
|
||||
Transform(
|
||||
self.canvas["title"],
|
||||
Text("New Title").to_corner(UL)
|
||||
)
|
||||
)
|
||||
self.next_slide()
|
||||
|
||||
self.remove_from_canvas("title", "slide_number")
|
||||
self.play(self.wipe(self.mobjects_without_canvas, []))
|
||||
|
||||
"""
|
||||
return self.__canvas
|
||||
|
||||
def add_to_canvas(self, **objects: Mobject) -> Mobject:
|
||||
"""
|
||||
Adds objects to the canvas, using key values as names.
|
||||
|
||||
:param objects: A mapping between names and Mobjects.
|
||||
|
||||
.. note::
|
||||
|
||||
This method does not actually do anything in terms of
|
||||
animations. You must still call :code:`self.add` or
|
||||
play some animation that introduces each Mobject for
|
||||
it to appear. The same applies when removing objects.
|
||||
"""
|
||||
self.__canvas.update(objects)
|
||||
|
||||
def remove_from_canvas(self, *names: str) -> None:
|
||||
"""
|
||||
Removes objects from the canvas.
|
||||
"""
|
||||
for name in names:
|
||||
self.__canvas.pop(name)
|
||||
|
||||
@property
|
||||
def canvas_mobjects(self) -> ValuesView[Mobject]:
|
||||
"""
|
||||
Returns Mobjects contained in the canvas.
|
||||
"""
|
||||
return self.canvas.values()
|
||||
|
||||
@property
|
||||
def mobjects_without_canvas(self) -> Sequence[Mobject]:
|
||||
"""
|
||||
Returns the list of objects contained in the scene,
|
||||
minus those present in the canvas.
|
||||
"""
|
||||
return [
|
||||
mobject for mobject in self.mobjects if mobject not in self.canvas_mobjects
|
||||
]
|
||||
|
||||
@property
|
||||
def wait_time_between_slides(self) -> float:
|
||||
r"""
|
||||
Returns the wait duration (in seconds) added between two slides.
|
||||
|
||||
By default, this value is set to 0.
|
||||
|
||||
Setting this value to something bigger than 0 will result in a
|
||||
:code:`self.wait` animation called at the end of every slide.
|
||||
|
||||
.. note::
|
||||
This is useful because animations are usually only terminated
|
||||
when a new animation is played. You can observe the small difference
|
||||
in the examples below: the circle is not fully complete in the first
|
||||
slide of the first example, but well in the second example.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
.. manim-slides:: WithoutWaitExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class WithoutWaitExample(Slide):
|
||||
def construct(self):
|
||||
circle = Circle(radius=2)
|
||||
arrow = Arrow().next_to(circle, RIGHT).scale(-1)
|
||||
text = Text("Small\ngap").next_to(arrow, RIGHT)
|
||||
|
||||
self.play(Create(arrow), FadeIn(text))
|
||||
self.play(Create(circle))
|
||||
self.next_slide()
|
||||
|
||||
self.play(FadeOut(circle))
|
||||
|
||||
.. manim-slides:: WithWaitExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class WithWaitExample(Slide):
|
||||
def construct(self):
|
||||
self.wait_time_between_slides = 0.1 # A small value > 1 / FPS
|
||||
circle = Circle(radius=2)
|
||||
arrow = Arrow().next_to(circle, RIGHT).scale(-1)
|
||||
text = Text("No more\ngap").next_to(arrow, RIGHT)
|
||||
|
||||
self.play(Create(arrow), FadeIn(text))
|
||||
self.play(Create(circle))
|
||||
self.next_slide()
|
||||
|
||||
self.play(FadeOut(circle))
|
||||
|
||||
"""
|
||||
return self.__wait_time_between_slides
|
||||
|
||||
@wait_time_between_slides.setter
|
||||
def wait_time_between_slides(self, wait_time: float) -> None:
|
||||
self.__wait_time_between_slides = max(wait_time, 0.0)
|
||||
|
||||
def play(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Overloads `self.play` and increment animation count."""
|
||||
super().play(*args, **kwargs)
|
||||
@ -132,16 +331,15 @@ class Slide(Scene): # type:ignore
|
||||
#. the second with "Hello World!" fading in;
|
||||
#. and the last with the text fading out;
|
||||
|
||||
.. code-block:: python
|
||||
.. manim-slides:: NextSlideExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class Example(Slide):
|
||||
class NextSlideExample(Slide):
|
||||
def construct(self):
|
||||
text = Text("Hello World!")
|
||||
|
||||
self.next_slide()
|
||||
self.play(FadeIn(text))
|
||||
|
||||
self.next_slide()
|
||||
@ -151,12 +349,13 @@ class Slide(Scene): # type:ignore
|
||||
self.__loop_start_animation is None
|
||||
), "You cannot call `self.next_slide()` inside a loop"
|
||||
|
||||
if self.wait_time_between_slides > 0.0:
|
||||
self.wait(self.wait_time_between_slides)
|
||||
|
||||
self.__slides.append(
|
||||
SlideConfig(
|
||||
type=SlideType.slide,
|
||||
PreSlideConfig(
|
||||
start_animation=self.__pause_start_animation,
|
||||
end_animation=self.__current_animation,
|
||||
number=self.__current_slide,
|
||||
)
|
||||
)
|
||||
self.__current_slide += 1
|
||||
@ -183,15 +382,13 @@ class Slide(Scene): # type:ignore
|
||||
len(self.__slides) > 0
|
||||
and self.__current_animation == self.__slides[-1].end_animation
|
||||
):
|
||||
self.__slides[-1].type = SlideType.last
|
||||
return
|
||||
|
||||
self.__slides.append(
|
||||
SlideConfig(
|
||||
type=SlideType.last,
|
||||
PreSlideConfig(
|
||||
start_animation=self.__pause_start_animation,
|
||||
end_animation=self.__current_animation,
|
||||
number=self.__current_slide,
|
||||
loop=self.__loop_start_animation is not None,
|
||||
)
|
||||
)
|
||||
|
||||
@ -202,25 +399,35 @@ class Slide(Scene): # type:ignore
|
||||
A loop will automatically replay the slide, i.e., everything between
|
||||
:func:`start_loop` and :func:`end_loop`, upon reaching end.
|
||||
|
||||
.. warning::
|
||||
|
||||
When rendered with RevealJS, loops cannot be in the first nor
|
||||
the last slide.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
The following contains one slide that will loop endlessly.
|
||||
|
||||
.. code-block:: python
|
||||
.. manim-slides:: LoopExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class Example(Slide):
|
||||
class LoopExample(Slide):
|
||||
def construct(self):
|
||||
dot = Dot(color=BLUE)
|
||||
dot = Dot(color=BLUE, radius=1)
|
||||
|
||||
self.play(FadeIn(dot))
|
||||
self.next_slide()
|
||||
|
||||
self.start_loop()
|
||||
|
||||
self.play(Indicate(dot))
|
||||
self.play(Indicate(dot, scale_factor=2))
|
||||
|
||||
self.end_loop()
|
||||
|
||||
self.play(FadeOut(dot))
|
||||
"""
|
||||
assert self.__loop_start_animation is None, "You cannot nest loops"
|
||||
self.__loop_start_animation = self.__current_animation
|
||||
@ -231,11 +438,10 @@ class Slide(Scene): # type:ignore
|
||||
self.__loop_start_animation is not None
|
||||
), "You have to start a loop before ending it"
|
||||
self.__slides.append(
|
||||
SlideConfig(
|
||||
type=SlideType.loop,
|
||||
PreSlideConfig(
|
||||
start_animation=self.__loop_start_animation,
|
||||
end_animation=self.__current_animation,
|
||||
number=self.__current_slide,
|
||||
loop=True,
|
||||
)
|
||||
)
|
||||
self.__current_slide += 1
|
||||
@ -250,83 +456,70 @@ class Slide(Scene): # type:ignore
|
||||
"""
|
||||
self.__add_last_slide()
|
||||
|
||||
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)
|
||||
files_folder = self.__output_folder / "files"
|
||||
|
||||
scene_name = str(self)
|
||||
scene_files_folder = os.path.join(files_folder, scene_name)
|
||||
scene_files_folder = files_folder / scene_name
|
||||
|
||||
old_animation_files = set()
|
||||
scene_files_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
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 = []
|
||||
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,
|
||||
):
|
||||
if src_file is None and not MANIMGL:
|
||||
# This happens if rendering with -na,b (manim only)
|
||||
# where animations not in [a,b] will be skipped
|
||||
# but animations before a will have a None src_file
|
||||
continue
|
||||
|
||||
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)
|
||||
# When rendering with -na,b (manim only)
|
||||
# the animations not in [a,b] will be skipped,
|
||||
# but animation before a will have a None source file.
|
||||
files: List[Path] = list(filter(None, self.__partial_movie_files))
|
||||
|
||||
# We must filter slides that end before the animation offset
|
||||
if offset := self.__start_at_animation_number:
|
||||
self.__slides = [
|
||||
slide for slide in self.__slides if slide.end_animation > offset
|
||||
]
|
||||
|
||||
for slide in self.__slides:
|
||||
slide.start_animation -= offset
|
||||
slide.start_animation = max(0, slide.start_animation - offset)
|
||||
slide.end_animation -= offset
|
||||
|
||||
logger.info(
|
||||
f"Copied {len(files)} animations to '{os.path.abspath(scene_files_folder)}' and generated reversed animations"
|
||||
)
|
||||
slides: List[SlideConfig] = []
|
||||
|
||||
slide_path = os.path.join(self.__output_folder, "%s.json" % (scene_name,))
|
||||
for pre_slide_config in tqdm(
|
||||
self.__slides,
|
||||
desc=f"Concatenating 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,
|
||||
):
|
||||
slide_files = files[pre_slide_config.slides_slice]
|
||||
|
||||
with open(slide_path, "w") as f:
|
||||
f.write(
|
||||
PresentationConfig(
|
||||
slides=self.__slides, files=files, resolution=self.__resolution
|
||||
).json(indent=2)
|
||||
file = merge_basenames(slide_files)
|
||||
dst_file = scene_files_folder / file.name
|
||||
rev_file = scene_files_folder / f"{file.stem}_reversed{file.suffix}"
|
||||
|
||||
# We only concat animations if it was not present
|
||||
if not use_cache or not dst_file.exists():
|
||||
concatenate_video_files(slide_files, dst_file)
|
||||
|
||||
# We only reverse video if it was not present
|
||||
if not use_cache or not rev_file.exists():
|
||||
reverse_video_file(dst_file, rev_file)
|
||||
|
||||
slides.append(
|
||||
SlideConfig.from_pre_slide_config_and_files(
|
||||
pre_slide_config, dst_file, rev_file
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Slide '{scene_name}' configuration written in '{os.path.abspath(slide_path)}'"
|
||||
f"Generated {len(slides)} slides to '{scene_files_folder.absolute()}'"
|
||||
)
|
||||
|
||||
slide_path = self.__output_folder / f"{scene_name}.json"
|
||||
|
||||
PresentationConfig(
|
||||
slides=slides,
|
||||
resolution=self.__resolution,
|
||||
background_color=self.__background_color,
|
||||
).to_file(slide_path)
|
||||
|
||||
logger.info(
|
||||
f"Slide '{scene_name}' configuration written in '{slide_path.absolute()}'"
|
||||
)
|
||||
|
||||
def run(self, *args: Any, **kwargs: Any) -> None:
|
||||
@ -346,12 +539,185 @@ class Slide(Scene): # type:ignore
|
||||
|
||||
self.__save_slides()
|
||||
|
||||
def wipe(
|
||||
self,
|
||||
current: Sequence[Mobject] = [],
|
||||
future: Sequence[Mobject] = [],
|
||||
direction: np.ndarray = LEFT,
|
||||
fade_in_kwargs: Mapping[str, Any] = {},
|
||||
fade_out_kwargs: Mapping[str, Any] = {},
|
||||
**kwargs: Any,
|
||||
) -> AnimationGroup:
|
||||
"""
|
||||
Returns a wipe animation that will shift all the current objects outside
|
||||
of the current scene's scope, and all the future objects inside.
|
||||
|
||||
:param current: A sequence of mobjects to remove from the scene.
|
||||
:param future: A sequence of mobjects to add to the scene.
|
||||
:param direction: The wipe direction.
|
||||
:param fade_in_kwargs: Keyword arguments passed to
|
||||
:class:`FadeIn<manim.animation.fading.FadeIn>`.
|
||||
:param fade_out_kwargs: Keyword arguments passed to
|
||||
:class:`FadeOut<manim.animation.fading.FadeOut>`.
|
||||
:param kwargs: Keyword arguments passed to
|
||||
:class:`AnimationGroup<manim.animation.composition.AnimationGroup>`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
.. manim-slides:: WipeExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class WipeExample(Slide):
|
||||
def construct(self):
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
square = Square()
|
||||
text = Text("This is a wipe example").next_to(square, DOWN)
|
||||
beautiful = Text("Beautiful, no?")
|
||||
|
||||
self.play(FadeIn(circle))
|
||||
self.next_slide()
|
||||
|
||||
self.play(self.wipe(circle, Group(square, text)))
|
||||
self.next_slide()
|
||||
|
||||
self.play(self.wipe(Group(square, text), beautiful, direction=UP))
|
||||
self.next_slide()
|
||||
|
||||
self.play(self.wipe(beautiful, circle, direction=DOWN + RIGHT))
|
||||
"""
|
||||
shift_amount = np.asarray(direction) * np.array(
|
||||
[self.__frame_width, self.__frame_height, 0.0]
|
||||
)
|
||||
|
||||
animations = []
|
||||
|
||||
for mobject in future:
|
||||
animations.append(FadeIn(mobject, shift=shift_amount, **fade_in_kwargs))
|
||||
|
||||
for mobject in current:
|
||||
animations.append(FadeOut(mobject, shift=shift_amount, **fade_out_kwargs))
|
||||
|
||||
return AnimationGroup(*animations, **kwargs)
|
||||
|
||||
def zoom(
|
||||
self,
|
||||
current: Sequence[Mobject] = [],
|
||||
future: Sequence[Mobject] = [],
|
||||
scale: float = 4.0,
|
||||
out: bool = False,
|
||||
fade_in_kwargs: Mapping[str, Any] = {},
|
||||
fade_out_kwargs: Mapping[str, Any] = {},
|
||||
**kwargs: Any,
|
||||
) -> AnimationGroup:
|
||||
"""
|
||||
Returns a zoom animation that will fade out all the current objects,
|
||||
and fade in all the future objects. Objects are faded in a direction
|
||||
that goes towards the camera.
|
||||
|
||||
:param current: A sequence of mobjects to remove from the scene.
|
||||
:param future: A sequence of mobjects to add to the scene.
|
||||
:param scale: How much the objects are scaled (up or down).
|
||||
:param out: If set, the objects fade in the opposite direction.
|
||||
:param fade_in_kwargs: Keyword arguments passed to
|
||||
:class:`FadeIn<manim.animation.fading.FadeIn>`.
|
||||
:param fade_out_kwargs: Keyword arguments passed to
|
||||
:class:`FadeOut<manim.animation.fading.FadeOut>`.
|
||||
:param kwargs: Keyword arguments passed to
|
||||
:class:`AnimationGroup<manim.animation.composition.AnimationGroup>`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
.. manim-slides:: ZoomExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class ZoomExample(Slide):
|
||||
def construct(self):
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
square = Square()
|
||||
|
||||
self.play(FadeIn(circle))
|
||||
self.next_slide()
|
||||
|
||||
self.play(self.zoom(circle, square))
|
||||
self.next_slide()
|
||||
|
||||
self.play(self.zoom(square, circle, out=True, scale=10.))
|
||||
"""
|
||||
scale_in = 1.0 / scale
|
||||
scale_out = scale
|
||||
|
||||
if out:
|
||||
scale_in, scale_out = scale_out, scale_in
|
||||
|
||||
animations = []
|
||||
|
||||
for mobject in future:
|
||||
animations.append(FadeIn(mobject, scale=scale_in, **fade_in_kwargs))
|
||||
|
||||
for mobject in current:
|
||||
animations.append(FadeOut(mobject, scale=scale_out, **fade_out_kwargs))
|
||||
|
||||
return AnimationGroup(*animations, **kwargs)
|
||||
|
||||
|
||||
class ThreeDSlide(Slide, ThreeDScene): # type: ignore
|
||||
"""
|
||||
Inherits from :class:`Slide` and :class:`manim.scene.three_d_scene.ThreeDScene` or :class:`manimlib.scene.three_d_scene.ThreeDScene` and provide necessary tools for slides rendering.
|
||||
Inherits from :class:`Slide` and :class:`ThreeDScene<manim.scene.three_d_scene.ThreeDScene>` and provide necessary tools for slides rendering.
|
||||
|
||||
.. note:: ManimGL does not need ThreeDScene for 3D rendering in recent versions, see `example.py`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
.. manim-slides:: ThreeDExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import ThreeDSlide
|
||||
|
||||
class ThreeDExample(ThreeDSlide):
|
||||
def construct(self):
|
||||
title = Text("A 2D Text")
|
||||
|
||||
self.play(FadeIn(title))
|
||||
self.next_slide()
|
||||
|
||||
sphere = Sphere([0, 0, -3])
|
||||
|
||||
self.move_camera(phi=PI/3, theta=-PI/4, distance=7)
|
||||
self.play(
|
||||
GrowFromCenter(sphere),
|
||||
Transform(title, Text("A 3D Text"))
|
||||
)
|
||||
self.next_slide()
|
||||
|
||||
bye = Text("Bye!")
|
||||
|
||||
self.start_loop()
|
||||
self.play(
|
||||
self.wipe(
|
||||
self.mobjects_without_canvas,
|
||||
[bye],
|
||||
direction=UP
|
||||
)
|
||||
)
|
||||
self.wait(.5)
|
||||
self.play(
|
||||
self.wipe(
|
||||
self.mobjects_without_canvas,
|
||||
[title, sphere],
|
||||
direction=DOWN
|
||||
)
|
||||
)
|
||||
self.wait(.5)
|
||||
self.end_loop()
|
||||
|
||||
self.play(*[FadeOut(mobject) for mobject in self.mobjects])
|
||||
"""
|
||||
|
||||
pass
|
||||
|
80
manim_slides/utils.py
Normal file
@ -0,0 +1,80 @@
|
||||
import hashlib
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from .manim import FFMPEG_BIN, logger
|
||||
|
||||
|
||||
def concatenate_video_files(files: List[Path], dest: Path) -> None:
|
||||
"""
|
||||
Concatenate multiple video files into one.
|
||||
"""
|
||||
|
||||
f = tempfile.NamedTemporaryFile(mode="w", delete=False)
|
||||
f.writelines(f"file '{path.absolute()}'\n" for path in files)
|
||||
f.close()
|
||||
|
||||
command: List[str] = [
|
||||
str(FFMPEG_BIN),
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
f.name,
|
||||
"-c",
|
||||
"copy",
|
||||
str(dest),
|
||||
"-y",
|
||||
]
|
||||
logger.debug(" ".join(command))
|
||||
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
output, error = process.communicate()
|
||||
|
||||
if output:
|
||||
logger.debug(output.decode())
|
||||
|
||||
if error:
|
||||
logger.debug(error.decode())
|
||||
|
||||
if not dest.exists():
|
||||
raise ValueError(
|
||||
"could not properly concatenate files, use `-v DEBUG` for more details"
|
||||
)
|
||||
|
||||
|
||||
def merge_basenames(files: List[Path]) -> Path:
|
||||
"""
|
||||
Merge multiple filenames by concatenating basenames.
|
||||
"""
|
||||
|
||||
dirname: Path = files[0].parent
|
||||
ext = files[0].suffix
|
||||
|
||||
basenames = list(file.stem for file in files)
|
||||
|
||||
basenames_str = ",".join(f"{len(b)}:{b}" for b in basenames)
|
||||
|
||||
# We use hashes to prevent too-long filenames, see issue #123:
|
||||
# https://github.com/jeertmans/manim-slides/issues/123
|
||||
basename = hashlib.sha256(basenames_str.encode()).hexdigest()
|
||||
|
||||
logger.debug(f"Generated a new basename for basenames: {basenames} -> '{basename}'")
|
||||
|
||||
return dirname.joinpath(basename + ext)
|
||||
|
||||
|
||||
def reverse_video_file(src: Path, dst: Path) -> None:
|
||||
"""Reverses a video file, writting the result to `dst`."""
|
||||
command = [str(FFMPEG_BIN), "-y", "-i", str(src), "-vf", "reverse", str(dst)]
|
||||
logger.debug(" ".join(command))
|
||||
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
output, error = process.communicate()
|
||||
|
||||
if output:
|
||||
logger.debug(output.decode())
|
||||
|
||||
if error:
|
||||
logger.debug(error.decode())
|
@ -1,6 +1,6 @@
|
||||
import os
|
||||
import sys
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
@ -68,11 +68,11 @@ class Wizard(QWidget): # type: ignore
|
||||
|
||||
self.layout = QGridLayout()
|
||||
|
||||
for i, (key, value) in enumerate(self.config.dict().items()):
|
||||
for i, (key, value) in enumerate(self.config.keys.dict().items()):
|
||||
# Create label for key name information
|
||||
label = QLabel()
|
||||
key_info = value["name"] or key
|
||||
label.setText(key_info)
|
||||
label.setText(key_info.title())
|
||||
self.layout.addWidget(label, i, 0)
|
||||
|
||||
# Create button that will pop-up a dialog and ask to input a new key
|
||||
@ -83,7 +83,7 @@ class Wizard(QWidget): # type: ignore
|
||||
)
|
||||
self.buttons.append(button)
|
||||
button.clicked.connect(
|
||||
partial(self.openDialog, i, getattr(self.config, key))
|
||||
partial(self.openDialog, i, getattr(self.config.keys, key))
|
||||
)
|
||||
self.layout.addWidget(button, i, 1)
|
||||
|
||||
@ -102,7 +102,7 @@ class Wizard(QWidget): # type: ignore
|
||||
|
||||
def saveConfig(self) -> None:
|
||||
try:
|
||||
Config.parse_obj(self.config.dict())
|
||||
Config.model_validate(self.config.dict())
|
||||
except ValueError:
|
||||
msg = QMessageBox()
|
||||
msg.setIcon(QMessageBox.Critical)
|
||||
@ -130,7 +130,7 @@ class Wizard(QWidget): # type: ignore
|
||||
@config_options
|
||||
@click.help_option("-h", "--help")
|
||||
@verbosity_option
|
||||
def wizard(config_path: str, force: bool, merge: bool) -> None:
|
||||
def wizard(config_path: Path, force: bool, merge: bool) -> None:
|
||||
"""Launch configuration wizard."""
|
||||
return _init(config_path, force, merge, skip_interactive=False)
|
||||
|
||||
@ -140,18 +140,18 @@ def wizard(config_path: str, force: bool, merge: bool) -> None:
|
||||
@click.help_option("-h", "--help")
|
||||
@verbosity_option
|
||||
def init(
|
||||
config_path: str, force: bool, merge: bool, skip_interactive: bool = False
|
||||
config_path: Path, force: bool, merge: bool, skip_interactive: bool = False
|
||||
) -> None:
|
||||
"""Initialize a new default configuration file."""
|
||||
return _init(config_path, force, merge, skip_interactive=True)
|
||||
|
||||
|
||||
def _init(
|
||||
config_path: str, force: bool, merge: bool, skip_interactive: bool = False
|
||||
config_path: Path, force: bool, merge: bool, skip_interactive: bool = False
|
||||
) -> None:
|
||||
"""Actual initialization code for configuration file, with optional interactive mode."""
|
||||
|
||||
if os.path.exists(config_path):
|
||||
if config_path.exists():
|
||||
click.secho(f"The `{CONFIG_PATH}` configuration file exists")
|
||||
|
||||
if not force and not merge:
|
||||
@ -175,8 +175,8 @@ def _init(
|
||||
logger.debug("Merging new config into `{config_path}`")
|
||||
|
||||
if not skip_interactive:
|
||||
if os.path.exists(config_path):
|
||||
config = Config.parse_file(config_path)
|
||||
if config_path.exists():
|
||||
config = Config.from_file(config_path)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Manim Slides Wizard")
|
||||
@ -187,9 +187,8 @@ def _init(
|
||||
config = window.config
|
||||
|
||||
if merge:
|
||||
config = Config.parse_file(config_path).merge_with(config)
|
||||
config = Config.from_file(config_path).merge_with(config)
|
||||
|
||||
with open(config_path, "w") as config_file:
|
||||
config_file.write(config.json(indent=2))
|
||||
config.to_file(config_path)
|
||||
|
||||
click.secho(f"Configuration file successfully saved to `{config_path}`")
|
||||
|
BIN
paper/docs.png
Before Width: | Height: | Size: 4.3 MiB After Width: | Height: | Size: 158 KiB |
@ -59,18 +59,23 @@ evolved very little since its inception and does not work with ManimGL.
|
||||
In 2022, Manim Slides has been created from manim-presentation, with the aim
|
||||
to make it a more complete tool, better documented, and usable on all platforms
|
||||
and with ManimCE or ManimGL. After almost a year of existence, Manim Slides has
|
||||
evolved a lot, has built a small community of contributors, and continues to
|
||||
evolved a lot (see
|
||||
[comparison section](#comparison-with-manim-presentation)),
|
||||
has built a small community of contributors, and continues to
|
||||
provide new features on a regular basis.
|
||||
|
||||
# Easy to Use Commitment
|
||||
|
||||
Manim Slides is commited to be an easy-to-use tool, when minimal installation
|
||||
Manim Slides is commited to be an easy-to-use tool, with minimal installation
|
||||
procedure and few modifications required. It can either be used locally with its
|
||||
graphical user interface (GUI), or shared via HTML thanks to the RevealJS
|
||||
Javascript package [@revealjs].
|
||||
graphical user interface (GUI), or shared via one of the two formats it can
|
||||
convert to:
|
||||
|
||||
* an HTML page thanks to the RevealJS Javascript package [@revealjs];
|
||||
* or a PowerPoint (`.pptx`) file.
|
||||
|
||||
This work has a very similar syntax to Manim and offers a comprehensive
|
||||
documentation hosted on [GitHub pages](https://eertmans.be/manim-slides/), see
|
||||
documentation hosted on [GitHub pages](https://jeertmans.github.io/manim-slides/), see
|
||||
\autoref{fig:docs}.
|
||||
|
||||

|
||||
@ -79,10 +84,10 @@ documentation hosted on [GitHub pages](https://eertmans.be/manim-slides/), see
|
||||
|
||||
We have used manim-presentation for our presentation at the COST
|
||||
Interact, hosted in Lyon, 2022, and
|
||||
[available online](https://eertmans.be/research/cost-interact-presentation/).
|
||||
[available online](https://web.archive.org/web/20230507184944/https://eertmans.be/posts/cost-interact-presentation/).
|
||||
This experience highly motivated the development of Manim Slides, and our
|
||||
EuCAP 2023 presentation slides are already
|
||||
[available online](https://eertmans.be/research/eucap-presentation/), thanks
|
||||
[available online](https://web.archive.org/web/20230507211243/https://eertmans.be/posts/eucap-presentation/), thanks
|
||||
to Manim Slides' HTML feature.
|
||||
|
||||
Also, one of our users created a short
|
||||
@ -92,8 +97,8 @@ and posted it on YouTube.
|
||||
# Stability and releases
|
||||
|
||||
Manim Slides is continously tested on most recent Python versions, both ManimCE
|
||||
and ManimGL, and on all major platforms: **Ubuntu**, **macOS** and **Windows**. As of Manim
|
||||
Slide's exposed API begin very minimal, and the variaty of tests that are
|
||||
and ManimGL, and on all major platforms: **Ubuntu**, **macOS** and **Windows**. Due to Manim
|
||||
Slide's exposed API being very minimal, and the variety of tests that are
|
||||
performed, this tool can be considered stable over time.
|
||||
|
||||
New releases are very frequent, as they mostly introduce enhancements or small
|
||||
@ -111,12 +116,56 @@ presenting Manim content in front of an audience much easier than before,
|
||||
allowing presenters to focus more on the content of their slides, rather than on
|
||||
how to actually present them efficiently.
|
||||
|
||||
## Target Audience
|
||||
|
||||
Manim Slides was developed with the goal of making educational content more
|
||||
accessible than ever. We believe that researchers, professors, teaching
|
||||
assistants and anyone else who needs to teach scientific content can benefit
|
||||
from using this tool. The ability to pace your presentation yourself is
|
||||
essential, and Manim Slides gives you that ability.
|
||||
|
||||
## A Need for Portability
|
||||
|
||||
One of the major concerns with presenting content in a non-standard format
|
||||
(i.e., not just a plain PDF) is the issue of portability.
|
||||
Depending on the programs available, the power of the target computer,
|
||||
or the access to the internet, not all solutions are equal.
|
||||
From the same configuration file, Manim Slides offers a series of solutions to
|
||||
share your slides, which we discuss on our
|
||||
[Sharing your slides](https://jeertmans.github.io/manim-slides/reference/sharing.html)
|
||||
page.
|
||||
|
||||
## Comparison with manim-presentation
|
||||
|
||||
Starting from @manim-presentation's original work, Manim Slides now provides
|
||||
numerous additional features.
|
||||
A non-exhaustive list of those new features is as follows:
|
||||
|
||||
* ManimGL compatibility;
|
||||
* playing slides in reverse;
|
||||
* exporting slides to HTML and PowerPoint;
|
||||
* 3D scene support;
|
||||
* multiple key inputs can map to the same action
|
||||
(e.g., useful when using a pointer);
|
||||
* optionally hiding mouse cursor when presenting;
|
||||
* recording your presentation;
|
||||
* multiple video scaling methods (for speed-vs-quality tradeoff);
|
||||
* and automatic detection of some scene parameters
|
||||
(e.g., resolution or background color).
|
||||
|
||||
The complete and up-to-date set of features Manim Slide supports is
|
||||
available in the
|
||||
[online documentation](https://jeertmans.github.io/manim-slides/).
|
||||
For new feature requests, we highly encourage users to
|
||||
[create an issue](https://github.com/jeertmans/manim-slides/issues/new/choose)
|
||||
with the appropriate template.
|
||||
|
||||
# Acknowledgements
|
||||
|
||||
We acknowledge the work of [@manim-presentation] that paved the initial structure
|
||||
We acknowledge the work of @manim-presentation that paved the initial structure
|
||||
of Manim Slides with the manim-presentation Python package.
|
||||
|
||||
We also acknowledge Grant Sanderson for its termendous work on Manim, as well as
|
||||
We also acknowledge Grant Sanderson for his tremendous work on Manim, as
|
||||
well as the Manim Community contributors.
|
||||
|
||||
Finally, we also acknowledge contributions from the GitHub contributors on the
|
||||
|
5987
poetry.lock
generated
@ -43,22 +43,40 @@ packages = [
|
||||
]
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/jeertmans/manim-slides"
|
||||
version = "4.11.0"
|
||||
version = "5.0.0-rc1"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
click = "^8.1.3"
|
||||
click-default-group = "^1.2.2"
|
||||
docutils = {version = "^0.20.1", optional = true}
|
||||
ipython = {version = ">=8.12.2", optional = true}
|
||||
jinja2 = {version = "^3.1.2", optional = true}
|
||||
lxml = "^4.9.2"
|
||||
manim = {version = "^0.17.3", optional = true}
|
||||
manimgl = {version = "^1.6.1", optional = true}
|
||||
notebook = {version = "^7.0.2", optional = true}
|
||||
numpy = "^1.19"
|
||||
opencv-python = "^4.6.0.66"
|
||||
pydantic = "^1.10.2"
|
||||
pyside6 = "^6.4.1"
|
||||
pillow = "^9.5.0"
|
||||
pydantic = "^2.0.1"
|
||||
pydantic-extra-types = "^2.0.0"
|
||||
pyside6 = "^6.5.1.1"
|
||||
python = ">=3.8.1,<3.12"
|
||||
python-pptx = "^0.6.21"
|
||||
requests = "^2.28.1"
|
||||
rich = "^13.3.2"
|
||||
rtoml = "^0.9.0"
|
||||
tqdm = "^4.64.1"
|
||||
|
||||
[tool.poetry.extras]
|
||||
magic = ["manim", "ipython"]
|
||||
manim = ["manim"]
|
||||
manimgl = ["manimgl"]
|
||||
sphinx-directive = ["docutils", "jinja2", "manim"]
|
||||
|
||||
[tool.poetry.group.dev]
|
||||
optional = true
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = "^22.10.0"
|
||||
bump2version = "^1.0.1"
|
||||
@ -67,24 +85,46 @@ mypy = "^0.991"
|
||||
pre-commit = "^3.0.2"
|
||||
ruff = "^0.0.219"
|
||||
|
||||
[tool.poetry.group.docs]
|
||||
optional = true
|
||||
|
||||
[tool.poetry.group.docs.dependencies]
|
||||
furo = "^2022.9.29"
|
||||
manim = "^0.17.0"
|
||||
myst-parser = "^0.18.1"
|
||||
sphinx = "^5.3.0"
|
||||
furo = "^2023.5.20"
|
||||
ipykernel = "^6.25.1"
|
||||
manim = "^0.17.3"
|
||||
myst-parser = "^2.0.0"
|
||||
nbsphinx = "^0.9.2"
|
||||
pandoc = "^2.3"
|
||||
sphinx = "^7.0.1"
|
||||
sphinx-click = "^4.4.0"
|
||||
sphinx-copybutton = "^0.5.1"
|
||||
sphinxext-opengraph = "^0.7.5"
|
||||
|
||||
[tool.poetry.group.test]
|
||||
optional = true
|
||||
|
||||
[tool.poetry.group.test.dependencies]
|
||||
manim = "^0.17.0"
|
||||
manim = "^0.17.3"
|
||||
manimgl = "^1.6.1"
|
||||
pytest = "^7.4.0"
|
||||
pytest-cov = "^4.1.0"
|
||||
pytest-env = "^0.8.2"
|
||||
pytest-xdist = "^3.3.1"
|
||||
|
||||
[tool.poetry.plugins]
|
||||
|
||||
[tool.poetry.plugins."console_scripts"]
|
||||
manim-slides = "manim_slides.__main__:cli"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
env = [
|
||||
"QT_QPA_PLATFORM=offscreen"
|
||||
]
|
||||
filterwarnings = [
|
||||
"error",
|
||||
"ignore::DeprecationWarning"
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
ignore = [
|
||||
"E501"
|
||||
|
BIN
static/docs.png
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 178 KiB |
Before Width: | Height: | Size: 670 KiB After Width: | Height: | Size: 485 KiB |
BIN
static/icon.png
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.8 KiB |
BIN
static/logo.png
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 90 KiB |
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 86 KiB |
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 86 KiB |
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 110 KiB |
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 102 KiB |
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 29 KiB |
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 24 KiB |
62
tests/conftest.py
Normal file
@ -0,0 +1,62 @@
|
||||
import random
|
||||
import string
|
||||
from pathlib import Path
|
||||
from typing import Generator, Iterator, List
|
||||
|
||||
import pytest
|
||||
|
||||
from manim_slides.config import PresentationConfig
|
||||
from manim_slides.logger import make_logger
|
||||
|
||||
_ = make_logger() # This is run so that logger is created
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data_folder() -> Iterator[Path]:
|
||||
path = (Path(__file__).parent / "data").resolve()
|
||||
assert path.exists()
|
||||
yield path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slides_folder(data_folder: Path) -> Iterator[Path]:
|
||||
path = (data_folder / "slides").resolve()
|
||||
assert path.exists()
|
||||
yield path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slides_file(data_folder: Path) -> Iterator[Path]:
|
||||
path = (data_folder / "slides.py").resolve()
|
||||
assert path.exists()
|
||||
yield path
|
||||
|
||||
|
||||
def random_path(
|
||||
length: int = 20,
|
||||
dirname: Path = Path("./media/videos/example"),
|
||||
suffix: str = ".mp4",
|
||||
touch: bool = False,
|
||||
) -> Path:
|
||||
basename = "".join(random.choices(string.ascii_letters, k=length))
|
||||
|
||||
filepath = dirname.joinpath(basename + suffix)
|
||||
|
||||
if touch:
|
||||
filepath.touch()
|
||||
|
||||
return filepath
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def paths() -> Generator[List[Path], None, None]:
|
||||
random.seed(1234)
|
||||
|
||||
yield [random_path() for _ in range(20)]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def presentation_config(
|
||||
slides_folder: Path,
|
||||
) -> Generator[PresentationConfig, None, None]:
|
||||
yield PresentationConfig.from_file(slides_folder / "BasicSlide.json")
|
24
tests/data/slides.py
Normal file
@ -0,0 +1,24 @@
|
||||
# flake8: noqa: F403, F405
|
||||
# type: ignore
|
||||
from manim import *
|
||||
|
||||
from manim_slides import Slide
|
||||
|
||||
|
||||
class BasicSlide(Slide):
|
||||
def construct(self):
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
dot = Dot()
|
||||
|
||||
self.play(GrowFromCenter(circle))
|
||||
self.next_slide()
|
||||
|
||||
self.start_loop()
|
||||
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
|
||||
self.wait(2.0)
|
||||
self.end_loop()
|
||||
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
self.next_slide()
|
||||
|
||||
self.play(self.wipe(Group(dot, circle), []))
|
29
tests/data/slides/BasicSlide.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"slides": [
|
||||
{
|
||||
"file": "slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4.mp4",
|
||||
"rev_file": "slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4_reversed.mp4",
|
||||
"loop": false
|
||||
},
|
||||
{
|
||||
"file": "slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da.mp4",
|
||||
"rev_file": "slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da_reversed.mp4",
|
||||
"loop": true
|
||||
},
|
||||
{
|
||||
"file": "slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810.mp4",
|
||||
"rev_file": "slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810_reversed.mp4",
|
||||
"loop": false
|
||||
},
|
||||
{
|
||||
"file": "slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9.mp4",
|
||||
"rev_file": "slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9_reversed.mp4",
|
||||
"loop": false
|
||||
}
|
||||
],
|
||||
"resolution": [
|
||||
854,
|
||||
480
|
||||
],
|
||||
"background_color": "black"
|
||||
}
|
142
tests/test_commons.py
Normal file
@ -0,0 +1,142 @@
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from manim_slides.commons import (
|
||||
config_options,
|
||||
config_path_option,
|
||||
folder_path_option,
|
||||
verbosity_option,
|
||||
)
|
||||
|
||||
|
||||
def test_config_options() -> None:
|
||||
@click.command()
|
||||
@config_options
|
||||
def main(config_path: Path, force: bool, merge: bool) -> None:
|
||||
pass
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
with open("config.json", "w") as f:
|
||||
f.write("Hello world!")
|
||||
|
||||
result = runner.invoke(main, ["--config", "config.json", "--force", "--merge"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = runner.invoke(main, ["-c", "config.json", "-f", "-m"])
|
||||
|
||||
|
||||
def test_config_path_option() -> None:
|
||||
@click.command()
|
||||
@config_path_option
|
||||
def main(config_path: Path) -> None:
|
||||
pass
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem() as temp_dir:
|
||||
with open("config.json", "w") as f:
|
||||
f.write("Hello world!")
|
||||
|
||||
result = runner.invoke(main, ["--config", "config.json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = runner.invoke(main, ["-c", "config.json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = runner.invoke(main, ["--config", "unexisting.json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = runner.invoke(main, ["--config", "unexisting"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = runner.invoke(main, ["--config", temp_dir])
|
||||
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
def test_folder_path_option() -> None:
|
||||
@click.command()
|
||||
@folder_path_option
|
||||
def main(folder: Path) -> None:
|
||||
pass
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem() as temp_dir:
|
||||
with open("file.txt", "w") as f:
|
||||
f.write("Hello world!")
|
||||
|
||||
result = runner.invoke(main, ["--folder", "file.txt"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
|
||||
result = runner.invoke(main, ["--folder", "unexisting.txt"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
|
||||
result = runner.invoke(main, ["--folder", "unexisting"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
|
||||
result = runner.invoke(main, ["--folder", temp_dir])
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("verbosity",),
|
||||
[("DEBUG",), ("info",), ("waRNING",), ("eRRor",), ("CrItIcAl",)],
|
||||
)
|
||||
def test_valid_verbosity_option(verbosity: str) -> None:
|
||||
@click.command()
|
||||
@verbosity_option
|
||||
def main() -> None:
|
||||
pass
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["-v", verbosity])
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
result = runner.invoke(main, ["--verbosity", verbosity])
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
with runner.isolation(env={"MANIM_SLIDES_VERBOSITY": verbosity}):
|
||||
result = runner.invoke(main)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("verbosity",), [("test",), ("deebug",), ("warn",), ("errors",)]
|
||||
)
|
||||
def test_invalid_verbosity_option(verbosity: str) -> None:
|
||||
@click.command()
|
||||
@verbosity_option
|
||||
def main() -> None:
|
||||
pass
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["-v", verbosity])
|
||||
|
||||
assert result.exit_code != 0
|
||||
|
||||
result = runner.invoke(main, ["--verbosity", verbosity])
|
||||
|
||||
assert result.exit_code != 0
|
||||
|
||||
with runner.isolation(env={"MANIM_SLIDES_VERBOSITY": verbosity}):
|
||||
result = runner.invoke(main)
|
||||
|
||||
assert result.exit_code != 0
|
32
tests/test_config.py
Normal file
@ -0,0 +1,32 @@
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from manim_slides.config import Key, PresentationConfig
|
||||
|
||||
|
||||
class TestKey:
|
||||
@pytest.mark.parametrize(("ids", "name"), [([1], None), ([1], "some key name")])
|
||||
def test_valid_keys(self, ids: Any, name: Any) -> None:
|
||||
_ = Key(ids=ids, name=name)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("ids", "name"), [([], None), ([-1], None), ([1], {"an": " invalid name"})]
|
||||
)
|
||||
def test_invalid_keys(self, ids: Any, name: Any) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
_ = Key(ids=ids, name=name)
|
||||
|
||||
|
||||
class TestPresentationConfig:
|
||||
def test_validate(self, presentation_config: PresentationConfig) -> None:
|
||||
obj = presentation_config.model_dump()
|
||||
_ = PresentationConfig.model_validate(obj)
|
||||
|
||||
def test_bump_to_json(self, presentation_config: PresentationConfig) -> None:
|
||||
_ = presentation_config.model_dump_json(indent=2)
|
||||
|
||||
def test_empty_presentation_config(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
_ = PresentationConfig(slides=[], files=[])
|
11
tests/test_convert.py
Normal file
@ -0,0 +1,11 @@
|
||||
import pytest
|
||||
|
||||
from manim_slides.convert import PDF, Converter, PowerPoint, RevealJS
|
||||
|
||||
|
||||
class TestConverter:
|
||||
@pytest.mark.parametrize(
|
||||
("name", "converter"), [("html", RevealJS), ("pdf", PDF), ("pptx", PowerPoint)]
|
||||
)
|
||||
def test_from_string(self, name: str, converter: type) -> None:
|
||||
assert Converter.from_string(name) == converter
|
15
tests/test_defaults.py
Normal file
@ -0,0 +1,15 @@
|
||||
from pathlib import Path
|
||||
|
||||
from manim_slides.defaults import CONFIG_PATH, FFMPEG_BIN, FOLDER_PATH
|
||||
|
||||
|
||||
def test_folder_path() -> None:
|
||||
assert FOLDER_PATH == Path("./slides")
|
||||
|
||||
|
||||
def test_config_path() -> None:
|
||||
assert CONFIG_PATH == Path(".manim-slides.toml")
|
||||
|
||||
|
||||
def test_ffmpeg_bin() -> None:
|
||||
assert FFMPEG_BIN == Path("ffmpeg")
|
93
tests/test_main.py
Normal file
@ -0,0 +1,93 @@
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from manim_slides.__main__ import cli
|
||||
|
||||
|
||||
def test_help() -> None:
|
||||
runner = CliRunner()
|
||||
results = runner.invoke(cli, ["-S", "--help"])
|
||||
|
||||
assert results.exit_code == 0
|
||||
|
||||
results = runner.invoke(cli, ["-S", "-h"])
|
||||
|
||||
assert results.exit_code == 0
|
||||
|
||||
|
||||
def test_defaults_to_present(slides_folder: Path) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
results = runner.invoke(
|
||||
cli, ["BasicSlide", "--folder", str(slides_folder), "-s"]
|
||||
)
|
||||
|
||||
assert results.exit_code == 0
|
||||
|
||||
|
||||
def test_present(slides_folder: Path) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
results = runner.invoke(
|
||||
cli, ["present", "BasicSlide", "--folder", str(slides_folder), "-s"]
|
||||
)
|
||||
|
||||
assert results.exit_code == 0
|
||||
|
||||
|
||||
def test_convert(slides_folder: Path) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
results = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"convert",
|
||||
"BasicSlide",
|
||||
"basic_example.html",
|
||||
"--folder",
|
||||
str(slides_folder),
|
||||
],
|
||||
)
|
||||
|
||||
assert results.exit_code == 0
|
||||
|
||||
|
||||
def test_init() -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
results = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"init",
|
||||
"--force",
|
||||
],
|
||||
)
|
||||
|
||||
assert results.exit_code == 0
|
||||
|
||||
|
||||
def test_list_scenes(slides_folder: Path) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
results = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"list-scenes",
|
||||
"--folder",
|
||||
str(slides_folder),
|
||||
],
|
||||
)
|
||||
|
||||
assert results.exit_code == 0
|
||||
assert "BasicSlide" in results.output
|
||||
|
||||
|
||||
def test_wizard() -> None:
|
||||
# TODO
|
||||
pass
|
143
tests/test_manim.py
Normal file
@ -0,0 +1,143 @@
|
||||
import importlib
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from importlib.abc import MetaPathFinder
|
||||
from importlib.machinery import ModuleSpec
|
||||
from types import ModuleType
|
||||
from typing import Iterator, Optional, Sequence
|
||||
|
||||
import pytest
|
||||
|
||||
import manim_slides.manim as msm
|
||||
|
||||
|
||||
@contextmanager
|
||||
def suppress_module_finder() -> Iterator[None]:
|
||||
meta_path = sys.meta_path
|
||||
try:
|
||||
|
||||
class PathFinder(MetaPathFinder):
|
||||
@classmethod
|
||||
def find_spec(
|
||||
cls,
|
||||
fullname: str,
|
||||
path: Optional[Sequence[str]],
|
||||
target: Optional[ModuleType] = None,
|
||||
) -> Optional[ModuleSpec]:
|
||||
if fullname in ["manim", "manimlib"]:
|
||||
return None
|
||||
|
||||
for finder in meta_path:
|
||||
spec = finder.find_spec(fullname, path, target=target)
|
||||
if spec is not None:
|
||||
return spec
|
||||
|
||||
return None
|
||||
|
||||
sys.meta_path = [PathFinder]
|
||||
yield
|
||||
finally:
|
||||
sys.meta_path = meta_path
|
||||
|
||||
|
||||
def assert_import(
|
||||
*,
|
||||
manim: bool,
|
||||
manim_available: bool,
|
||||
manim_imported: bool,
|
||||
manimgl: bool,
|
||||
manimgl_available: bool,
|
||||
manimgl_imported: bool,
|
||||
) -> None:
|
||||
importlib.reload(msm)
|
||||
|
||||
assert msm.MANIM == manim
|
||||
assert msm.MANIM_AVAILABLE == manim_available
|
||||
assert msm.MANIM_IMPORTED == manim_imported
|
||||
assert msm.MANIMGL == manimgl
|
||||
assert msm.MANIMGL_AVAILABLE == manim_available
|
||||
assert msm.MANIMGL_IMPORTED == manimgl_imported
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore:assert_import")
|
||||
def test_manim_and_manimgl_imported() -> None:
|
||||
import manim # noqa: F401
|
||||
import manimlib # noqa: F401
|
||||
|
||||
assert_import(
|
||||
manim=True,
|
||||
manim_available=True,
|
||||
manim_imported=True,
|
||||
manimgl=False,
|
||||
manimgl_available=True,
|
||||
manimgl_imported=True,
|
||||
)
|
||||
|
||||
|
||||
def test_manim_imported() -> None:
|
||||
import manim # noqa: F401
|
||||
|
||||
if "manimlib" in sys.modules:
|
||||
del sys.modules["manimlib"]
|
||||
|
||||
assert_import(
|
||||
manim=True,
|
||||
manim_available=True,
|
||||
manim_imported=True,
|
||||
manimgl=False,
|
||||
manimgl_available=True,
|
||||
manimgl_imported=False,
|
||||
)
|
||||
|
||||
|
||||
def test_manimgl_imported() -> None:
|
||||
import manimlib # noqa: F401
|
||||
|
||||
if "manim" in sys.modules:
|
||||
del sys.modules["manim"]
|
||||
|
||||
assert_import(
|
||||
manim=False,
|
||||
manim_available=True,
|
||||
manim_imported=False,
|
||||
manimgl=True,
|
||||
manimgl_available=True,
|
||||
manimgl_imported=True,
|
||||
)
|
||||
|
||||
|
||||
def test_nothing_imported() -> None:
|
||||
if "manim" in sys.modules:
|
||||
del sys.modules["manim"]
|
||||
|
||||
if "manimlib" in sys.modules:
|
||||
del sys.modules["manimlib"]
|
||||
|
||||
assert_import(
|
||||
manim=True,
|
||||
manim_available=True,
|
||||
manim_imported=False,
|
||||
manimgl=False,
|
||||
manimgl_available=True,
|
||||
manimgl_imported=False,
|
||||
)
|
||||
|
||||
|
||||
def test_no_package_available() -> None:
|
||||
with suppress_module_finder():
|
||||
if "manim" in sys.modules:
|
||||
del sys.modules["manim"]
|
||||
|
||||
if "manimlib" in sys.modules:
|
||||
del sys.modules["manimlib"]
|
||||
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
# Actual values are not important
|
||||
assert_import(
|
||||
manim=False,
|
||||
manim_available=False,
|
||||
manim_imported=False,
|
||||
manimgl=False,
|
||||
manimgl_available=False,
|
||||
manimgl_imported=False,
|
||||
)
|
147
tests/test_slide.py
Normal file
@ -0,0 +1,147 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
from manim import Text
|
||||
from manim.__main__ import main as cli
|
||||
from pydantic import ValidationError
|
||||
|
||||
from manim_slides.config import PresentationConfig
|
||||
from manim_slides.slide import Slide
|
||||
|
||||
|
||||
def assert_construct(cls: type) -> type:
|
||||
class Wrapper:
|
||||
@classmethod
|
||||
def test_construct(_) -> None:
|
||||
cls().construct()
|
||||
|
||||
return Wrapper
|
||||
|
||||
|
||||
def test_render_basic_examples(
|
||||
slides_file: Path, presentation_config: PresentationConfig
|
||||
) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
results = runner.invoke(cli, [str(slides_file), "BasicSlide", "-ql"])
|
||||
|
||||
assert results.exit_code == 0
|
||||
|
||||
local_slides_folder = Path("slides")
|
||||
|
||||
assert local_slides_folder.exists()
|
||||
|
||||
local_config_file = local_slides_folder / "BasicSlide.json"
|
||||
|
||||
assert local_config_file.exists()
|
||||
|
||||
local_presentation_config = PresentationConfig.from_file(local_config_file)
|
||||
|
||||
assert len(local_presentation_config.slides) == len(presentation_config.slides)
|
||||
|
||||
assert (
|
||||
local_presentation_config.background_color
|
||||
== presentation_config.background_color
|
||||
)
|
||||
|
||||
assert (
|
||||
local_presentation_config.background_color
|
||||
== presentation_config.background_color
|
||||
)
|
||||
|
||||
assert local_presentation_config.resolution == presentation_config.resolution
|
||||
|
||||
|
||||
class TestSlide:
|
||||
@assert_construct
|
||||
class TestLoop(Slide):
|
||||
def construct(self) -> None:
|
||||
text = Text("Some text")
|
||||
|
||||
self.add(text)
|
||||
|
||||
self.start_loop()
|
||||
self.play(text.animate.scale(2))
|
||||
self.end_loop()
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
self.end_loop()
|
||||
|
||||
self.start_loop()
|
||||
with pytest.raises(AssertionError):
|
||||
self.start_loop()
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
self.end_loop()
|
||||
|
||||
@assert_construct
|
||||
class TestWipe(Slide):
|
||||
def construct(self) -> None:
|
||||
text = Text("Some text")
|
||||
bye = Text("Bye")
|
||||
|
||||
self.add(text)
|
||||
|
||||
assert text in self.mobjects
|
||||
assert bye not in self.mobjects
|
||||
|
||||
self.play(self.wipe([text], [bye]))
|
||||
|
||||
assert text not in self.mobjects
|
||||
assert bye in self.mobjects
|
||||
|
||||
@assert_construct
|
||||
class TestZoom(Slide):
|
||||
def construct(self) -> None:
|
||||
text = Text("Some text")
|
||||
bye = Text("Bye")
|
||||
|
||||
self.add(text)
|
||||
|
||||
assert text in self.mobjects
|
||||
assert bye not in self.mobjects
|
||||
|
||||
self.play(self.zoom([text], [bye]))
|
||||
|
||||
assert text not in self.mobjects
|
||||
assert bye in self.mobjects
|
||||
|
||||
@assert_construct
|
||||
class TestCanvas(Slide):
|
||||
def construct(self) -> None:
|
||||
text = Text("Some text")
|
||||
bye = Text("Bye")
|
||||
|
||||
assert len(self.canvas) == 0
|
||||
|
||||
self.add(text)
|
||||
|
||||
assert len(self.canvas) == 0
|
||||
|
||||
self.add_to_canvas(text=text)
|
||||
|
||||
assert len(self.canvas) == 1
|
||||
|
||||
self.add(bye)
|
||||
|
||||
assert len(self.canvas) == 1
|
||||
|
||||
assert text not in self.mobjects_without_canvas
|
||||
assert bye in self.mobjects_without_canvas
|
||||
|
||||
self.remove(text)
|
||||
|
||||
assert len(self.canvas) == 1
|
||||
|
||||
self.add_to_canvas(bye=bye)
|
||||
|
||||
assert len(self.canvas) == 2
|
||||
|
||||
self.remove_from_canvas("text", "bye")
|
||||
|
||||
assert len(self.canvas) == 0
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
self.remove_from_canvas("text")
|
23
tests/test_utils.py
Normal file
@ -0,0 +1,23 @@
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from manim_slides.utils import merge_basenames
|
||||
|
||||
|
||||
def test_merge_basenames(paths: List[Path]) -> None:
|
||||
path = merge_basenames(paths)
|
||||
assert path.suffix == paths[0].suffix
|
||||
assert path.parent == paths[0].parent
|
||||
|
||||
|
||||
def test_merge_basenames_same_with_different_parent_directories(
|
||||
paths: List[Path],
|
||||
) -> None:
|
||||
d1 = Path("a/b/c")
|
||||
d2 = Path("d/e/f")
|
||||
p1 = d1 / "one.txt"
|
||||
p2 = d1 / "a/b/c/two.txt"
|
||||
p3 = d2 / "d/e/f/one.txt"
|
||||
p4 = d2 / "d/e/f/two.txt"
|
||||
|
||||
assert merge_basenames([p1, p2]).name == merge_basenames([p3, p4]).name
|