mirror of
https://github.com/jeertmans/manim-slides.git
synced 2025-05-18 19:16:21 +08:00
refactor(lib): change how manim API is imported (#285)
* refactor(lib): change how manim API is imported * chore(lib): delete old files * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * wip: moving all commands * adding animations * fix tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix mypy * fixes * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * trying to fix docs * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * wip: docs * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * make it work * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * wip test * tests are working * improving docs * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix index * docs: nicer shift * docs: nicer quickstart example * fix tests * change tests * move coverage to test workflow * fix(tests): remove resolve * strict resolve * change local path test * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * documented changes * cleanup docs * cleanup files * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(ci): set type --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
48
.github/workflows/coverage.yml
vendored
48
.github/workflows/coverage.yml
vendored
@ -1,48 +0,0 @@
|
||||
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@v4
|
||||
|
||||
- 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
|
122
.github/workflows/tests.yml
vendored
122
.github/workflows/tests.yml
vendored
@ -9,73 +9,8 @@ jobs:
|
||||
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@v4
|
||||
|
||||
- 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
|
||||
matrix:
|
||||
manim: [manim, manimgl]
|
||||
os: [macos-latest, ubuntu-latest, windows-latest]
|
||||
pyversion: ['3.8', '3.9', '3.10', '3.11']
|
||||
exclude:
|
||||
# excludes manimgl on Windows because if throws errors
|
||||
# related to OpenGL, which seems hard to fix:
|
||||
# Your graphics drivers do not support OpenGL 2.0.
|
||||
- os: windows-latest
|
||||
manim: manimgl
|
||||
# We only test Python 3.11 on Windows and MacOS
|
||||
- os: windows-latest
|
||||
pyversion: '3.8'
|
||||
- os: windows-latest
|
||||
pyversion: '3.9'
|
||||
- os: windows-latest
|
||||
pyversion: '3.10'
|
||||
manim: manim
|
||||
- os: macos-latest
|
||||
pyversion: '3.8'
|
||||
- os: macos-latest
|
||||
pyversion: '3.9'
|
||||
- os: macos-latest
|
||||
pyversion: '3.10'
|
||||
manim: manim
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
QT_QPA_PLATFORM: offscreen
|
||||
@ -111,31 +46,15 @@ jobs:
|
||||
run: echo "${HOME}/.local/bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
|
||||
# OS depedencies
|
||||
- name: Install manim dependencies on MacOs
|
||||
if: matrix.os == 'macos-latest' && matrix.manim == 'manim'
|
||||
- name: Install manim dependencies on MacOS
|
||||
if: matrix.os == 'macos-latest'
|
||||
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'
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
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 &
|
||||
|
||||
@ -143,27 +62,22 @@ jobs:
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: choco install ffmpeg
|
||||
|
||||
# Install Manim Slides
|
||||
- name: Install Manim Slides
|
||||
run: |
|
||||
poetry install --extras ${{ matrix.manim }}
|
||||
poetry install --with test --all-extras
|
||||
|
||||
# Render slides
|
||||
- name: Render slides
|
||||
if: matrix.manim == 'manim'
|
||||
run: poetry run manim -ql example.py BasicExample ThreeDExample
|
||||
- name: Run pytest
|
||||
if: matrix.os != 'ubuntu-latest' && matrix.pyversion != '3.11'
|
||||
run: poetry run pytest -x -n auto
|
||||
|
||||
- name: Render slides
|
||||
if: matrix.manim == 'manimgl'
|
||||
run: poetry run -v manimgl -l example.py BasicExample ThreeDExample
|
||||
- name: Run pytest and coverage
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.pyversion == '3.11'
|
||||
run: poetry run pytest --cov-report xml --cov=manim_slides tests/
|
||||
|
||||
# Play slides
|
||||
- name: Test slides
|
||||
run: poetry run manim-slides BasicExample ThreeDExample --skip-all
|
||||
|
||||
# Test slides to html
|
||||
- name: Test convert on Ubuntu
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.manim == 'manim'
|
||||
run: |
|
||||
poetry run manim -ql example.py ConvertExample
|
||||
poetry run manim-slides convert --to=html ConvertExample index.html
|
||||
- name: Upload to codecov.io
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.pyversion == '3.11'
|
||||
uses: codecov/codecov-action@v3
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -8,6 +8,7 @@ __pycache__/
|
||||
# Manim files
|
||||
images/
|
||||
/media
|
||||
tests/data/media/
|
||||
docs/source/media/
|
||||
|
||||
# ManimGL files
|
||||
@ -44,3 +45,5 @@ paper/media/
|
||||
|
||||
# Others
|
||||
coverage.xml
|
||||
|
||||
rendering_times.csv
|
||||
|
@ -23,6 +23,18 @@ repos:
|
||||
rev: 23.9.1
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/adamchainz/blacken-docs
|
||||
rev: 1.16.0
|
||||
hooks:
|
||||
- id: blacken-docs
|
||||
additional_dependencies:
|
||||
- black==23.9.1
|
||||
- repo: https://github.com/PyCQA/docformatter
|
||||
rev: v1.7.5
|
||||
hooks:
|
||||
- id: docformatter
|
||||
additional_dependencies: [tomli]
|
||||
args: [--in-place, --config, ./pyproject.toml]
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.0.292
|
||||
hooks:
|
||||
|
24
CHANGELOG.md
24
CHANGELOG.md
@ -28,6 +28,17 @@ In an effort to better document changes, this CHANGELOG document is now created.
|
||||
- Added support for including code from a file in Manim Slides
|
||||
Sphinx directive.
|
||||
[#261](https://github.com/jeertmans/manim-slides/pull/261)
|
||||
- Added the `manim_slides.slide.animation` module and created the
|
||||
`Wipe` and `Zoom` classes, that return a new animation.
|
||||
[#285](https://github.com/jeertmans/manim-slides/pull/285)
|
||||
- Added two environ variables, `MANIM_API` and `FORCE_MANIM_API`,
|
||||
to specify the `MANIM_API` to be used: `manim` and `manimce` will
|
||||
import `manim`, while `manimgl` and `manimlib` will import `manimlib`.
|
||||
If one of the two APIs is already imported, use `FORCE_MANIM_API=1` to
|
||||
override this.
|
||||
[#285](https://github.com/jeertmans/manim-slides/pull/285)
|
||||
- Added a working `ThreeDSlide` class compatible with `manimlib`.
|
||||
[#285](https://github.com/jeertmans/manim-slides/pull/285)
|
||||
|
||||
### Changed
|
||||
|
||||
@ -55,6 +66,19 @@ In an effort to better document changes, this CHANGELOG document is now created.
|
||||
[#271](https://github.com/jeertmans/manim-slides/pull/271)
|
||||
- Bumped RevealJS' default version to v4.6.1, and added three new themes.
|
||||
[#272](https://github.com/jeertmans/manim-slides/pull/272)
|
||||
- Changed the logger such that `make_logger` is called at module import,
|
||||
and we do not use Manim's logger anymore.
|
||||
[#285](https://github.com/jeertmans/manim-slides/pull/285)
|
||||
- Changed `Slide.wipe` and `Slide.zoom` to automatically call `self.play`.
|
||||
This is a **breaking change** as calling `self.play(self.wipe(...))` now
|
||||
raises an error (because `None` is not an animation).
|
||||
[#285](https://github.com/jeertmans/manim-slides/pull/285)
|
||||
- Changed the `manim_slides.slide` module to contain submodules, i.e.,
|
||||
`slide.manim`, `slide.manimlib`, `slide.animation`.
|
||||
Only `slide.animation` is part of the public API.
|
||||
Rules for choosing the Manim API (either `manim` or `manimlib`) has changed,
|
||||
and defaults to the currently imported module, with a preference for `manim`.
|
||||
[#285](https://github.com/jeertmans/manim-slides/pull/285)
|
||||
|
||||
### Fixed
|
||||
|
||||
|
@ -94,10 +94,11 @@ Wrap a series of animations between `self.start_loop()` and `self.stop_loop()` w
|
||||
```python
|
||||
# example.py
|
||||
|
||||
from manim import *
|
||||
# or: from manimlib import *
|
||||
from manim import * # or: from manimlib import *
|
||||
|
||||
from manim_slides import Slide
|
||||
|
||||
|
||||
class BasicExample(Slide):
|
||||
def construct(self):
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
@ -111,7 +112,6 @@ class BasicExample(Slide):
|
||||
self.end_loop() # This will loop until user inputs a key
|
||||
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
self.next_slide() # Waits user to press continue to go to the next slide
|
||||
```
|
||||
|
||||
First, render the animation files:
|
||||
|
2
custom_config.yml
Normal file
2
custom_config.yml
Normal file
@ -0,0 +1,2 @@
|
||||
style:
|
||||
background_color: '#000000'
|
@ -29,9 +29,7 @@ extensions = [
|
||||
"manim_slides.docs.manim_slides_directive",
|
||||
]
|
||||
|
||||
typehints_defaults = "comma"
|
||||
typehints_use_signature = True
|
||||
typehints_use_signature_return = True
|
||||
autodoc_typehints = "both"
|
||||
|
||||
myst_enable_extensions = [
|
||||
"colon_fence",
|
||||
@ -41,6 +39,8 @@ myst_enable_extensions = [
|
||||
templates_path = ["_templates"]
|
||||
exclude_patterns = []
|
||||
|
||||
# Removes the 'package.module' part from package.module.Class
|
||||
add_module_names = False
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
|
||||
@ -74,6 +74,7 @@ intersphinx_mapping = {
|
||||
"python": ("https://docs.python.org/3", None),
|
||||
"manim": ("https://docs.manim.community/en/stable/", None),
|
||||
"manimlib": ("https://3b1b.github.io/manim/", None),
|
||||
"numpy": ("https://numpy.org/doc/stable/", None),
|
||||
}
|
||||
|
||||
# -- OpenGraph settings
|
||||
|
2
docs/source/docutils.conf
Normal file
2
docs/source/docutils.conf
Normal file
@ -0,0 +1,2 @@
|
||||
[restructuredtext parser]
|
||||
syntax_highlight = short
|
@ -13,7 +13,6 @@ The following summarizes the different presentation features Manim Slides offers
|
||||
| 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 |
|
||||
@ -23,5 +22,8 @@ The following summarizes the different presentation features Manim Slides offers
|
||||
| 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).
|
||||
[^2]: PowerPoint online does not seem to support automatic playing of videos, so you need LibreOffice Impress on Linux platforms.
|
||||
[^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).
|
||||
[^2]: PowerPoint online does not seem to support automatic playing of videos,
|
||||
so you need LibreOffice Impress on Linux platforms.
|
||||
|
@ -42,6 +42,7 @@ Manim Slides.
|
||||
quickstart
|
||||
reference/index
|
||||
features_table
|
||||
manim_or_manimgl
|
||||
```
|
||||
|
||||
```{toctree}
|
||||
|
71
docs/source/manim_or_manimgl.md
Normal file
71
docs/source/manim_or_manimgl.md
Normal file
@ -0,0 +1,71 @@
|
||||
# Manim or ManimGL
|
||||
|
||||
Manim Slides supports both Manim (Community Edition) and ManimGL (by 3b1b).
|
||||
|
||||
Because both modules have slightly different APIs, Manim Slides needs to know
|
||||
which Manim API you are using, to import the correct module.
|
||||
|
||||
## Default Behavior
|
||||
|
||||
By default, Manim Slides looks at {py:data}`sys.modules` and chooses the first
|
||||
Manim package that is already imported: `manim` for Manim,
|
||||
`manimlib` for ManimGL. This works pretty well when rendering
|
||||
the slides.
|
||||
|
||||
If both modules are present in {py:data}`sys.modules`, then Manim Slides will
|
||||
prefer using `manim`.
|
||||
|
||||
|
||||
### Usage
|
||||
|
||||
The simplest way to use Manim Slides with the correct Manim API is to:
|
||||
|
||||
1. first import the Manim API;
|
||||
2. and, then, import `manim_slides`.
|
||||
|
||||
Example for `manim`:
|
||||
|
||||
```python
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
```
|
||||
|
||||
Example for `manimlib`:
|
||||
|
||||
```python
|
||||
from manimlib import *
|
||||
from manim_slides import Slide
|
||||
```
|
||||
|
||||
### Example of Default Import
|
||||
|
||||
The following code shows how Manim Slides detected that `manimlib`
|
||||
was imported, so the {py:class}`Slide<manim_slides.slide.Slide>`
|
||||
automatically subclasses the class from ManimGL, not Manim.
|
||||
|
||||
```python
|
||||
from manimlib import Scene
|
||||
from manim_slides import Slide
|
||||
|
||||
assert issubclass(Slide, Scene) # Slide subclasses Scene from ManimGL
|
||||
|
||||
from manim import Scene
|
||||
|
||||
assert not issubclass(Slide, Scene) # but not Scene from Manim
|
||||
```
|
||||
|
||||
## Custom Manim API
|
||||
|
||||
If you want to override the default Manim API, you can set the `MANIM_API`
|
||||
environment variable to:
|
||||
|
||||
- `manim` or `manimce` to import `manim`;
|
||||
- `manimlib` or `manimgl` to import `manimlib`;
|
||||
|
||||
prior to importing `manim_slides`.
|
||||
|
||||
Note that Manim Slides will still first look at {py:data}`sys.modules` to check
|
||||
if any of the two modules is already imported.
|
||||
|
||||
If you want to force Manim Slides to obey the `MANIM_API` environment variable,
|
||||
you must also set `FORCE_MANIM_API=1`.
|
@ -6,8 +6,10 @@ and `ThreeDSlide`, which are subclasses of `Scene` and `ThreeDScene` from Manim.
|
||||
Therefore, we only document here the methods we think the end-user will ever
|
||||
use, not the methods used internally when rendering.
|
||||
|
||||
## Slide
|
||||
|
||||
```{eval-rst}
|
||||
.. autoclass:: manim_slides.Slide
|
||||
.. autoclass:: manim_slides.slide.Slide
|
||||
:members:
|
||||
add_to_canvas,
|
||||
canvas,
|
||||
@ -15,13 +17,25 @@ use, not the methods used internally when rendering.
|
||||
end_loop,
|
||||
mobjects_without_canvas,
|
||||
next_slide,
|
||||
pause,
|
||||
remove_from_canvas,
|
||||
start_loop,
|
||||
wait_time_between_slides,
|
||||
wipe,
|
||||
zoom,
|
||||
```
|
||||
|
||||
.. autoclass:: manim_slides.ThreeDSlide
|
||||
## 3D Slide
|
||||
|
||||
```{eval-rst}
|
||||
.. autoclass:: manim_slides.slide.ThreeDSlide
|
||||
:members:
|
||||
```
|
||||
|
||||
## Animations
|
||||
|
||||
```{eval-rst}
|
||||
.. automodule:: manim_slides.slide.animation
|
||||
:members:
|
||||
Wipe,
|
||||
Zoom,
|
||||
```
|
||||
|
63
example.py
63
example.py
@ -1,17 +1,13 @@
|
||||
# flake8: noqa: F403, F405
|
||||
# type: ignore
|
||||
import sys
|
||||
|
||||
if "manimlib" in sys.modules:
|
||||
from manimlib import *
|
||||
|
||||
MANIMGL = True
|
||||
else:
|
||||
from manim import *
|
||||
|
||||
MANIMGL = False
|
||||
|
||||
from manim_slides import Slide, ThreeDSlide
|
||||
from manim_slides.slide import MANIM, MANIMGL
|
||||
|
||||
if MANIM:
|
||||
from manim import *
|
||||
elif MANIMGL:
|
||||
from manimlib import *
|
||||
|
||||
|
||||
class BasicExample(Slide):
|
||||
@ -27,44 +23,6 @@ class BasicExample(Slide):
|
||||
self.end_loop() # This will loop until user inputs a key
|
||||
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
self.next_slide() # Waits user to press continue to go to the next slide
|
||||
|
||||
|
||||
class MultipleAnimationsInLastSlide(Slide):
|
||||
"""This is used to check against solution for issue #161."""
|
||||
|
||||
def construct(self):
|
||||
circle = Circle(color=BLUE)
|
||||
dot = Dot()
|
||||
|
||||
self.play(GrowFromCenter(circle))
|
||||
self.play(FadeIn(dot))
|
||||
self.next_slide()
|
||||
|
||||
self.play(dot.animate.move_to(RIGHT))
|
||||
self.play(dot.animate.move_to(UP))
|
||||
self.play(dot.animate.move_to(LEFT))
|
||||
self.play(dot.animate.move_to(DOWN))
|
||||
|
||||
self.next_slide()
|
||||
|
||||
|
||||
class TestFileTooLong(Slide):
|
||||
"""This is used to check against solution for issue #123."""
|
||||
|
||||
def construct(self):
|
||||
import random
|
||||
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
dot = Dot()
|
||||
self.play(GrowFromCenter(circle), run_time=0.1)
|
||||
|
||||
for _ in range(30):
|
||||
direction = (random.random() - 0.5) * LEFT + (random.random() - 0.5) * UP
|
||||
self.play(dot.animate.move_to(direction), run_time=0.1)
|
||||
self.play(dot.animate.move_to(ORIGIN), run_time=0.1)
|
||||
|
||||
self.next_slide()
|
||||
|
||||
|
||||
class ConvertExample(Slide):
|
||||
@ -207,7 +165,7 @@ class Example(Slide):
|
||||
language="console",
|
||||
).shift(DOWN)
|
||||
|
||||
self.play(self.wipe(title, code))
|
||||
self.wipe(title, code)
|
||||
self.next_slide()
|
||||
|
||||
self.play(FadeIn(step, shift=RIGHT))
|
||||
@ -311,11 +269,7 @@ else:
|
||||
# [manimgl-3d]
|
||||
# WARNING: 3b1b's manim change how ThreeDScene work,
|
||||
# this is why things have to be managed differently.
|
||||
class ThreeDExample(Slide):
|
||||
CONFIG = {
|
||||
"camera_class": ThreeDCamera,
|
||||
}
|
||||
|
||||
class ThreeDExample(ThreeDSlide):
|
||||
def construct(self):
|
||||
axes = ThreeDAxes()
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
@ -327,7 +281,6 @@ else:
|
||||
frame.set_euler_angles(
|
||||
theta=30 * DEGREES,
|
||||
phi=75 * DEGREES,
|
||||
gamma=0,
|
||||
)
|
||||
|
||||
self.play(GrowFromCenter(circle))
|
||||
|
@ -6,7 +6,7 @@ from click_default_group import DefaultGroup
|
||||
|
||||
from .__version__ import __version__
|
||||
from .convert import convert
|
||||
from .logger import make_logger
|
||||
from .logger import logger
|
||||
from .present import list_scenes, present
|
||||
from .wizard import init, wizard
|
||||
|
||||
@ -27,7 +27,6 @@ def cli(notify_outdated_version: bool) -> None:
|
||||
|
||||
If no command is specified, defaults to `present`.
|
||||
"""
|
||||
logger = make_logger()
|
||||
# Code below is mostly a copy from:
|
||||
# https://github.com/ManimCommunity/manim/blob/main/manim/cli/render/commands.py
|
||||
if notify_outdated_version:
|
||||
|
@ -115,7 +115,7 @@ class Keys(BaseModel): # type: ignore[misc]
|
||||
|
||||
|
||||
class Config(BaseModel): # type: ignore[misc]
|
||||
"""General Manim Slides config"""
|
||||
"""General Manim Slides config."""
|
||||
|
||||
keys: Keys = Keys()
|
||||
|
||||
@ -207,9 +207,7 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
|
||||
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.
|
||||
"""
|
||||
"""Copy the files to a given directory."""
|
||||
for slide_config in self.slides:
|
||||
file = slide_config.file
|
||||
rev_file = slide_config.rev_file
|
||||
|
@ -65,9 +65,7 @@ def validate_config_option(
|
||||
|
||||
|
||||
def file_to_data_uri(file: Path) -> str:
|
||||
"""
|
||||
Reads a video and returns the corresponding data-uri.
|
||||
"""
|
||||
"""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"
|
||||
|
||||
@ -84,9 +82,11 @@ class Converter(BaseModel): # type: ignore
|
||||
raise NotImplementedError
|
||||
|
||||
def load_template(self) -> str:
|
||||
"""Returns the template as a string.
|
||||
"""
|
||||
Returns the template as a string.
|
||||
|
||||
An empty string is returned if no template is used."""
|
||||
An empty string is returned if no template is used.
|
||||
"""
|
||||
return ""
|
||||
|
||||
def open(self, file: Path) -> Any:
|
||||
@ -358,7 +358,8 @@ class RevealJS(Converter):
|
||||
return webbrowser.open(file.absolute().as_uri())
|
||||
|
||||
def convert_to(self, dest: Path) -> None:
|
||||
"""Converts this configuration into a RevealJS HTML presentation, saved to DEST."""
|
||||
"""Converts this configuration into a RevealJS HTML presentation, saved to
|
||||
DEST."""
|
||||
if self.data_uri:
|
||||
assets_dir = Path("") # Actually we won't care.
|
||||
else:
|
||||
@ -632,9 +633,7 @@ def convert(
|
||||
config_options: Dict[str, str],
|
||||
template: Optional[Path],
|
||||
) -> None:
|
||||
"""
|
||||
Convert SCENE(s) into a given format and writes the result in DEST.
|
||||
"""
|
||||
"""Convert SCENE(s) into a given format and writes the result in DEST."""
|
||||
|
||||
presentation_configs = get_scenes_presentation_config(scenes, folder)
|
||||
|
||||
|
@ -138,8 +138,9 @@ 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.
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
@ -158,8 +159,9 @@ def depart(self, 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.
|
||||
r"""
|
||||
Reformats a string of space separated class names as a list of strings containing
|
||||
valid Sphinx references.
|
||||
|
||||
Tests
|
||||
-----
|
||||
@ -175,8 +177,8 @@ def process_name_list(option_input: str, reference_type: str) -> list[str]:
|
||||
|
||||
|
||||
class ManimSlidesDirective(Directive):
|
||||
r"""The manim-slides directive, rendering videos while building
|
||||
the documentation.
|
||||
r"""
|
||||
The manim-slides directive, rendering videos while building the documentation.
|
||||
|
||||
See the module docstring for documentation.
|
||||
"""
|
||||
|
@ -59,8 +59,8 @@ class ManimSlidesMagic(Magics): # type: ignore
|
||||
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.
|
||||
r"""
|
||||
Render Manim Slides contained in IPython cells. Works as a line or cell magic.
|
||||
|
||||
.. note::
|
||||
|
||||
@ -143,7 +143,6 @@ class ManimSlidesMagic(Magics): # type: ignore
|
||||
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)
|
||||
|
@ -1,5 +1,6 @@
|
||||
"""
|
||||
Logger utils, mostly copied from Manim Community:
|
||||
|
||||
https://github.com/ManimCommunity/manim/blob/d5b65b844b8ce8ff5151a2f56f9dc98cebbc1db4/manim/_config/logger_utils.py#L29-L101
|
||||
"""
|
||||
|
||||
@ -8,7 +9,7 @@ import logging
|
||||
from rich.console import Console
|
||||
from rich.logging import RichHandler
|
||||
|
||||
__all__ = ["logger", "make_logger"]
|
||||
__all__ = ["logger"]
|
||||
|
||||
HIGHLIGHTED_KEYWORDS = [ # these keywords are highlighted specially
|
||||
"Played",
|
||||
@ -29,9 +30,7 @@ HIGHLIGHTED_KEYWORDS = [ # these keywords are highlighted specially
|
||||
|
||||
|
||||
def make_logger() -> logging.Logger:
|
||||
"""
|
||||
Make a logger similar to the one used by Manim.
|
||||
"""
|
||||
"""Make a logger similar to the one used by Manim."""
|
||||
RichHandler.KEYWORDS = HIGHLIGHTED_KEYWORDS
|
||||
rich_handler = RichHandler(
|
||||
show_time=True,
|
||||
@ -44,4 +43,6 @@ def make_logger() -> logging.Logger:
|
||||
return logger
|
||||
|
||||
|
||||
make_logger()
|
||||
|
||||
logger = logging.getLogger("manim-slides")
|
||||
|
@ -1,93 +0,0 @@
|
||||
import sys
|
||||
from importlib.util import find_spec
|
||||
|
||||
__all__ = [
|
||||
# Constants
|
||||
"FFMPEG_BIN",
|
||||
"LEFT",
|
||||
"MANIM",
|
||||
"MANIM_PACKAGE_NAME",
|
||||
"MANIM_AVAILABLE",
|
||||
"MANIM_IMPORTED",
|
||||
"MANIMGL",
|
||||
"MANIMGL_PACKAGE_NAME",
|
||||
"MANIMGL_AVAILABLE",
|
||||
"MANIMGL_IMPORTED",
|
||||
# Classes
|
||||
"AnimationGroup",
|
||||
"FadeIn",
|
||||
"FadeOut",
|
||||
"Mobject",
|
||||
"Scene",
|
||||
"ThreeDScene",
|
||||
# Objects
|
||||
"logger",
|
||||
"config",
|
||||
]
|
||||
|
||||
|
||||
MANIM_PACKAGE_NAME = "manim"
|
||||
MANIM_AVAILABLE = find_spec(MANIM_PACKAGE_NAME) is not None
|
||||
MANIM_IMPORTED = MANIM_PACKAGE_NAME in sys.modules
|
||||
|
||||
MANIMGL_PACKAGE_NAME = "manimlib"
|
||||
MANIMGL_AVAILABLE = find_spec(MANIMGL_PACKAGE_NAME) is not None
|
||||
MANIMGL_IMPORTED = MANIMGL_PACKAGE_NAME in sys.modules
|
||||
|
||||
if MANIM_IMPORTED and MANIMGL_IMPORTED:
|
||||
from manim import logger
|
||||
|
||||
logger.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
|
||||
elif MANIM_IMPORTED:
|
||||
MANIM = True
|
||||
MANIMGL = False
|
||||
elif MANIMGL_IMPORTED:
|
||||
MANIM = False
|
||||
MANIMGL = True
|
||||
elif MANIM_AVAILABLE:
|
||||
MANIM = True
|
||||
MANIMGL = False
|
||||
elif MANIMGL_AVAILABLE:
|
||||
MANIM = False
|
||||
MANIMGL = True
|
||||
else:
|
||||
raise ModuleNotFoundError(
|
||||
"Either manim (community) or manimgl (3b1b) package must be installed"
|
||||
)
|
||||
|
||||
|
||||
if MANIMGL:
|
||||
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:
|
||||
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
|
@ -1,727 +0,0 @@
|
||||
import platform
|
||||
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, PreSlideConfig, SlideConfig
|
||||
from .defaults import FOLDER_PATH
|
||||
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:`Scene<manim.scene.scene.Scene>` and provide necessary tools for slides rendering.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, *args: Any, output_folder: Path = FOLDER_PATH, **kwargs: Any
|
||||
) -> None:
|
||||
if MANIMGL:
|
||||
Path("videos").mkdir(exist_ok=True)
|
||||
kwargs["file_writer_config"] = {
|
||||
"break_into_partial_movies": True,
|
||||
"output_directory": "",
|
||||
"write_to_movie": True,
|
||||
}
|
||||
|
||||
kwargs["preview"] = False
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.__output_folder: 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:
|
||||
color = config["background_color"]
|
||||
if hex_color := getattr(color, "hex", None):
|
||||
return hex_color # type: ignore
|
||||
else: # manim>=0.18, see https://github.com/ManimCommunity/manim/pull/3020
|
||||
return color.to_hex() # type: ignore
|
||||
|
||||
@property
|
||||
def __resolution(self) -> Tuple[int, int]:
|
||||
"""Returns the scene's resolution used during rendering."""
|
||||
if MANIMGL:
|
||||
return self.camera_config["pixel_width"], self.camera_config["pixel_height"]
|
||||
else:
|
||||
return config["pixel_width"], config["pixel_height"]
|
||||
|
||||
@property
|
||||
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
|
||||
|
||||
kwargs = {
|
||||
"remove_non_integer_files": True,
|
||||
"extension": self.file_writer.movie_file_extension,
|
||||
}
|
||||
files = get_sorted_integer_files(
|
||||
self.file_writer.partial_movie_directory, **kwargs
|
||||
)
|
||||
else:
|
||||
files = self.renderer.file_writer.partial_movie_files
|
||||
|
||||
return [Path(file) for file in files]
|
||||
|
||||
@property
|
||||
def __show_progress_bar(self) -> bool:
|
||||
"""Returns True if progress bar should be displayed."""
|
||||
if MANIMGL:
|
||||
return getattr(self, "show_progress_bar", True)
|
||||
else:
|
||||
return config["progress_bar"] != "none" # type: ignore
|
||||
|
||||
@property
|
||||
def __leave_progress_bar(self) -> bool:
|
||||
"""Returns True if progress bar should be left after completed."""
|
||||
if MANIMGL:
|
||||
return getattr(self, "leave_progress_bars", False)
|
||||
else:
|
||||
return config["progress_bar"] == "leave" # type: ignore
|
||||
|
||||
@property
|
||||
def __start_at_animation_number(self) -> Optional[int]:
|
||||
if MANIMGL:
|
||||
return getattr(self, "start_at_animation_number", None)
|
||||
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)
|
||||
self.__current_animation += 1
|
||||
|
||||
def next_slide(self) -> None:
|
||||
"""
|
||||
Creates a new slide with previous animations.
|
||||
|
||||
This usually means that the user will need to press some key before the
|
||||
next slide is played. By default, this is the right arrow key.
|
||||
|
||||
|
||||
.. note::
|
||||
|
||||
Calls to :func:`next_slide` at the very beginning or at the end are
|
||||
not needed, since they are automatically added.
|
||||
|
||||
.. warning::
|
||||
|
||||
This is not allowed to call :func:`next_slide` inside a loop.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
The following contains 3 slides:
|
||||
|
||||
#. the first with nothing on it;
|
||||
#. the second with "Hello World!" fading in;
|
||||
#. and the last with the text fading out;
|
||||
|
||||
.. manim-slides:: NextSlideExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class NextSlideExample(Slide):
|
||||
def construct(self):
|
||||
text = Text("Hello World!")
|
||||
|
||||
self.play(FadeIn(text))
|
||||
|
||||
self.next_slide()
|
||||
self.play(FadeOut(text))
|
||||
"""
|
||||
assert (
|
||||
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(
|
||||
PreSlideConfig(
|
||||
start_animation=self.__pause_start_animation,
|
||||
end_animation=self.__current_animation,
|
||||
)
|
||||
)
|
||||
self.__current_slide += 1
|
||||
self.__pause_start_animation = self.__current_animation
|
||||
|
||||
def pause(self) -> None:
|
||||
"""
|
||||
Creates a new slide with previous animations.
|
||||
|
||||
.. deprecated:: 4.10.0
|
||||
Use :func:`next_slide` instead.
|
||||
"""
|
||||
warn(
|
||||
"`self.pause()` is deprecated. Use `self.next_slide()` instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
Slide.next_slide(self)
|
||||
|
||||
def __add_last_slide(self) -> None:
|
||||
"""Adds a 'last' slide to the end of slides."""
|
||||
|
||||
if (
|
||||
len(self.__slides) > 0
|
||||
and self.__current_animation == self.__slides[-1].end_animation
|
||||
):
|
||||
return
|
||||
|
||||
self.__slides.append(
|
||||
PreSlideConfig(
|
||||
start_animation=self.__pause_start_animation,
|
||||
end_animation=self.__current_animation,
|
||||
loop=self.__loop_start_animation is not None,
|
||||
)
|
||||
)
|
||||
|
||||
def start_loop(self) -> None:
|
||||
"""
|
||||
Starts a loop. End it with :func:`end_loop`.
|
||||
|
||||
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.
|
||||
|
||||
.. manim-slides:: LoopExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class LoopExample(Slide):
|
||||
def construct(self):
|
||||
dot = Dot(color=BLUE, radius=1)
|
||||
|
||||
self.play(FadeIn(dot))
|
||||
self.next_slide()
|
||||
|
||||
self.start_loop()
|
||||
|
||||
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
|
||||
|
||||
def end_loop(self) -> None:
|
||||
"""Ends an existing loop. See :func:`start_loop` for more details."""
|
||||
assert (
|
||||
self.__loop_start_animation is not None
|
||||
), "You have to start a loop before ending it"
|
||||
self.__slides.append(
|
||||
PreSlideConfig(
|
||||
start_animation=self.__loop_start_animation,
|
||||
end_animation=self.__current_animation,
|
||||
loop=True,
|
||||
)
|
||||
)
|
||||
self.__current_slide += 1
|
||||
self.__loop_start_animation = None
|
||||
self.__pause_start_animation = self.__current_animation
|
||||
|
||||
def __save_slides(self, use_cache: bool = True) -> None:
|
||||
"""
|
||||
Saves slides, optionally using cached files.
|
||||
|
||||
Note that cached files only work with Manim.
|
||||
"""
|
||||
self.__add_last_slide()
|
||||
|
||||
files_folder = self.__output_folder / "files"
|
||||
|
||||
scene_name = str(self)
|
||||
scene_files_folder = files_folder / scene_name
|
||||
|
||||
scene_files_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 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 = max(0, slide.start_animation - offset)
|
||||
slide.end_animation -= offset
|
||||
|
||||
slides: List[SlideConfig] = []
|
||||
|
||||
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]
|
||||
|
||||
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"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:
|
||||
"""MANIMGL renderer"""
|
||||
super().run(*args, **kwargs)
|
||||
self.__save_slides(use_cache=False)
|
||||
|
||||
def render(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""MANIM render"""
|
||||
# We need to disable the caching limit since we rely on intermediate files
|
||||
max_files_cached = config["max_files_cached"]
|
||||
config["max_files_cached"] = float("inf")
|
||||
|
||||
super().render(*args, **kwargs)
|
||||
|
||||
config["max_files_cached"] = max_files_cached
|
||||
|
||||
self.__save_slides()
|
||||
|
||||
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:`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
|
63
manim_slides/slide/__init__.py
Normal file
63
manim_slides/slide/__init__.py
Normal file
@ -0,0 +1,63 @@
|
||||
__all__ = [
|
||||
"MANIM",
|
||||
"MANIMGL",
|
||||
"API_NAME",
|
||||
"Slide",
|
||||
"ThreeDSlide",
|
||||
]
|
||||
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
class ManimApiNotFoundError(ImportError):
|
||||
"""Error raised if specified manim API could be imported."""
|
||||
|
||||
_msg = "Could not import the specified manim API"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(self._msg)
|
||||
|
||||
|
||||
API_NAMES = {
|
||||
"manim": "manim",
|
||||
"manimce": "manim",
|
||||
"manimlib": "manimlib",
|
||||
"manimgl": "manimlib",
|
||||
}
|
||||
|
||||
MANIM_API: str = "MANIM_API"
|
||||
FORCE_MANIM_API: str = "FORCE_" + MANIM_API
|
||||
|
||||
API: str = os.environ.get(MANIM_API, "manim").lower()
|
||||
|
||||
|
||||
if API not in API_NAMES:
|
||||
raise ImportError(
|
||||
f"Specified MANIM_API={API!r} is not in valid options: " f"{API_NAMES}",
|
||||
)
|
||||
|
||||
API_NAME = API_NAMES[API]
|
||||
|
||||
if not os.environ.get(FORCE_MANIM_API):
|
||||
if "manim" in sys.modules:
|
||||
API_NAME = "manim"
|
||||
elif "manimlib" in sys.modules:
|
||||
API_NAME = "manimlib"
|
||||
|
||||
MANIM: bool = API_NAME == "manim"
|
||||
MANIMGL: bool = API_NAME == "manimlib"
|
||||
|
||||
if MANIM:
|
||||
try:
|
||||
from .manim import Slide, ThreeDSlide
|
||||
except ImportError as e:
|
||||
raise ManimApiNotFoundError from e
|
||||
elif MANIMGL:
|
||||
try:
|
||||
from .manimlib import Slide, ThreeDSlide
|
||||
except ImportError as e:
|
||||
raise ManimApiNotFoundError from e
|
||||
else:
|
||||
raise ManimApiNotFoundError
|
145
manim_slides/slide/animation.py
Normal file
145
manim_slides/slide/animation.py
Normal file
@ -0,0 +1,145 @@
|
||||
"""
|
||||
Additional animations for Manim objects.
|
||||
|
||||
Like with Manim, animations are classes that must be put inside a
|
||||
:meth:`Scene.play<manim.scene.scene.Scene.play>` call.
|
||||
|
||||
For each of the provided classes, there exists a method variant
|
||||
that directly calls ``self.play(Animation(...))``, see
|
||||
:class:`Slide<manim_slides.slide.Slide>`.
|
||||
"""
|
||||
|
||||
__all__ = ["Wipe", "Zoom"]
|
||||
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from . import MANIM
|
||||
|
||||
if MANIM:
|
||||
from manim import LEFT, AnimationGroup, FadeIn, FadeOut
|
||||
from manim.mobject.mobject import Mobject
|
||||
else:
|
||||
from manimlib import LEFT, AnimationGroup, FadeIn, FadeOut
|
||||
|
||||
Mobject = Any
|
||||
|
||||
|
||||
class Wipe(AnimationGroup): # type: ignore[misc]
|
||||
"""
|
||||
Creates a wipe animation that will shift all the current objects and future objects
|
||||
by a given value.
|
||||
|
||||
:param current: A sequence of mobjects to remove from the scene.
|
||||
:param future: A sequence of mobjects to add to the scene.
|
||||
:param shift: The shift vector, used for both fading in and out.
|
||||
: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:: WipeClassExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
from manim_slides.slide.animation import Wipe
|
||||
|
||||
class WipeClassExample(Slide):
|
||||
def construct(self):
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
square = Square()
|
||||
|
||||
self.play(FadeIn(circle))
|
||||
self.next_slide()
|
||||
|
||||
self.play(Wipe(circle, square, shift=3 * LEFT))
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
current: Sequence[Mobject] = [],
|
||||
future: Sequence[Mobject] = [],
|
||||
shift: np.ndarray = LEFT,
|
||||
fade_in_kwargs: Mapping[str, Any] = {},
|
||||
fade_out_kwargs: Mapping[str, Any] = {},
|
||||
**kwargs: Any,
|
||||
):
|
||||
animations = []
|
||||
|
||||
for mobject in future:
|
||||
animations.append(FadeIn(mobject, shift=shift, **fade_in_kwargs))
|
||||
|
||||
for mobject in current:
|
||||
animations.append(FadeOut(mobject, shift=shift, **fade_out_kwargs))
|
||||
|
||||
super().__init__(*animations, **kwargs)
|
||||
|
||||
|
||||
class Zoom(AnimationGroup): # type: ignore[misc]
|
||||
"""
|
||||
Creates 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:: ZoomClassExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
from manim_slides.slide.animation import Zoom
|
||||
|
||||
class ZoomClassExample(Slide):
|
||||
def construct(self):
|
||||
circles = [Circle(radius=i) for i in range(1, 4)]
|
||||
|
||||
self.play(FadeIn(circles[0]))
|
||||
self.next_slide()
|
||||
|
||||
for i in range(2):
|
||||
self.play(Zoom(circles[i], circles[i+1]))
|
||||
self.next_slide()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
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,
|
||||
) -> None:
|
||||
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))
|
||||
|
||||
super().__init__(*animations, **kwargs)
|
559
manim_slides/slide/base.py
Normal file
559
manim_slides/slide/base.py
Normal file
@ -0,0 +1,559 @@
|
||||
__all__ = ["BaseSlide"]
|
||||
|
||||
import platform
|
||||
from abc import abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Any, List, MutableMapping, Optional, Sequence, Tuple, ValuesView
|
||||
|
||||
import numpy as np
|
||||
from tqdm import tqdm
|
||||
|
||||
from ..config import PresentationConfig, PreSlideConfig, SlideConfig
|
||||
from ..defaults import FFMPEG_BIN, FOLDER_PATH
|
||||
from ..logger import logger
|
||||
from ..utils import concatenate_video_files, merge_basenames, reverse_video_file
|
||||
from . import MANIM
|
||||
|
||||
if MANIM:
|
||||
from manim.mobject.mobject import Mobject
|
||||
else:
|
||||
Mobject = Any
|
||||
|
||||
|
||||
class BaseSlide:
|
||||
def __init__(
|
||||
self, *args: Any, output_folder: Path = FOLDER_PATH, **kwargs: Any
|
||||
) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
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 _ffmpeg_bin(self) -> Path:
|
||||
"""Returns the path to the ffmpeg binaries."""
|
||||
return FFMPEG_BIN
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _frame_height(self) -> float:
|
||||
"""Returns the scene's frame height."""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _frame_width(self) -> float:
|
||||
"""Returns the scene's frame width."""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _background_color(self) -> str:
|
||||
"""Returns the scene's background color."""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _resolution(self) -> Tuple[int, int]:
|
||||
"""Returns the scene's resolution used during rendering."""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _partial_movie_files(self) -> List[Path]:
|
||||
"""Returns a list of partial movie files, a.k.a animations."""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _show_progress_bar(self) -> bool:
|
||||
"""Returns True if progress bar should be displayed."""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _leave_progress_bar(self) -> bool:
|
||||
"""Returns True if progress bar should be left after completed."""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _start_at_animation_number(self) -> Optional[int]:
|
||||
"""If set, returns the animation number at which rendering start."""
|
||||
...
|
||||
|
||||
@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.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.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 # type: ignore[attr-defined]
|
||||
]
|
||||
|
||||
@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) # type: ignore[misc]
|
||||
self._current_animation += 1
|
||||
|
||||
def next_slide(self) -> None:
|
||||
"""
|
||||
Creates a new slide with previous animations.
|
||||
|
||||
This usually means that the user will need to press some key before the
|
||||
next slide is played. By default, this is the right arrow key.
|
||||
|
||||
|
||||
.. note::
|
||||
|
||||
Calls to :func:`next_slide` at the very beginning or at the end are
|
||||
not needed, since they are automatically added.
|
||||
|
||||
.. warning::
|
||||
|
||||
This is not allowed to call :func:`next_slide` inside a loop.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
The following contains 3 slides:
|
||||
|
||||
#. the first with nothing on it;
|
||||
#. the second with "Hello World!" fading in;
|
||||
#. and the last with the text fading out;
|
||||
|
||||
.. manim-slides:: NextSlideExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class NextSlideExample(Slide):
|
||||
def construct(self):
|
||||
text = Text("Hello World!")
|
||||
|
||||
self.play(FadeIn(text))
|
||||
|
||||
self.next_slide()
|
||||
self.play(FadeOut(text))
|
||||
"""
|
||||
assert (
|
||||
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) # type: ignore[attr-defined]
|
||||
|
||||
self._slides.append(
|
||||
PreSlideConfig(
|
||||
start_animation=self._pause_start_animation,
|
||||
end_animation=self._current_animation,
|
||||
)
|
||||
)
|
||||
self._current_slide += 1
|
||||
self._pause_start_animation = self._current_animation
|
||||
|
||||
def _add_last_slide(self) -> None:
|
||||
"""Adds a 'last' slide to the end of slides."""
|
||||
|
||||
if (
|
||||
len(self._slides) > 0
|
||||
and self._current_animation == self._slides[-1].end_animation
|
||||
):
|
||||
return
|
||||
|
||||
self._slides.append(
|
||||
PreSlideConfig(
|
||||
start_animation=self._pause_start_animation,
|
||||
end_animation=self._current_animation,
|
||||
loop=self._loop_start_animation is not None,
|
||||
)
|
||||
)
|
||||
|
||||
def start_loop(self) -> None:
|
||||
"""
|
||||
Starts a loop. End it with :func:`end_loop`.
|
||||
|
||||
A loop will automatically replay the slide, i.e., everything between
|
||||
:func:`start_loop` and :func:`end_loop`, upon reaching end.
|
||||
|
||||
.. warning::
|
||||
|
||||
You should always call :func:`next_slide` before calling this
|
||||
method. Otherwise, ...
|
||||
|
||||
.. 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.
|
||||
|
||||
.. manim-slides:: LoopExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class LoopExample(Slide):
|
||||
def construct(self):
|
||||
dot = Dot(color=BLUE, radius=1)
|
||||
|
||||
self.play(FadeIn(dot))
|
||||
self.next_slide()
|
||||
|
||||
self.start_loop()
|
||||
|
||||
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
|
||||
|
||||
def end_loop(self) -> None:
|
||||
"""
|
||||
Ends an existing loop.
|
||||
|
||||
See :func:`start_loop` for more details.
|
||||
"""
|
||||
assert (
|
||||
self._loop_start_animation is not None
|
||||
), "You have to start a loop before ending it"
|
||||
self._slides.append(
|
||||
PreSlideConfig(
|
||||
start_animation=self._loop_start_animation,
|
||||
end_animation=self._current_animation,
|
||||
loop=True,
|
||||
)
|
||||
)
|
||||
self._current_slide += 1
|
||||
self._loop_start_animation = None
|
||||
self._pause_start_animation = self._current_animation
|
||||
|
||||
def _save_slides(self, use_cache: bool = True) -> None:
|
||||
"""
|
||||
Saves slides, optionally using cached files.
|
||||
|
||||
Note that cached files only work with Manim.
|
||||
"""
|
||||
self._add_last_slide()
|
||||
|
||||
files_folder = self._output_folder / "files"
|
||||
|
||||
scene_name = str(self)
|
||||
scene_files_folder = files_folder / scene_name
|
||||
|
||||
scene_files_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
files: List[Path] = 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 = max(0, slide.start_animation - offset)
|
||||
slide.end_animation -= offset
|
||||
|
||||
slides: List[SlideConfig] = []
|
||||
|
||||
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]
|
||||
|
||||
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(self._ffmpeg_bin, 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(self._ffmpeg_bin, dst_file, rev_file)
|
||||
|
||||
slides.append(
|
||||
SlideConfig.from_pre_slide_config_and_files(
|
||||
pre_slide_config, dst_file, rev_file
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
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 wipe(
|
||||
self,
|
||||
*args: Any,
|
||||
direction: np.ndarray = np.array([-1.0, 0.0, 0.0]),
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""
|
||||
Plays a wipe animation that will shift all the current objects outside of the
|
||||
current scene's scope, and all the future objects inside.
|
||||
|
||||
:param args: Positional arguments passed to
|
||||
:class:`Wipe<manim_slides.slide.animation.Wipe>`.
|
||||
:param direction: The wipe direction, that will be scaled by the scene size.
|
||||
:param kwargs: Keyword arguments passed to
|
||||
:class:`Wipe<manim_slides.slide.animation.Wipe>`.
|
||||
|
||||
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.wipe(circle, Group(square, text))
|
||||
self.next_slide()
|
||||
|
||||
self.wipe(Group(square, text), beautiful, direction=UP)
|
||||
self.next_slide()
|
||||
|
||||
self.wipe(beautiful, circle, direction=DOWN + RIGHT)
|
||||
"""
|
||||
from .animation import Wipe
|
||||
|
||||
shift_amount = np.asarray(direction) * np.array(
|
||||
[self._frame_width, self._frame_height, 0.0]
|
||||
)
|
||||
|
||||
kwargs.setdefault("shift", shift_amount)
|
||||
|
||||
animation = Wipe(
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.play(animation)
|
||||
|
||||
def zoom(
|
||||
self,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""
|
||||
Plays 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 args: Positional arguments passed to
|
||||
:class:`Zoom<manim_slides.slide.animation.Zoom>`.
|
||||
:param kwargs: Keyword arguments passed to
|
||||
:class:`Zoom<manim_slides.slide.animation.Zoom>`.
|
||||
|
||||
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.zoom(circle, square)
|
||||
self.next_slide()
|
||||
|
||||
self.zoom(square, circle, out=True, scale=10.0)
|
||||
"""
|
||||
from .animation import Zoom
|
||||
|
||||
animation = Zoom(*args, **kwargs)
|
||||
|
||||
self.play(animation)
|
128
manim_slides/slide/manim.py
Normal file
128
manim_slides/slide/manim.py
Normal file
@ -0,0 +1,128 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from manim import Scene, ThreeDScene, config
|
||||
|
||||
from .base import BaseSlide
|
||||
|
||||
|
||||
class Slide(BaseSlide, Scene): # type: ignore[misc]
|
||||
"""Inherits from :class:`Scene<manim.scene.scene.Scene>` and provide necessary tools
|
||||
for slides rendering."""
|
||||
|
||||
@property
|
||||
def _ffmpeg_bin(self) -> Path:
|
||||
# Prior to v0.16.0.post0,
|
||||
# ffmpeg was stored as a constant in manim.constants
|
||||
try:
|
||||
return Path(config.ffmpeg_executable)
|
||||
except AttributeError:
|
||||
return super()._ffmpeg_bin
|
||||
|
||||
@property
|
||||
def _frame_height(self) -> float:
|
||||
return config["frame_height"] # type: ignore
|
||||
|
||||
@property
|
||||
def _frame_width(self) -> float:
|
||||
return config["frame_width"] # type: ignore
|
||||
|
||||
@property
|
||||
def _background_color(self) -> str:
|
||||
color = config["background_color"]
|
||||
if hex_color := getattr(color, "hex", None):
|
||||
return hex_color # type: ignore
|
||||
else: # manim>=0.18, see https://github.com/ManimCommunity/manim/pull/3020
|
||||
return color.to_hex() # type: ignore
|
||||
|
||||
@property
|
||||
def _resolution(self) -> Tuple[int, int]:
|
||||
return config["pixel_width"], config["pixel_height"]
|
||||
|
||||
@property
|
||||
def _partial_movie_files(self) -> List[Path]:
|
||||
# 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.
|
||||
return [
|
||||
Path(file)
|
||||
for file in self.renderer.file_writer.partial_movie_files
|
||||
if file is not None
|
||||
]
|
||||
|
||||
@property
|
||||
def _show_progress_bar(self) -> bool:
|
||||
return config["progress_bar"] != "none" # type: ignore
|
||||
|
||||
@property
|
||||
def _leave_progress_bar(self) -> bool:
|
||||
return config["progress_bar"] == "leave" # type: ignore
|
||||
|
||||
@property
|
||||
def _start_at_animation_number(self) -> Optional[int]:
|
||||
return config["from_animation_number"] # type: ignore
|
||||
|
||||
def render(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""MANIM render."""
|
||||
# We need to disable the caching limit since we rely on intermediate files
|
||||
max_files_cached = config["max_files_cached"]
|
||||
config["max_files_cached"] = float("inf")
|
||||
|
||||
super().render(*args, **kwargs)
|
||||
|
||||
config["max_files_cached"] = max_files_cached
|
||||
|
||||
self._save_slides()
|
||||
|
||||
|
||||
class ThreeDSlide(Slide, ThreeDScene): # type: ignore[misc]
|
||||
"""
|
||||
Inherits from :class:`Slide` and
|
||||
:class:`ThreeDScene<manim.scene.three_d_scene.ThreeDScene>` and provide necessary
|
||||
tools for slides rendering.
|
||||
|
||||
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.wipe(
|
||||
self.mobjects_without_canvas,
|
||||
[bye],
|
||||
direction=UP
|
||||
)
|
||||
self.wait(.5)
|
||||
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
|
76
manim_slides/slide/manimlib.py
Normal file
76
manim_slides/slide/manimlib.py
Normal file
@ -0,0 +1,76 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from manimlib import Scene, ThreeDCamera
|
||||
from manimlib.utils.file_ops import get_sorted_integer_files
|
||||
|
||||
from .base import BaseSlide
|
||||
|
||||
|
||||
class Slide(BaseSlide, Scene): # type: ignore[misc]
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
kwargs.setdefault("file_writer_config", {}).update(
|
||||
skip_animations=True,
|
||||
break_into_partial_movies=True,
|
||||
write_to_movie=True,
|
||||
)
|
||||
|
||||
kwargs["preview"] = False # Avoid opening a preview window
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def _frame_height(self) -> float:
|
||||
return self.camera.frame.get_height() # type: ignore
|
||||
|
||||
@property
|
||||
def _frame_width(self) -> float:
|
||||
return self.camera.frame.get_width() # type: ignore
|
||||
|
||||
@property
|
||||
def _background_color(self) -> str:
|
||||
"""Returns the scene's background color."""
|
||||
return self.camera_config["background_color"].hex # type: ignore
|
||||
|
||||
@property
|
||||
def _resolution(self) -> Tuple[int, int]:
|
||||
"""Returns the scene's resolution used during rendering."""
|
||||
return self.camera_config["pixel_width"], self.camera_config["pixel_height"]
|
||||
|
||||
@property
|
||||
def _partial_movie_files(self) -> List[Path]:
|
||||
"""Returns a list of partial movie files, a.k.a animations."""
|
||||
|
||||
kwargs = {
|
||||
"remove_non_integer_files": True,
|
||||
"extension": self.file_writer.movie_file_extension,
|
||||
}
|
||||
return [
|
||||
Path(file)
|
||||
for file in get_sorted_integer_files(
|
||||
self.file_writer.partial_movie_directory, **kwargs
|
||||
)
|
||||
]
|
||||
|
||||
@property
|
||||
def _show_progress_bar(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def _leave_progress_bar(self) -> bool:
|
||||
return self.leave_progress_bars # type: ignore
|
||||
|
||||
@property
|
||||
def _start_at_animation_number(self) -> Optional[int]:
|
||||
return self.start_at_animation_number # type: ignore
|
||||
|
||||
def run(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""MANIMGL renderer."""
|
||||
super().run(*args, **kwargs)
|
||||
self._save_slides(use_cache=False)
|
||||
|
||||
|
||||
class ThreeDSlide(Slide):
|
||||
CONFIG = {
|
||||
"camera_class": ThreeDCamera,
|
||||
}
|
||||
pass
|
@ -4,20 +4,18 @@ import tempfile
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from .manim import FFMPEG_BIN, logger
|
||||
from .logger import logger
|
||||
|
||||
|
||||
def concatenate_video_files(files: List[Path], dest: Path) -> None:
|
||||
"""
|
||||
Concatenate multiple video files into one.
|
||||
"""
|
||||
def concatenate_video_files(ffmpeg_bin: Path, 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),
|
||||
str(ffmpeg_bin),
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
@ -46,9 +44,9 @@ def concatenate_video_files(files: List[Path], dest: Path) -> None:
|
||||
|
||||
|
||||
def merge_basenames(files: List[Path]) -> Path:
|
||||
"""
|
||||
Merge multiple filenames by concatenating basenames.
|
||||
"""
|
||||
"""Merge multiple filenames by concatenating basenames."""
|
||||
if len(files) == 0:
|
||||
raise ValueError("Cannot merge an empty list of files!")
|
||||
|
||||
dirname: Path = files[0].parent
|
||||
ext = files[0].suffix
|
||||
@ -66,9 +64,9 @@ def merge_basenames(files: List[Path]) -> Path:
|
||||
return dirname.joinpath(basename + ext)
|
||||
|
||||
|
||||
def reverse_video_file(src: Path, dst: Path) -> None:
|
||||
def reverse_video_file(ffmpeg_bin: Path, 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)]
|
||||
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()
|
||||
|
@ -149,7 +149,8 @@ def init(
|
||||
def _init(
|
||||
config_path: Path, force: bool, merge: bool, skip_interactive: bool = False
|
||||
) -> None:
|
||||
"""Actual initialization code for configuration file, with optional interactive mode."""
|
||||
"""Actual initialization code for configuration file, with optional interactive
|
||||
mode."""
|
||||
|
||||
if config_path.exists():
|
||||
click.secho(f"The `{CONFIG_PATH}` configuration file exists")
|
||||
|
1702
poetry.lock
generated
1702
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -5,6 +5,10 @@ requires = ["setuptools", "poetry-core>=1.0.0"]
|
||||
[tool.black]
|
||||
target-version = ["py38"]
|
||||
|
||||
[tool.docformatter]
|
||||
black = true
|
||||
pre-summary-newline = true
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
py_version = 38
|
||||
|
@ -6,30 +6,36 @@ 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
|
||||
def tests_folder() -> Iterator[Path]:
|
||||
yield Path(__file__).parent.resolve(strict=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_folder(tests_folder: Path) -> Iterator[Path]:
|
||||
yield tests_folder.parent.resolve(strict=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data_folder(tests_folder: Path) -> Iterator[Path]:
|
||||
yield (tests_folder / "data").resolve(strict=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slides_folder(data_folder: Path) -> Iterator[Path]:
|
||||
path = (data_folder / "slides").resolve()
|
||||
assert path.exists()
|
||||
yield path
|
||||
yield (data_folder / "slides").resolve(strict=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slides_file(data_folder: Path) -> Iterator[Path]:
|
||||
path = (data_folder / "slides.py").resolve()
|
||||
assert path.exists()
|
||||
yield path
|
||||
yield (data_folder / "slides.py").resolve(strict=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manimgl_config(project_folder: Path) -> Iterator[Path]:
|
||||
yield (project_folder / "custom_config.yml").resolve(strict=True)
|
||||
|
||||
|
||||
def random_path(
|
||||
@ -45,7 +51,7 @@ def random_path(
|
||||
if touch:
|
||||
filepath.touch()
|
||||
|
||||
return filepath
|
||||
return filepath.resolve(strict=touch)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
@ -1,24 +1,40 @@
|
||||
# flake8: noqa: F403, F405
|
||||
# type: ignore
|
||||
from manim import *
|
||||
|
||||
from manim_slides import Slide
|
||||
from manim_slides.slide import MANIM, MANIMGL
|
||||
|
||||
if MANIM:
|
||||
from manim import *
|
||||
elif MANIMGL:
|
||||
from manimlib import *
|
||||
|
||||
|
||||
class BasicSlide(Slide):
|
||||
def construct(self):
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
dot = Dot()
|
||||
text = Text("This is some text")
|
||||
|
||||
self.play(Write(text))
|
||||
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
|
||||
self.play(Transform(text, circle))
|
||||
|
||||
circle = text # this is to avoid name confusion
|
||||
|
||||
square = Square()
|
||||
|
||||
self.play(FadeIn(square))
|
||||
|
||||
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.play(Rotate(square, +PI / 2))
|
||||
self.play(Rotate(square, -PI / 2))
|
||||
self.end_loop()
|
||||
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
self.next_slide()
|
||||
other_text = Text("Other text")
|
||||
self.wipe([square, circle], [other_text])
|
||||
|
||||
self.play(self.wipe(Group(dot, circle), []))
|
||||
self.next_slide()
|
||||
self.zoom(other_text, [])
|
||||
|
@ -1,23 +1,23 @@
|
||||
{
|
||||
"slides": [
|
||||
{
|
||||
"file": "slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4.mp4",
|
||||
"rev_file": "slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4_reversed.mp4",
|
||||
"file": "slides/files/BasicSlide/28bf32c4df2711b07b765a647667059683133b3c45291f34692be0c845f75511.mp4",
|
||||
"rev_file": "slides/files/BasicSlide/28bf32c4df2711b07b765a647667059683133b3c45291f34692be0c845f75511_reversed.mp4",
|
||||
"loop": false
|
||||
},
|
||||
{
|
||||
"file": "slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da.mp4",
|
||||
"rev_file": "slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da_reversed.mp4",
|
||||
"file": "slides/files/BasicSlide/c7d0d9ccbf764d32bf316451f2d00607b8f12893e64afe215041a8aedceeb368.mp4",
|
||||
"rev_file": "slides/files/BasicSlide/c7d0d9ccbf764d32bf316451f2d00607b8f12893e64afe215041a8aedceeb368_reversed.mp4",
|
||||
"loop": true
|
||||
},
|
||||
{
|
||||
"file": "slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810.mp4",
|
||||
"rev_file": "slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810_reversed.mp4",
|
||||
"file": "slides/files/BasicSlide/5060f74bee3cb2e40a399a023e0120b3f91d348a9867c7f401db54ea337de97c.mp4",
|
||||
"rev_file": "slides/files/BasicSlide/5060f74bee3cb2e40a399a023e0120b3f91d348a9867c7f401db54ea337de97c_reversed.mp4",
|
||||
"loop": false
|
||||
},
|
||||
{
|
||||
"file": "slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9.mp4",
|
||||
"rev_file": "slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9_reversed.mp4",
|
||||
"file": "slides/files/BasicSlide/7a5de547a0b5de2230ff3451dd680425cf0a7ea065b31e8f92b5e93527694077.mp4",
|
||||
"rev_file": "slides/files/BasicSlide/7a5de547a0b5de2230ff3451dd680425cf0a7ea065b31e8f92b5e93527694077_reversed.mp4",
|
||||
"loop": false
|
||||
}
|
||||
],
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,62 +1,55 @@
|
||||
import importlib
|
||||
import os
|
||||
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
|
||||
import manim_slides.slide as slide
|
||||
|
||||
|
||||
def assert_import(
|
||||
*,
|
||||
api_name: str,
|
||||
manim: bool,
|
||||
manim_available: bool,
|
||||
manim_imported: bool,
|
||||
manimgl: bool,
|
||||
manimgl_available: bool,
|
||||
manimgl_imported: bool,
|
||||
) -> None:
|
||||
importlib.reload(msm)
|
||||
importlib.reload(slide)
|
||||
|
||||
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
|
||||
assert slide.API_NAME == api_name
|
||||
assert slide.MANIM == manim
|
||||
assert slide.MANIMGL == manimgl
|
||||
|
||||
|
||||
def test_force_api() -> None:
|
||||
import manim # noqa: F401
|
||||
|
||||
if "manimlib" in sys.modules:
|
||||
del sys.modules["manimlib"]
|
||||
|
||||
os.environ[slide.MANIM_API] = "manimlib"
|
||||
os.environ[slide.FORCE_MANIM_API] = "1"
|
||||
|
||||
assert_import(
|
||||
api_name="manimlib",
|
||||
manim=False,
|
||||
manimgl=True,
|
||||
)
|
||||
|
||||
del os.environ[slide.MANIM_API]
|
||||
del os.environ[slide.FORCE_MANIM_API]
|
||||
|
||||
|
||||
def test_invalid_api() -> None:
|
||||
os.environ[slide.MANIM_API] = "manim_slides"
|
||||
|
||||
with pytest.raises(ImportError):
|
||||
assert_import(
|
||||
api_name="",
|
||||
manim=False,
|
||||
manimgl=False,
|
||||
)
|
||||
|
||||
del os.environ[slide.MANIM_API]
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore:assert_import")
|
||||
@ -65,12 +58,9 @@ def test_manim_and_manimgl_imported() -> None:
|
||||
import manimlib # noqa: F401
|
||||
|
||||
assert_import(
|
||||
api_name="manim",
|
||||
manim=True,
|
||||
manim_available=True,
|
||||
manim_imported=True,
|
||||
manimgl=False,
|
||||
manimgl_available=True,
|
||||
manimgl_imported=True,
|
||||
)
|
||||
|
||||
|
||||
@ -81,12 +71,9 @@ def test_manim_imported() -> None:
|
||||
del sys.modules["manimlib"]
|
||||
|
||||
assert_import(
|
||||
api_name="manim",
|
||||
manim=True,
|
||||
manim_available=True,
|
||||
manim_imported=True,
|
||||
manimgl=False,
|
||||
manimgl_available=True,
|
||||
manimgl_imported=False,
|
||||
)
|
||||
|
||||
|
||||
@ -97,12 +84,9 @@ def test_manimgl_imported() -> None:
|
||||
del sys.modules["manim"]
|
||||
|
||||
assert_import(
|
||||
api_name="manimlib",
|
||||
manim=False,
|
||||
manim_available=True,
|
||||
manim_imported=False,
|
||||
manimgl=True,
|
||||
manimgl_available=True,
|
||||
manimgl_imported=True,
|
||||
)
|
||||
|
||||
|
||||
@ -114,30 +98,7 @@ def test_nothing_imported() -> None:
|
||||
del sys.modules["manimlib"]
|
||||
|
||||
assert_import(
|
||||
api_name="manim",
|
||||
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,
|
||||
)
|
||||
|
@ -1,41 +1,73 @@
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
from manim import Text
|
||||
from manim.__main__ import main as cli
|
||||
from manim import (
|
||||
BLUE,
|
||||
DOWN,
|
||||
LEFT,
|
||||
ORIGIN,
|
||||
RIGHT,
|
||||
UP,
|
||||
Circle,
|
||||
Dot,
|
||||
FadeIn,
|
||||
GrowFromCenter,
|
||||
Text,
|
||||
)
|
||||
from manim.__main__ import main as manim_cli
|
||||
from pydantic import ValidationError
|
||||
|
||||
from manim_slides.config import PresentationConfig
|
||||
from manim_slides.slide import Slide
|
||||
from manim_slides.defaults import FOLDER_PATH
|
||||
from manim_slides.slide.manim import Slide
|
||||
|
||||
|
||||
def assert_construct(cls: type) -> type:
|
||||
class Wrapper:
|
||||
@classmethod
|
||||
def test_construct(_) -> None:
|
||||
cls().construct()
|
||||
|
||||
return Wrapper
|
||||
@click.command(
|
||||
context_settings=dict(
|
||||
ignore_unknown_options=True,
|
||||
allow_extra_args=True,
|
||||
)
|
||||
)
|
||||
@click.pass_context
|
||||
def manimgl_cli(ctx: click.Context) -> None:
|
||||
subprocess.run([sys.executable, "-m", "manimlib", *ctx.args])
|
||||
|
||||
|
||||
def test_render_basic_examples(
|
||||
slides_file: Path, presentation_config: PresentationConfig
|
||||
cli = pytest.mark.parametrize(
|
||||
["cli"],
|
||||
[
|
||||
[manim_cli],
|
||||
[manimgl_cli],
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@cli
|
||||
def test_render_basic_slide(
|
||||
cli: click.Command,
|
||||
slides_file: Path,
|
||||
presentation_config: PresentationConfig,
|
||||
manimgl_config: Path,
|
||||
) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
with runner.isolated_filesystem() as tmp_dir:
|
||||
shutil.copy(manimgl_config, tmp_dir)
|
||||
results = runner.invoke(cli, [str(slides_file), "BasicSlide", "-ql"])
|
||||
|
||||
assert results.exit_code == 0
|
||||
|
||||
local_slides_folder = Path("slides")
|
||||
local_slides_folder = (Path(tmp_dir) / "slides").resolve(strict=True)
|
||||
|
||||
assert local_slides_folder.exists()
|
||||
|
||||
local_config_file = local_slides_folder / "BasicSlide.json"
|
||||
|
||||
assert local_config_file.exists()
|
||||
local_config_file = (local_slides_folder / "BasicSlide.json").resolve(
|
||||
strict=True
|
||||
)
|
||||
|
||||
local_presentation_config = PresentationConfig.from_file(local_config_file)
|
||||
|
||||
@ -54,8 +86,70 @@ def test_render_basic_examples(
|
||||
assert local_presentation_config.resolution == presentation_config.resolution
|
||||
|
||||
|
||||
def assert_constructs(cls: type) -> type:
|
||||
class Wrapper:
|
||||
@classmethod
|
||||
def test_render(_) -> None:
|
||||
cls().construct()
|
||||
|
||||
return Wrapper
|
||||
|
||||
|
||||
def assert_renders(cls: type) -> type:
|
||||
class Wrapper:
|
||||
@classmethod
|
||||
def test_render(_) -> None:
|
||||
cls().render()
|
||||
|
||||
return Wrapper
|
||||
|
||||
|
||||
class TestSlide:
|
||||
@assert_construct
|
||||
@assert_constructs
|
||||
class TestDefaultProperties(Slide):
|
||||
def construct(self) -> None:
|
||||
assert self._output_folder == FOLDER_PATH
|
||||
assert len(self._slides) == 0
|
||||
assert self._current_slide == 1
|
||||
assert self._loop_start_animation is None
|
||||
assert self._pause_start_animation == 0
|
||||
assert len(self._canvas) == 0
|
||||
assert self._wait_time_between_slides == 0.0
|
||||
|
||||
@assert_renders
|
||||
class TestMultipleAnimationsInLastSlide(Slide):
|
||||
"""This is used to check against solution for issue #161."""
|
||||
|
||||
def construct(self) -> None:
|
||||
circle = Circle(color=BLUE)
|
||||
dot = Dot()
|
||||
|
||||
self.play(GrowFromCenter(circle))
|
||||
self.play(FadeIn(dot))
|
||||
self.next_slide()
|
||||
|
||||
self.play(dot.animate.move_to(RIGHT))
|
||||
self.play(dot.animate.move_to(UP))
|
||||
self.play(dot.animate.move_to(LEFT))
|
||||
self.play(dot.animate.move_to(DOWN))
|
||||
|
||||
@assert_renders
|
||||
class TestFileTooLong(Slide):
|
||||
"""This is used to check against solution for issue #123."""
|
||||
|
||||
def construct(self) -> None:
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
dot = Dot()
|
||||
self.play(GrowFromCenter(circle), run_time=0.1)
|
||||
|
||||
for _ in range(30):
|
||||
direction = (random.random() - 0.5) * LEFT + (
|
||||
random.random() - 0.5
|
||||
) * UP
|
||||
self.play(dot.animate.move_to(direction), run_time=0.1)
|
||||
self.play(dot.animate.move_to(ORIGIN), run_time=0.1)
|
||||
|
||||
@assert_constructs
|
||||
class TestLoop(Slide):
|
||||
def construct(self) -> None:
|
||||
text = Text("Some text")
|
||||
@ -76,7 +170,7 @@ class TestSlide:
|
||||
with pytest.raises(ValidationError):
|
||||
self.end_loop()
|
||||
|
||||
@assert_construct
|
||||
@assert_constructs
|
||||
class TestWipe(Slide):
|
||||
def construct(self) -> None:
|
||||
text = Text("Some text")
|
||||
@ -87,12 +181,12 @@ class TestSlide:
|
||||
assert text in self.mobjects
|
||||
assert bye not in self.mobjects
|
||||
|
||||
self.play(self.wipe([text], [bye]))
|
||||
self.wipe([text], [bye])
|
||||
|
||||
assert text not in self.mobjects
|
||||
assert bye in self.mobjects
|
||||
|
||||
@assert_construct
|
||||
@assert_constructs
|
||||
class TestZoom(Slide):
|
||||
def construct(self) -> None:
|
||||
text = Text("Some text")
|
||||
@ -103,12 +197,12 @@ class TestSlide:
|
||||
assert text in self.mobjects
|
||||
assert bye not in self.mobjects
|
||||
|
||||
self.play(self.zoom([text], [bye]))
|
||||
self.zoom([text], [bye])
|
||||
|
||||
assert text not in self.mobjects
|
||||
assert bye in self.mobjects
|
||||
|
||||
@assert_construct
|
||||
@assert_constructs
|
||||
class TestCanvas(Slide):
|
||||
def construct(self) -> None:
|
||||
text = Text("Some text")
|
||||
|
Reference in New Issue
Block a user