Compare commits
70 Commits
Author | SHA1 | Date | |
---|---|---|---|
6a96b3ab8c | |||
a1c041db80 | |||
4fd3452f95 | |||
ff2be6851b | |||
95289ee7a5 | |||
f1a026208a | |||
b3fd1d209e | |||
8c38db0989 | |||
6da0c36c96 | |||
3b01efa601 | |||
c9ef5e9a75 | |||
bfad43bd38 | |||
6f2cbc9b19 | |||
5bd88c2fd5 | |||
f0c17b1e2a | |||
fce9546a9b | |||
d6ad56120e | |||
5db0261b01 | |||
8ab33ef71f | |||
4da0e2cc2d | |||
0e82e28313 | |||
8b13106fcc | |||
bce4d8188f | |||
c420b47ad2 | |||
fad13f33dc | |||
d42a7f5ff1 | |||
88d598709a | |||
2ba9b734a3 | |||
49c4a10453 | |||
8c578d2577 | |||
2a327c470b | |||
04dcf530f5 | |||
9a573f29f1 | |||
02f425f536 | |||
149b12fd01 | |||
e01be300a0 | |||
940916d4aa | |||
3da8fab145 | |||
f0c5d48107 | |||
426470ef3c | |||
700584cbcc | |||
a440da9468 | |||
6486ce147c | |||
b258deeb31 | |||
a32773c50f | |||
a16aa93ee6 | |||
e809e64f9a | |||
5967760dc3 | |||
7f824be682 | |||
9346f199d7 | |||
5c40dc69d8 | |||
bf10068cfc | |||
2f307225d1 | |||
8b5db4b2fd | |||
855c74de34 | |||
a70876d696 | |||
3cb0085f24 | |||
42d70380b0 | |||
dc1be25e6e | |||
4cd433b35a | |||
e83df48c5d | |||
ed30e2136a | |||
a9f5355595 | |||
1ef42ec82a | |||
b5f6a165db | |||
7b9c9b0c39 | |||
ac23949043 | |||
71564a4c2e | |||
b06250056d | |||
43c24d7ae1 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 4.8.3
|
||||
current_version = 4.13.2
|
||||
commit = True
|
||||
message = chore(version): bump {current_version} to {new_version}
|
||||
|
||||
|
13
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: daily
|
||||
labels:
|
||||
- dependencies
|
34
.github/workflows/clearcache.yml
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
# From: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
|
||||
name: Cleanup caches by a branch
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Cleanup
|
||||
run: |
|
||||
gh extension install actions/gh-actions-cache
|
||||
|
||||
REPO=${{ github.repository }}
|
||||
BRANCH="refs/pull/${{ github.event.pull_request.number }}/merge"
|
||||
|
||||
echo "Fetching list of cache key"
|
||||
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 )
|
||||
|
||||
## Setting this to not fail the workflow while deleting cache keys.
|
||||
set +e
|
||||
echo "Deleting caches..."
|
||||
for cacheKey in $cacheKeysForPR
|
||||
do
|
||||
gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
|
||||
done
|
||||
echo "Done"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
33
.github/workflows/draft-pdf.yml
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
# Simple workflow for deploying static content to GitHub Pages
|
||||
name: Create JOSE Paper
|
||||
|
||||
on:
|
||||
# Runs on pushes targeting the default branch
|
||||
push:
|
||||
paths:
|
||||
- paper/*
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
paper:
|
||||
runs-on: ubuntu-latest
|
||||
name: Paper Draft
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Build draft PDF
|
||||
uses: openjournals/openjournals-draft-action@master
|
||||
with:
|
||||
journal: jose
|
||||
# This should be the path to the paper within your repo.
|
||||
paper-path: paper/paper.md
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v1
|
||||
with:
|
||||
name: paper
|
||||
# This is the output path where Pandoc will write the compiled
|
||||
# PDF. Note, this should be the same directory as the input
|
||||
# paper.md
|
||||
path: paper/paper.pdf
|
25
.github/workflows/pages.yml
vendored
@ -25,6 +25,7 @@ concurrency:
|
||||
jobs:
|
||||
# Single deploy job since we're just deploying
|
||||
deploy:
|
||||
permissions: write-all
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
@ -45,22 +46,30 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev
|
||||
- name: Install Python dependencies
|
||||
run: pip install manim sphinx sphinx_click furo
|
||||
- name: Install local Python package
|
||||
run: poetry install --with docs
|
||||
run: poetry install --extras=manim --with docs
|
||||
- name: Restore cached media
|
||||
id: cache-media-restore
|
||||
uses: actions/cache/restore@v3
|
||||
with:
|
||||
path: media
|
||||
key: ${{ runner.os }}-media
|
||||
- name: Build animation and convert it into HTML slides
|
||||
- name: Build animations
|
||||
run: |
|
||||
poetry run manim example.py ConvertExample BasicExample ThreeDExample
|
||||
poetry run manim-slides convert ConvertExample docs/source/_static/slides.html -ccontrols=true
|
||||
poetry run manim-slides convert BasicExample docs/source/_static/basic_example.html -ccontrols=true
|
||||
poetry run manim-slides convert ThreeDExample docs/source/_static/three_d_example.html -ccontrols=true
|
||||
- name: Convert animations to HTML slides
|
||||
run: |
|
||||
poetry run manim-slides convert -v DEBUG ConvertExample docs/source/_static/slides.html -ccontrols=true
|
||||
poetry run manim-slides convert -v DEBUG BasicExample docs/source/_static/basic_example.html -ccontrols=true
|
||||
poetry run manim-slides convert -v DEBUG ThreeDExample docs/source/_static/three_d_example.html -ccontrols=true
|
||||
- name: Show docs/source/_static/ dir content (video only)
|
||||
run: tree -L 3 docs/source/_static/ -P '*.mp4'
|
||||
- name: Clear cache
|
||||
run: |
|
||||
gh extension install actions/gh-actions-cache
|
||||
gh actions-cache delete ${{ steps.cache-media-restore.outputs.cache-primary-key }} --confirm || true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Save media to cache
|
||||
id: cache-media-save
|
||||
uses: actions/cache/save@v3
|
||||
@ -75,6 +84,8 @@ jobs:
|
||||
with:
|
||||
# Upload docs/build/html dir
|
||||
path: docs/build/html/
|
||||
- name: Show docs/build/html/_static/ dir content (video only)
|
||||
run: tree -L 3 docs/build/html/_static/ -P '*.mp4'
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
if: github.event_name != 'pull_request'
|
||||
|
50
.github/workflows/python-publish.yml
vendored
@ -1,4 +1,3 @@
|
||||
# Modified from: https://github.com/pypa/cibuildwheel
|
||||
name: Upload Python Package
|
||||
|
||||
on:
|
||||
@ -8,41 +7,28 @@ on:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build_wheels:
|
||||
name: Build wheels on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
build_and_release:
|
||||
name: Build and release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-python@v2
|
||||
- name: Install Poetry
|
||||
run: pipx install poetry
|
||||
|
||||
- name: Install build package
|
||||
run: python -m pip install -U build
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: poetry
|
||||
|
||||
- name: Build wheels
|
||||
run: python -m build --sdist
|
||||
run: poetry build
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: dist
|
||||
path: dist/*.tar.*
|
||||
|
||||
release:
|
||||
name: Release
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build_wheels]
|
||||
steps:
|
||||
- uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
- name: Upload to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@master
|
||||
with:
|
||||
user: __token__
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
||||
- name: Publish to PyPI
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
env:
|
||||
POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }}
|
||||
run: poetry publish
|
||||
|
2
.github/workflows/test_examples.yml
vendored
@ -1,6 +1,8 @@
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- pyproject.toml
|
||||
- poetry.lock
|
||||
- '**.py'
|
||||
- .github/workflows/test_examples.yml
|
||||
workflow_dispatch:
|
||||
|
6
.gitignore
vendored
@ -35,3 +35,9 @@ docs/source/_static/basic_example.html
|
||||
docs/source/_static/three_d_example.html
|
||||
|
||||
docs/source/_static/three_d_example_assets/
|
||||
|
||||
paper/media/
|
||||
|
||||
*.jats
|
||||
|
||||
paper/paper.pdf
|
||||
|
@ -12,7 +12,7 @@ repos:
|
||||
- id: isort
|
||||
name: isort (python)
|
||||
- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
|
||||
rev: v2.6.0
|
||||
rev: v2.9.0
|
||||
hooks:
|
||||
- id: pretty-format-yaml
|
||||
args: [--autofix]
|
||||
@ -20,15 +20,15 @@ repos:
|
||||
exclude: poetry.lock
|
||||
args: [--autofix]
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 22.12.0
|
||||
rev: 23.3.0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||
rev: v0.0.237
|
||||
rev: v0.0.269
|
||||
hooks:
|
||||
- id: ruff
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v0.991
|
||||
rev: v1.3.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies: [types-requests, types-setuptools]
|
||||
|
88
README.md
@ -1,13 +1,18 @@
|
||||

|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/logo_dark_transparent.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/logo_light_transparent.png">
|
||||
<img alt="Manim Slides Logo" src="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/logo.png">
|
||||
</picture>
|
||||
|
||||
[![Latest Release][pypi-version-badge]][pypi-version-url]
|
||||
[![Python version][pypi-python-version-badge]][pypi-version-url]
|
||||

|
||||
[![PyPI - Downloads][pypi-download-badge]][pypi-version-url]
|
||||
[![Documentation][documentation-badge]][documentation-url]
|
||||
# Manim Slides
|
||||
|
||||
Tool for live presentations using either [Manim (community edition)](https://www.manim.community/) or [ManimGL](https://3b1b.github.io/manim/). Manim Slides will *automatically* detect the one you are using!
|
||||
|
||||
> **_NOTE:_** This project extends the work of [`manim-presentation`](https://github.com/galatolofederico/manim-presentation), with a lot more features!
|
||||
> **NOTE:** this project extends the work of [`manim-presentation`](https://github.com/galatolofederico/manim-presentation), with a lot more features!
|
||||
|
||||
- [Installation](#installation)
|
||||
* [Dependencies](#dependencies)
|
||||
@ -22,6 +27,9 @@ Tool for live presentations using either [Manim (community edition)](https://www
|
||||
- [F.A.Q](#faq)
|
||||
* [How to increase quality on Windows](#how-to-increase-quality-on-windows)
|
||||
- [Contributing](#contributing)
|
||||
* [Reporting an Issue](#reporting-an-issue)
|
||||
* [Seeking for Help](#seeking-for-help)
|
||||
* [Contact](#contact)
|
||||
|
||||
## Installation
|
||||
|
||||
@ -49,6 +57,16 @@ The recommended way to install the latest release is to use pip:
|
||||
pip install manim-slides
|
||||
```
|
||||
|
||||
Optionally, you can also install Manim or ManimGL using extras[^1]:
|
||||
|
||||
```bash
|
||||
pip install manim-slides[manim] # For Manim
|
||||
# or
|
||||
pip install manim-slides[manimgl] # For ManimGL
|
||||
```
|
||||
|
||||
[^1]: NOTE: you still need to have Manim or ManimGL platform-specific dependencies installed on your computer.
|
||||
|
||||
### Install From Repository
|
||||
|
||||
An alternative way to install Manim Slides is to clone the git repository, and install from there: read the [contributing guide](https://eertmans.be/manim-slides/contributing/workflow.html) to know how.
|
||||
@ -60,7 +78,7 @@ An alternative way to install Manim Slides is to clone the git repository, and i
|
||||
<!-- start usage -->
|
||||
|
||||
Using Manim Slides is a two-step process:
|
||||
1. Render animations using `Slide` (resp. `ThreeDSlide`) as a base class instead of `Scene` (resp. `ThreeDScene`), and add calls to `self.pause()` everytime you want to create a new slide.
|
||||
1. Render animations using `Slide` (resp. `ThreeDSlide`) as a base class instead of `Scene` (resp. `ThreeDScene`), and add calls to `self.next_slide()` everytime you want to create a new slide.
|
||||
2. Run `manim-slides` on rendered animations and display them like a *Power Point* presentation.
|
||||
|
||||
The documentation is available [online](https://eertmans.be/manim-slides/).
|
||||
@ -82,14 +100,14 @@ class BasicExample(Slide):
|
||||
dot = Dot()
|
||||
|
||||
self.play(GrowFromCenter(circle))
|
||||
self.pause() # Waits user to press continue to go to the next slide
|
||||
self.next_slide() # Waits user to press continue to go to the next slide
|
||||
|
||||
self.start_loop() # Start loop
|
||||
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
|
||||
self.end_loop() # This will loop until user inputs a key
|
||||
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
self.pause() # Waits user to press continue to go to the next slide
|
||||
self.next_slide() # Waits user to press continue to go to the next slide
|
||||
```
|
||||
|
||||
First, render the animation files:
|
||||
@ -118,7 +136,11 @@ manim-slides BasicExample
|
||||
|
||||
The default key bindings to control the presentation are:
|
||||
|
||||

|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/wizard_dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/wizard_light.png">
|
||||
<img alt="Manim Slides Wizard" src="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/wizard_light.png">
|
||||
</picture>
|
||||
|
||||
|
||||
You can run the **configuration wizard** to change those key bindings:
|
||||
@ -181,6 +203,58 @@ in *Settings*->*Display*.
|
||||
|
||||
Contributions are more than welcome! Please read through [our contributing section](https://eertmans.be/manim-slides/contributing/index.html).
|
||||
|
||||
### Reporting an Issue
|
||||
|
||||
<!-- start reporting-an-issue -->
|
||||
|
||||
If you think you found a bug,
|
||||
an error in the documentation,
|
||||
or wish there was some feature that is currently missing,
|
||||
we would love to hear from you!
|
||||
|
||||
The best way to reach us is via the
|
||||
[GitHub issues](https://github.com/jeertmans/manim-slides/issues).
|
||||
If your problem is not covered by an already existing (closed or open) issue,
|
||||
then we suggest you create a
|
||||
[new issue](https://github.com/jeertmans/manim-slides/issues/new/choose).
|
||||
You can choose from a list of templates, or open a
|
||||
[blank issue](https://github.com/jeertmans/manim-slides/issues/new)
|
||||
if your issue does not fit one of the proposed topics.
|
||||
|
||||
The more precise you are in the description of your problem, the faster we will
|
||||
be able to help you!
|
||||
|
||||
<!-- end reporting-an-issue -->
|
||||
|
||||
### Seeking for help
|
||||
|
||||
<!-- start seeking-for-help -->
|
||||
|
||||
Sometimes, you may have a question about Manim Slides,
|
||||
not necessarily an issue.
|
||||
|
||||
There are two ways you can reach us for questions:
|
||||
|
||||
- via the `Question/Help/Support` topic when
|
||||
[choosing an issue template](https://github.com/jeertmans/manim-slides/issues/new/choose);
|
||||
- or via
|
||||
[GitHub discussions](https://github.com/jeertmans/manim-slides/discussions).
|
||||
|
||||
<!-- end seeking-for-help -->
|
||||
|
||||
### Contact
|
||||
|
||||
<!-- start contact -->
|
||||
|
||||
Finally, if you do not have any GitHub account,
|
||||
or just wish to contact the author of Manim Slides,
|
||||
you can do so at: [jeertmans@icloud.com](mailto:jeertmans@icloud.com).
|
||||
|
||||
<!-- end contact -->
|
||||
|
||||
[pypi-version-badge]: https://img.shields.io/pypi/v/manim-slides?label=manim-slides
|
||||
[pypi-version-url]: https://pypi.org/project/manim-slides/
|
||||
[pypi-python-version-badge]: https://img.shields.io/pypi/pyversions/manim-slides
|
||||
[pypi-download-badge]: https://img.shields.io/pypi/dm/manim-slides
|
||||
[documentation-badge]: https://img.shields.io/website?down_color=lightgrey&down_message=offline&label=documentation&up_color=green&up_message=online&url=https%3A%2F%2Feertmans.be%2Fmanim-slides%2F
|
||||
[documentation-url]: https://eertmans.be/manim-slides/
|
||||
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 24 B |
1
docs/source/_static/logo.png
Symbolic link
@ -0,0 +1 @@
|
||||
../../../static/logo.png
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 24 B |
1
docs/source/_static/logo_dark_docs.png
Symbolic link
@ -0,0 +1 @@
|
||||
../../../static/logo_dark_docs.png
|
1
docs/source/_static/logo_dark_github.png
Symbolic link
@ -0,0 +1 @@
|
||||
../../../static/logo_dark_github.png
|
1
docs/source/_static/logo_dark_transparent.png
Symbolic link
@ -0,0 +1 @@
|
||||
../../../static/logo_dark_transparent.png
|
1
docs/source/_static/logo_light_transparent.png
Symbolic link
@ -0,0 +1 @@
|
||||
../../../static/logo_light_transparent.png
|
1
docs/source/_static/wizard_dark.png
Symbolic link
@ -0,0 +1 @@
|
||||
../../../static/wizard_dark.png
|
1
docs/source/_static/wizard_light.png
Symbolic link
@ -0,0 +1 @@
|
||||
../../../static/wizard_light.png
|
@ -24,6 +24,11 @@ extensions = [
|
||||
"sphinx_copybutton",
|
||||
]
|
||||
|
||||
myst_enable_extensions = [
|
||||
"colon_fence",
|
||||
"html_admonition",
|
||||
]
|
||||
|
||||
templates_path = ["_templates"]
|
||||
exclude_patterns = []
|
||||
|
||||
@ -35,6 +40,8 @@ html_theme = "furo"
|
||||
html_static_path = ["_static"]
|
||||
|
||||
html_theme_options = {
|
||||
"light_logo": "logo_light_transparent.png",
|
||||
"dark_logo": "logo_dark_transparent.png",
|
||||
"footer_icons": [
|
||||
{
|
||||
"name": "GitHub",
|
||||
@ -62,4 +69,5 @@ intersphinx_mapping = {
|
||||
|
||||
# -- OpenGraph settings
|
||||
|
||||
ogp_site_url = "https://eertmans.be/manim-slides/"
|
||||
ogp_use_first_image = True
|
||||
|
@ -2,10 +2,14 @@
|
||||
|
||||
Thank you for your interest in Manim Slides! ✨
|
||||
|
||||
Manim Slides is an open source project, first created as a fork of [manim-presentation](https://github.com/galatolofederico/manim-presentation) (now deprecated in favor to Manim Slides), and we welcome contributions of all forms.
|
||||
|
||||
This section is here to help fist-time contributors know how they can help this project grow. Whether you are already familiar with Manim or GitHub, it is worth taking a few minutes to read those documents!
|
||||
Manim Slides is an open source project, first created as a fork of
|
||||
[manim-presentation](https://github.com/galatolofederico/manim-presentation)
|
||||
(now deprecated in favor to Manim Slides),
|
||||
and we welcome contributions of all forms.
|
||||
|
||||
This section is here to help fist-time contributors know how they can help this
|
||||
project grow. Whether you are already familiar with Manim or GitHub,
|
||||
it is worth taking a few minutes to read those documents!
|
||||
|
||||
```{toctree}
|
||||
:hidden:
|
||||
@ -19,3 +23,24 @@ internals
|
||||
|
||||
[Internals](./internals)
|
||||
: how Manim Slides is built and how the various parts of it work.
|
||||
|
||||
## Reporting an Issue
|
||||
|
||||
```{include} ../../../README.md
|
||||
:start-after: <!-- start reporting-an-issue -->
|
||||
:end-before: <!-- end reporting-an-issue -->
|
||||
```
|
||||
|
||||
## Seeking for Help
|
||||
|
||||
```{include} ../../../README.md
|
||||
:start-after: <!-- start seeking-for-help -->
|
||||
:end-before: <!-- end seeking-for-help -->
|
||||
```
|
||||
|
||||
## Contact
|
||||
|
||||
```{include} ../../../README.md
|
||||
:start-after: <!-- start contact -->
|
||||
:end-before: <!-- end contact -->
|
||||
```
|
||||
|
@ -11,7 +11,7 @@ This document is there to help you recreate a working environment for Manim Slid
|
||||
|
||||
## Forking the repository and cloning it locally
|
||||
|
||||
We used GitHub to host Manim Slides' repository, and we encourage contributors to use git.
|
||||
We use GitHub to host Manim Slides' repository, and we encourage contributors to use git.
|
||||
|
||||
Useful links:
|
||||
|
||||
@ -30,6 +30,32 @@ With Poetry, installation becomes straightforward:
|
||||
poetry install
|
||||
```
|
||||
|
||||
This, however, only installs the minimal set of dependencies to run the package.
|
||||
|
||||
If you would like to install Manim or ManimGL, as documented in the [quickstart](../quickstart),
|
||||
you can use the `--extras` option:
|
||||
|
||||
```bash
|
||||
poetry install --extras manim # For Manim
|
||||
# or
|
||||
poetry install --extras manimgl # For ManimGL
|
||||
```
|
||||
|
||||
Additionnally, Manim Slides comes with group dependencies for development purposes:
|
||||
|
||||
```bash
|
||||
poetry install --with dev # For linters and formatters
|
||||
# or
|
||||
poetry install --with docs # To build the documentation locally
|
||||
```
|
||||
|
||||
Another group is `test`, but it is only used for
|
||||
[GitHub actions](https://github.com/jeertmans/manim-slides/blob/main/.github/workflows/test_examples.yml).
|
||||
|
||||
:::{note}
|
||||
You can combine any number of groups or extras when installing the package locally.
|
||||
:::
|
||||
|
||||
## Running commands
|
||||
|
||||
As modules were installed in a new Python environment, you cannot use them directly in the shell.
|
||||
@ -42,7 +68,7 @@ poetry run manim-slides wizard
|
||||
or enter a new shell that uses this new Python environment:
|
||||
|
||||
```
|
||||
poetry run
|
||||
poetry shell
|
||||
manim-slides wizard
|
||||
```
|
||||
|
||||
|
27
docs/source/features_table.md
Normal file
@ -0,0 +1,27 @@
|
||||
# Features Table
|
||||
|
||||
The following summarizes the different presentation features Manim Slides offers.
|
||||
|
||||
:::{table} Comparison of the different presentation methods.
|
||||
:widths: auto
|
||||
:align: center
|
||||
|
||||
| Feature / Constraint | [`present`](reference/cli.md) | [`convert --to=html`](reference/cli.md) | [`convert --to=pptx`](reference/cli.md) |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| Basic navigation through slides | Yes | Yes | Yes |
|
||||
| Replay slide | Yes | No | No |
|
||||
| Pause animation | Yes | No | No |
|
||||
| Play slide in reverse | Yes | No | No |
|
||||
| Slide count | Yes | Yes (optional) | Yes (optional) |
|
||||
| Animation count | Yes | No | No |
|
||||
| Needs Python with Manim Slides installed | Yes | No | No |
|
||||
| Requires internet access | No | Yes | No |
|
||||
| Auto. play slides | Yes | Yes | Yes |
|
||||
| Loops support | Yes | Yes | Yes |
|
||||
| Fully customizable | No | Yes (`--use-template` option) | No |
|
||||
| Other dependencies | None | A modern web browser | PowerPoint or LibreOffice Impress[^1]
|
||||
| Works cross-platforms | Yes | Yes | Partly[^1][^2] |
|
||||
:::
|
||||
|
||||
[^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.
|
@ -4,9 +4,18 @@ og:description: Manim Slides makes creating slides with Manim super easy!
|
||||
---
|
||||
|
||||
```{eval-rst}
|
||||
.. image:: _static/logo.png
|
||||
.. image:: _static/logo_light_transparent.png
|
||||
:width: 600px
|
||||
:align: center
|
||||
:class: only-light
|
||||
:alt: Manim Slide logo
|
||||
```
|
||||
|
||||
```{eval-rst}
|
||||
.. image:: _static/logo_dark_transparent.png
|
||||
:width: 600px
|
||||
:align: center
|
||||
:class: only-dark
|
||||
:alt: Manim Slide logo
|
||||
```
|
||||
|
||||
@ -30,6 +39,7 @@ Slide through the demo below to get a quick glimpse on what you can do with Mani
|
||||
|
||||
quickstart
|
||||
reference/index
|
||||
features_table
|
||||
```
|
||||
|
||||
```{toctree}
|
||||
|
@ -1,12 +1,12 @@
|
||||
# Application Programming Interface
|
||||
|
||||
Manim Slides' API is very limited: it simply consists in two classes, `Slide` and `ThreeDSlide`, which are subclasses of `Scene` and `ThreeDScene` from Manim.
|
||||
Manim Slides' API is very limited: it simply consists of two classes, `Slide` and `ThreeDSlide`, which are subclasses of `Scene` and `ThreeDScene` from Manim.
|
||||
|
||||
Thefore, we only document here the methods we think the end-user will ever use, not the methods used internally when rendering.
|
||||
|
||||
```{eval-rst}
|
||||
.. autoclass:: manim_slides.Slide
|
||||
:members: start_loop, end_loop, pause, play
|
||||
:members: start_loop, end_loop, pause, next_slide
|
||||
|
||||
.. autoclass:: manim_slides.ThreeDSlide
|
||||
:members:
|
||||
|
@ -66,6 +66,56 @@ Example using 3D camera. As Manim and ManimGL handle 3D differently, definitions
|
||||
:end-before: [manimgl-3d]
|
||||
```
|
||||
|
||||
## Subclass Custom Scenes
|
||||
|
||||
For compatibility reasons, Manim Slides only provides subclasses for
|
||||
`Scene` and `ThreeDScene`.
|
||||
However, subclassing other scene classes is totally possible,
|
||||
and very simple to do actually!
|
||||
|
||||
[For example](https://github.com/jeertmans/manim-slides/discussions/185),
|
||||
you can subclass the `MovingCameraScene` class from `manim`
|
||||
with the following code:
|
||||
|
||||
```{code-block} python
|
||||
:linenos:
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
|
||||
class MovingCameraSlide(Slide, MovingCameraScene):
|
||||
pass
|
||||
```
|
||||
|
||||
And later use this class anywhere in your code:
|
||||
|
||||
|
||||
```{code-block} python
|
||||
:linenos:
|
||||
|
||||
class SubclassExample(MovingCameraSlide):
|
||||
def construct(self):
|
||||
eq1 = MathTex("x", "=", "1")
|
||||
eq2 = MathTex("x", "=", "2")
|
||||
|
||||
self.play(Write(eq1))
|
||||
|
||||
self.next_slide()
|
||||
|
||||
self.play(
|
||||
TransformMatchingTex(eq1, eq2),
|
||||
self.camera.frame.animate.scale(0.5)
|
||||
)
|
||||
|
||||
self.wait()
|
||||
```
|
||||
|
||||
:::{note}
|
||||
If you do not plan to reuse `MovingCameraSlide` more than once, then you can
|
||||
directly write the `construct` method in the body of `MovingCameraSlide`.
|
||||
:::
|
||||
|
||||
## Advanced Example
|
||||
|
||||
A more advanced example is `ConvertExample`, which is used as demo slide and tutorial.
|
||||
|
71
docs/source/reference/gui.md
Normal file
@ -0,0 +1,71 @@
|
||||
# Graphical User Interface
|
||||
|
||||
Manim Slides' graphical user interface (GUI) is the *de facto* way to present slides.
|
||||
|
||||
If you do not specify one of the commands listed in the [CLI reference](./cli),
|
||||
Manim Slides will use **present** by default, which launches a GUI window,
|
||||
playing your scene(s) like so:
|
||||
|
||||
```bash
|
||||
manim-slides [present] [SCENES]...
|
||||
```
|
||||
|
||||
Some optional parameters can be specified and can be listed with:
|
||||
|
||||
```bash
|
||||
manim-slides present --help
|
||||
```
|
||||
|
||||
:::{note}
|
||||
All the `SCENES` must be in the same folder (`--folder DIRECTORY`), which
|
||||
defaults to `./slides`. If you rendered your animations without changing
|
||||
directory, you should not worry about that :-)
|
||||
:::
|
||||
|
||||
## Configuration File
|
||||
|
||||
It is possible to configure Manim Slides via a configuration file, even though
|
||||
this feature is currently limited. You may initiliaze the default configuration
|
||||
file with:
|
||||
|
||||
```bash
|
||||
manim-slides init
|
||||
```
|
||||
|
||||
:::{warning}
|
||||
Note that, by default, Manim Slides will use default key bindings that are
|
||||
platform-dependent. If you decide to overwrite those with a config file, you may
|
||||
encounter some problems from platform to platform.
|
||||
:::
|
||||
|
||||
## Configuring Key Bindings
|
||||
|
||||
If you wish to use other key bindings than the defaults, you can run the
|
||||
configuration wizard with:
|
||||
|
||||
```bash
|
||||
manim-slides wizard
|
||||
```
|
||||
|
||||
A similar window to the image below will pop up and prompt to change keys.
|
||||
|
||||
```{eval-rst}
|
||||
.. image:: ../_static/wizard_light.png
|
||||
:width: 300px
|
||||
:align: center
|
||||
:class: only-light
|
||||
:alt: Manim Slide Wizard
|
||||
```
|
||||
|
||||
```{eval-rst}
|
||||
.. image:: ../_static/wizard_dark.png
|
||||
:width: 300px
|
||||
:align: center
|
||||
:class: only-dark
|
||||
:alt: Manim Slide Wizard
|
||||
```
|
||||
|
||||
:::{note}
|
||||
Even though it is not currently supported through the GUI, you can select
|
||||
multiple key binding for the same action by modifying the config file.
|
||||
:::
|
40
docs/source/reference/html.md
Normal file
@ -0,0 +1,40 @@
|
||||
# HTML Presentations
|
||||
|
||||
Manim Slides allows you to convert presentations into one HTML file, with
|
||||
[RevealJS](https://revealjs.com/). This file can then be opened with any modern
|
||||
web browser, allowing for a nice portability of your presentations.
|
||||
|
||||
As with every command with Manim Slides, converting slides' fragments into one
|
||||
HTML file (and its assets) can be done in one command:
|
||||
|
||||
```bash
|
||||
manim-slides convert [SCENES]... DEST
|
||||
```
|
||||
|
||||
where `DEST` is the `.html` destination file.
|
||||
|
||||
## Configuring the Template
|
||||
|
||||
Many configuration options are available through the `-c<option>=<value>` syntax.
|
||||
Most, if not all, RevealJS options should be available by default. If that is
|
||||
not the case, please
|
||||
[fill an issue](https://github.com/jeertmans/manim-slides/issues/new/choose)
|
||||
on GitHub.
|
||||
|
||||
You can print the list of available options with:
|
||||
|
||||
```bash
|
||||
manim-slides convert --show-config
|
||||
```
|
||||
|
||||
## Using a Custom Template
|
||||
|
||||
The default template used for HTML conversion can be found on
|
||||
[GitHub](https://github.com/jeertmans/manim-slides/blob/main/manim_slides/data/revealjs_template.html)
|
||||
or printed with the `--show-template` option.
|
||||
If you wish to use another template, you can do so with the
|
||||
`--use-template FILE` option.
|
||||
|
||||
## More about HTML Slides
|
||||
|
||||
You can read more about HTML slides in the [sharing](./sharing) section.
|
@ -8,10 +8,21 @@ Automatically generated reference for Manim Slides.
|
||||
api
|
||||
cli
|
||||
examples
|
||||
gui
|
||||
html
|
||||
sharing
|
||||
```
|
||||
|
||||
[Application Programming Interface](./api): list of classes and methods that may be useful to the end-user.
|
||||
[Application Programming Interface](./api): list of classes and methods that may
|
||||
be useful to the end-user.
|
||||
|
||||
[Command Line Interface](./cli): list of all commands available using Manim Slides' executable.
|
||||
[Command Line Interface](./cli): list of all commands available using Manim
|
||||
Slides' executable.
|
||||
|
||||
[Examples](./examples): curated list of examples and their output.
|
||||
|
||||
[Graphical User Interface](./gui): details about the main Manim Slide' feature.
|
||||
|
||||
[HTML Presenetation](./html): an alternative way of presenting your animations.
|
||||
|
||||
[Sharing](./sharing): how to share your presentation with others.
|
||||
|
163
docs/source/reference/sharing.md
Normal file
@ -0,0 +1,163 @@
|
||||
# Sharing your slides
|
||||
|
||||
Maybe one of the most important features is the ability to share your
|
||||
presentation with other people, or even with yourself but on another computer!
|
||||
|
||||
There exists a variety of solutions, and all of them are exposed here.
|
||||
|
||||
We will go from the *most restrictive* method, to the least restrictive one.
|
||||
If you need to present on a computer without prior knowledge on what will be
|
||||
installed on it, please directly refer to the last sections.
|
||||
|
||||
> **NOTES:** in the next sections, we will assume your animations are described
|
||||
in `example.py`, and you have one presentation called `BasicExample`.
|
||||
|
||||
## With Manim Slides installed on the target machine
|
||||
|
||||
If Manim Slides, Manim (or ManimGL), and their dependencies are installed, then
|
||||
using `manim-slides present` allows for the best presentations, with the most
|
||||
options available.
|
||||
|
||||
### Sharing your Python file(s)
|
||||
|
||||
The lightest way to share your presentation is with the Python files that
|
||||
describe the slides.
|
||||
|
||||
If you have such files, you can recompile the animations locally, and use
|
||||
`manim-slides present` for your presentation. You may want to copy / paste
|
||||
you own `.manim-slides.json` config file, but it is **not recommended** if
|
||||
you are sharing from one platform (e.g., Linux) to another (e.g., Windows) as
|
||||
the key bindings might not be the same.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
# If you use ManimGl, replace `manim` with `manimgl`
|
||||
manim example.py BasicExample
|
||||
|
||||
# This or `manim-slides BasicExample` works since
|
||||
# `present` is implied by default
|
||||
manim-slides present BasicExample
|
||||
```
|
||||
|
||||
### Sharing your animations files
|
||||
|
||||
If you do not want to recompile all the animations, you can simply share the
|
||||
slides folder (defaults to `./slides`). Then, Manim Slides will be able to read
|
||||
the animations from this folder and its subdirectories.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
# Make sure that the slides directory is in the current
|
||||
# working directory, or specify with `--folder <FOLDER>`
|
||||
manim-slides present BasicExample
|
||||
```
|
||||
|
||||
and the corresponding tree:
|
||||
|
||||
```
|
||||
.
|
||||
└── slides
|
||||
├── BasicExample.json
|
||||
└── files
|
||||
└── BasicExample (files not shown)
|
||||
```
|
||||
|
||||
## Without Manim Slides installed on the target machine
|
||||
|
||||
An alternative to `manim-slides present` is `manim-slides convert`.
|
||||
Currently, HTML and PPTX conversion are available, but do not hesitate to propose
|
||||
other formats by creating a
|
||||
[Feature Request](https://github.com/jeertmans/manim-slides/issues/new/choose),
|
||||
or directly proposing a
|
||||
[Pull Request](https://github.com/jeertmans/manim-slides/compare).
|
||||
|
||||
A major advantage of HTML files is that they can be opened cross-platform,
|
||||
granted one has a modern web browser (which is pretty standard).
|
||||
|
||||
### Sharing HTML and animation files
|
||||
|
||||
First, you need to create the HTML file and its assets directory.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
manim-slides convert BasicExample basic_example.html
|
||||
```
|
||||
|
||||
Then, you need to copy the HTML files and its assets directory to target location,
|
||||
while keeping the relative path between the HTML and the assets the same. The
|
||||
easiest solution is to compress both the file and the directory into one ZIP,
|
||||
and to extract it to the desired location.
|
||||
|
||||
By default, the assets directory will be named after the main HTML file, using `{basename}_assets`.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
.
|
||||
├── basic_example_assets
|
||||
│ ├── 1413466013_2261824125_223132457.mp4
|
||||
│ ├── 1672018281_2145352439_3942561600.mp4
|
||||
│ └── 1672018281_3136302242_2191168284.mp4
|
||||
└── basic_example.html
|
||||
```
|
||||
|
||||
Then, you can simply open the HTML file with any web browser application.
|
||||
|
||||
If you want to embed the presentation inside an HTML web page, a possibility is
|
||||
to use an `iframe`:
|
||||
|
||||
```html
|
||||
<div style="position:relative;padding-bottom:56.25%;">
|
||||
<!-- 56.25 comes from aspect ratio of 16:9, change this accordingly -->
|
||||
<iframe
|
||||
style="width:100%;height:100%;position:absolute;left:0px;top:0px;"
|
||||
frameborder="0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
allowfullscreen
|
||||
allow="autoplay"
|
||||
src="basic_example.html">
|
||||
</iframe>
|
||||
</div>
|
||||
```
|
||||
|
||||
The additional code comes from
|
||||
[this article](https://faq.dailymotion.com/hc/en-us/articles/360022841393-How-to-preserve-the-player-aspect-ratio-on-a-responsive-page)
|
||||
and it there to preserve the original aspect ratio (16:9).
|
||||
|
||||
|
||||
### Sharing ONE HTML file
|
||||
|
||||
A future feature, that will be available once
|
||||
[#122](https://github.com/jeertmans/manim-slides/issues/122) is solved, will be
|
||||
to include all animations as data URI encoded, within the HTML file itself.
|
||||
|
||||
### Over the internet
|
||||
|
||||
Finally, HTML conversion makes it convenient to play your presentation on a
|
||||
remote server.
|
||||
|
||||
This is how your are able to watch all the examples on this website. If you want
|
||||
to know how to share your slide with GitHub pages, see the
|
||||
[workflow file](https://github.com/jeertmans/manim-slides/blob/main/.github/workflows/pages.yml).
|
||||
|
||||
> **WARNING:** keep in mind that playing large video files over the internet
|
||||
can take some time, and *glitches* may occur between slide transitions for this
|
||||
reason.
|
||||
|
||||
### With PowerPoint (*EXPERIMENTAL*)
|
||||
|
||||
A recent conversion feature is to the PowerPoint format, thanks to the `python-pptx` package. Even though it is fully working, it is still considered in an *EXPERIMENTAL* status because we do not exactly know what versions of PowerPoint (or LibreOffice Impress) are supported.
|
||||
|
||||
Basically, you can create a PowerPoint in a single command:
|
||||
|
||||
```bash
|
||||
manim-slides convert --to=pptx BasicExample basic_example.pptx
|
||||
```
|
||||
|
||||
All the videos and necessary files will be contained inside the `.pptx` file, so you can safely share it with anyone. By default, the `poster_frame_image`, i.e., what is displayed by PowerPoint when the video is not playing, is the first frame of each slide. This allows for smooth transitions.
|
||||
|
||||
In the future, we hope to provide more features to this format, so feel free to suggest new features too!
|
83
example.py
@ -22,14 +22,51 @@ class BasicExample(Slide):
|
||||
dot = Dot()
|
||||
|
||||
self.play(GrowFromCenter(circle))
|
||||
self.pause() # Waits user to press continue to go to the next slide
|
||||
self.next_slide() # Waits user to press continue to go to the next slide
|
||||
|
||||
self.start_loop() # Start loop
|
||||
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
|
||||
self.end_loop() # This will loop until user inputs a key
|
||||
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
self.pause() # Waits user to press continue to go to the next slide
|
||||
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):
|
||||
@ -39,7 +76,6 @@ class ConvertExample(Slide):
|
||||
self.wait(0.1)
|
||||
|
||||
def construct(self):
|
||||
|
||||
title = VGroup(
|
||||
Text("From Manim animations", t2c={"From": BLUE}),
|
||||
Text("to slides presentation", t2c={"to": BLUE}),
|
||||
@ -60,7 +96,7 @@ class ConvertExample(Slide):
|
||||
|
||||
self.play(FadeIn(title))
|
||||
|
||||
self.pause()
|
||||
self.next_slide()
|
||||
|
||||
code = Code(
|
||||
code="""from manim import *
|
||||
@ -129,10 +165,10 @@ class Example(Slide):
|
||||
self.add(dot)
|
||||
|
||||
self.play(Indicate(dot, scale_factor=2))
|
||||
self.pause()
|
||||
self.next_slide()
|
||||
square = Square()
|
||||
self.play(Transform(dot, square))
|
||||
self.pause()
|
||||
self.next_slide()
|
||||
self.play(Rotate(square, angle=PI/2))
|
||||
""",
|
||||
language="python",
|
||||
@ -151,7 +187,7 @@ class Example(Slide):
|
||||
self.end_loop()
|
||||
square = Square()
|
||||
self.play(Transform(dot, square))
|
||||
self.pause()
|
||||
self.next_slide()
|
||||
self.play(Rotate(square, angle=PI/2))
|
||||
""",
|
||||
language="python",
|
||||
@ -178,38 +214,38 @@ class Example(Slide):
|
||||
|
||||
self.play(FadeIn(code))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
self.next_slide()
|
||||
|
||||
self.play(FadeIn(step, shift=RIGHT))
|
||||
self.play(Transform(code, code_step_1))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
self.next_slide()
|
||||
|
||||
self.play(Transform(step, step_2))
|
||||
self.play(Transform(code, code_step_2))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
self.next_slide()
|
||||
|
||||
self.play(Transform(step, step_3))
|
||||
self.play(Transform(code, code_step_3))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
self.next_slide()
|
||||
|
||||
self.play(Transform(step, step_4))
|
||||
self.play(Transform(code, code_step_4))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
self.next_slide()
|
||||
|
||||
self.play(Transform(step, step_5))
|
||||
self.play(Transform(code, code_step_5))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
self.next_slide()
|
||||
|
||||
self.play(Transform(step, step_6))
|
||||
self.play(Transform(code, code_step_6))
|
||||
self.play(code.animate.shift(UP), FadeIn(code_step_7), FadeIn(or_text))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
self.next_slide()
|
||||
|
||||
watch_text = Text("Watch result on next slides!").shift(2 * DOWN).scale(0.5)
|
||||
|
||||
@ -229,10 +265,10 @@ class Example(Slide):
|
||||
self.remove(dot)
|
||||
self.add(square)
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
self.next_slide()
|
||||
self.play(Rotate(square, angle=PI / 4))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
self.next_slide()
|
||||
|
||||
learn_more_text = (
|
||||
VGroup(
|
||||
@ -250,7 +286,6 @@ class Example(Slide):
|
||||
# For ThreeDExample, things are different
|
||||
|
||||
if not MANIMGL:
|
||||
|
||||
# [manim-3d]
|
||||
class ThreeDExample(ThreeDSlide):
|
||||
def construct(self):
|
||||
@ -265,7 +300,7 @@ if not MANIMGL:
|
||||
self.play(GrowFromCenter(circle))
|
||||
self.begin_ambient_camera_rotation(rate=75 * DEGREES / 4)
|
||||
|
||||
self.pause()
|
||||
self.next_slide()
|
||||
|
||||
self.start_loop()
|
||||
self.play(MoveAlongPath(dot, circle), run_time=4, rate_func=linear)
|
||||
@ -275,16 +310,15 @@ if not MANIMGL:
|
||||
self.move_camera(phi=75 * DEGREES, theta=30 * DEGREES)
|
||||
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
self.pause()
|
||||
self.next_slide()
|
||||
|
||||
self.play(dot.animate.move_to(RIGHT * 3))
|
||||
self.pause()
|
||||
self.next_slide()
|
||||
|
||||
self.start_loop()
|
||||
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
|
||||
self.end_loop()
|
||||
|
||||
# Each slide MUST end with an animation (a self.wait is considered an animation)
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
|
||||
# [manim-3d]
|
||||
@ -315,7 +349,7 @@ else:
|
||||
updater = lambda m, dt: m.increment_theta((75 * DEGREES / 4) * dt)
|
||||
frame.add_updater(updater)
|
||||
|
||||
self.pause()
|
||||
self.next_slide()
|
||||
|
||||
self.start_loop()
|
||||
self.play(MoveAlongPath(dot, circle), run_time=4, rate_func=linear)
|
||||
@ -324,16 +358,15 @@ else:
|
||||
frame.remove_updater(updater)
|
||||
self.play(frame.animate.set_theta(30 * DEGREES))
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
self.pause()
|
||||
self.next_slide()
|
||||
|
||||
self.play(dot.animate.move_to(RIGHT * 3))
|
||||
self.pause()
|
||||
self.next_slide()
|
||||
|
||||
self.start_loop()
|
||||
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
|
||||
self.end_loop()
|
||||
|
||||
# Each slide MUST end with an animation (a self.wait is considered an animation)
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
|
||||
# [manimgl-3d]
|
||||
|
@ -1,3 +1,48 @@
|
||||
# flake8: noqa: F401
|
||||
import sys
|
||||
from types import ModuleType
|
||||
from typing import Any, List
|
||||
|
||||
from .__version__ import __version__
|
||||
from .slide import Slide, ThreeDSlide
|
||||
|
||||
|
||||
class module(ModuleType):
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
if name == "Slide" or name == "ThreeDSlide":
|
||||
module = __import__(
|
||||
"manim_slides.slide", None, None, ["Slide", "ThreeDSlide"]
|
||||
)
|
||||
return getattr(module, name)
|
||||
|
||||
return ModuleType.__getattribute__(self, name)
|
||||
|
||||
def __dir__(self) -> List[str]:
|
||||
result = list(new_module.__all__)
|
||||
result.extend(
|
||||
(
|
||||
"__file__",
|
||||
"__doc__",
|
||||
"__all__",
|
||||
"__docformat__",
|
||||
"__name__",
|
||||
"__path__",
|
||||
"__package__",
|
||||
"__version__",
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
old_module = sys.modules["manim_slides"]
|
||||
new_module = sys.modules["manim_slides"] = module("manim_slides")
|
||||
|
||||
new_module.__dict__.update(
|
||||
{
|
||||
"__file__": __file__,
|
||||
"__package__": "manim_slides",
|
||||
"__path__": __path__,
|
||||
"__doc__": __doc__,
|
||||
"__version__": __version__,
|
||||
"__all__": ("__version__", "Slides", "ThreeDSlide"),
|
||||
}
|
||||
)
|
||||
|
@ -4,9 +4,9 @@ import click
|
||||
import requests
|
||||
from click_default_group import DefaultGroup
|
||||
|
||||
from . import __version__
|
||||
from .__version__ import __version__
|
||||
from .convert import convert
|
||||
from .manim import logger
|
||||
from .logger import make_logger
|
||||
from .present import list_scenes, present
|
||||
from .wizard import init, wizard
|
||||
|
||||
@ -27,6 +27,7 @@ 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:
|
||||
|
@ -1 +1 @@
|
||||
__version__ = "4.8.3"
|
||||
__version__ = "4.13.2"
|
||||
|
@ -1,10 +1,11 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import click
|
||||
from click import Context, Parameter
|
||||
|
||||
from .defaults import CONFIG_PATH, FOLDER_PATH
|
||||
from .manim import logger
|
||||
from .logger import logger
|
||||
|
||||
F = Callable[..., Any]
|
||||
Wrapper = Callable[[F], F]
|
||||
@ -18,7 +19,7 @@ def config_path_option(function: F) -> F:
|
||||
"config_path",
|
||||
metavar="FILE",
|
||||
default=CONFIG_PATH,
|
||||
type=click.Path(dir_okay=False),
|
||||
type=click.Path(dir_okay=False, path_type=Path),
|
||||
help="Set path to configuration file.",
|
||||
show_default=True,
|
||||
)
|
||||
@ -44,7 +45,6 @@ def verbosity_option(function: F) -> F:
|
||||
"""Wraps a function to add verbosity option."""
|
||||
|
||||
def callback(ctx: Context, param: Parameter, value: bool) -> None:
|
||||
|
||||
if not value or ctx.resilient_parsing:
|
||||
return
|
||||
|
||||
@ -54,10 +54,10 @@ def verbosity_option(function: F) -> F:
|
||||
"-v",
|
||||
"--verbosity",
|
||||
type=click.Choice(
|
||||
["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
||||
["PERF", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
||||
case_sensitive=False,
|
||||
),
|
||||
help="Verbosity of CLI output",
|
||||
help="Verbosity of CLI output. PERF will log performances (timing) information.",
|
||||
default=None,
|
||||
expose_value=False,
|
||||
envvar="MANIM_SLIDES_VERBOSITY",
|
||||
@ -74,7 +74,7 @@ def folder_path_option(function: F) -> F:
|
||||
"--folder",
|
||||
metavar="DIRECTORY",
|
||||
default=FOLDER_PATH,
|
||||
type=click.Path(exists=True, file_okay=False),
|
||||
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
||||
help="Set slides folder.",
|
||||
show_default=True,
|
||||
)
|
||||
|
@ -1,27 +1,38 @@
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from enum import Enum
|
||||
from typing import Callable, Dict, List, Optional, Set, Union
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
from pydantic import BaseModel, root_validator, validator
|
||||
from pydantic import BaseModel, FilePath, PositiveInt, root_validator, validator
|
||||
from pydantic.color import Color
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from .manim import FFMPEG_BIN, logger
|
||||
from .defaults import FFMPEG_BIN
|
||||
from .logger import logger
|
||||
|
||||
|
||||
def merge_basenames(files: List[str]) -> str:
|
||||
def merge_basenames(files: List[FilePath]) -> Path:
|
||||
"""
|
||||
Merge multiple filenames by concatenating basenames.
|
||||
"""
|
||||
logger.info(f"Generating a new filename for animations: {files}")
|
||||
|
||||
dirname = os.path.dirname(files[0])
|
||||
_, ext = os.path.splitext(files[0])
|
||||
dirname: Path = files[0].parent
|
||||
ext = files[0].suffix
|
||||
|
||||
basename = "_".join(os.path.splitext(os.path.basename(file))[0] for file in files)
|
||||
basenames = (file.stem for file in files)
|
||||
|
||||
return os.path.join(dirname, basename + ext)
|
||||
basenames_str = ",".join(f"{len(b)}:{b}" for b in basenames)
|
||||
|
||||
# We use hashes to prevent too-long filenames, see issue #123:
|
||||
# https://github.com/jeertmans/manim-slides/issues/123
|
||||
basename = hashlib.sha256(basenames_str.encode()).hexdigest()
|
||||
|
||||
return dirname.joinpath(basename + ext)
|
||||
|
||||
|
||||
class Key(BaseModel): # type: ignore
|
||||
@ -111,7 +122,6 @@ class SlideConfig(BaseModel): # type: ignore
|
||||
cls, values: Dict[str, Union[SlideType, int, bool]]
|
||||
) -> Dict[str, Union[SlideType, int, bool]]:
|
||||
if values["start_animation"] >= values["end_animation"]: # type: ignore
|
||||
|
||||
if values["start_animation"] == values["end_animation"] == 0:
|
||||
raise ValueError(
|
||||
"You have to play at least one animation (e.g., `self.wait()`) before pausing. If you want to start paused, use the approriate command-line option when presenting. IMPORTANT: when using ManimGL, `self.wait()` is not considered to be an animation, so prefer to directly use `self.play(...)`."
|
||||
@ -139,26 +149,16 @@ class SlideConfig(BaseModel): # type: ignore
|
||||
|
||||
class PresentationConfig(BaseModel): # type: ignore
|
||||
slides: List[SlideConfig]
|
||||
files: List[str]
|
||||
|
||||
@validator("files", pre=True, each_item=True)
|
||||
def is_file_and_exists(cls, v: str) -> str:
|
||||
if not os.path.exists(v):
|
||||
raise ValueError(
|
||||
f"Animation file {v} does not exist. Are you in the right directory?"
|
||||
)
|
||||
|
||||
if not os.path.isfile(v):
|
||||
raise ValueError(f"Animation file {v} is not a file")
|
||||
|
||||
return v
|
||||
files: List[FilePath]
|
||||
resolution: Tuple[PositiveInt, PositiveInt] = (1920, 1080)
|
||||
background_color: Color = "black"
|
||||
|
||||
@root_validator
|
||||
def animation_indices_match_files(
|
||||
cls, values: Dict[str, Union[List[SlideConfig], List[str]]]
|
||||
) -> Dict[str, Union[List[SlideConfig], List[str]]]:
|
||||
files = values.get("files")
|
||||
slides = values.get("slides")
|
||||
cls, values: Dict[str, Union[List[SlideConfig], List[FilePath]]]
|
||||
) -> Dict[str, Union[List[SlideConfig], List[FilePath]]]:
|
||||
files: List[FilePath] = values.get("files") # type: ignore
|
||||
slides: List[SlideConfig] = values.get("slides") # type: ignore
|
||||
|
||||
if files is None or slides is None:
|
||||
return values
|
||||
@ -166,33 +166,33 @@ class PresentationConfig(BaseModel): # type: ignore
|
||||
n_files = len(files)
|
||||
|
||||
for slide in slides:
|
||||
if slide.end_animation > n_files: # type: ignore
|
||||
if slide.end_animation > n_files:
|
||||
raise ValueError(
|
||||
f"The following slide's contains animations not listed in files {files}: {slide}"
|
||||
)
|
||||
|
||||
return values
|
||||
|
||||
def move_to(self, dest: str, copy: bool = True) -> "PresentationConfig":
|
||||
def copy_to(self, dest: Path, use_cached: bool = True) -> "PresentationConfig":
|
||||
"""
|
||||
Moves (or copy) the files to a given directory.
|
||||
Copy the files to a given directory.
|
||||
"""
|
||||
copy_func: Callable[[str, str], None] = shutil.copy
|
||||
move_func: Callable[[str, str], None] = shutil.move
|
||||
move = copy_func if copy else move_func
|
||||
|
||||
n = len(self.files)
|
||||
for i in range(n):
|
||||
file = self.files[i]
|
||||
basename = os.path.basename(file)
|
||||
dest_path = os.path.join(dest, basename)
|
||||
logger.debug(f"Moving / copying {file} to {dest_path}")
|
||||
move(file, dest_path)
|
||||
dest_path = dest / self.files[i].name
|
||||
self.files[i] = dest_path
|
||||
if use_cached and dest_path.exists():
|
||||
logger.debug(f"Skipping copy of {file}, using cached copy")
|
||||
continue
|
||||
logger.debug(f"Copying {file} to {dest_path}")
|
||||
shutil.copy(file, dest_path)
|
||||
|
||||
return self
|
||||
|
||||
def concat_animations(self, dest: Optional[str] = None) -> "PresentationConfig":
|
||||
def concat_animations(
|
||||
self, dest: Optional[Path] = None, use_cached: bool = True
|
||||
) -> "PresentationConfig":
|
||||
"""
|
||||
Concatenate animations such that each slide contains one animation.
|
||||
"""
|
||||
@ -202,14 +202,22 @@ class PresentationConfig(BaseModel): # type: ignore
|
||||
for i, slide_config in enumerate(self.slides):
|
||||
files = self.files[slide_config.slides_slice]
|
||||
|
||||
slide_config.start_animation = i
|
||||
slide_config.end_animation = i + 1
|
||||
|
||||
if len(files) > 1:
|
||||
dest_path = merge_basenames(files)
|
||||
dest_paths.append(dest_path)
|
||||
|
||||
if use_cached and dest_path.exists():
|
||||
logger.debug(f"Concatenated animations already exist for slide {i}")
|
||||
continue
|
||||
|
||||
f = tempfile.NamedTemporaryFile(mode="w", delete=False)
|
||||
f.writelines(f"file '{os.path.abspath(path)}'\n" for path in files)
|
||||
f.close()
|
||||
|
||||
command = [
|
||||
command: List[str] = [
|
||||
FFMPEG_BIN,
|
||||
"-f",
|
||||
"concat",
|
||||
@ -219,7 +227,7 @@ class PresentationConfig(BaseModel): # type: ignore
|
||||
f.name,
|
||||
"-c",
|
||||
"copy",
|
||||
dest_path,
|
||||
str(dest_path),
|
||||
"-y",
|
||||
]
|
||||
logger.debug(" ".join(command))
|
||||
@ -234,18 +242,18 @@ class PresentationConfig(BaseModel): # type: ignore
|
||||
if error:
|
||||
logger.debug(error.decode())
|
||||
|
||||
dest_paths.append(dest_path)
|
||||
if not dest_path.exists():
|
||||
raise ValueError(
|
||||
"could not properly concatenate animations, use `-v INFO` for more details"
|
||||
)
|
||||
|
||||
else:
|
||||
dest_paths.append(files[0])
|
||||
|
||||
slide_config.start_animation = i
|
||||
slide_config.end_animation = i + 1
|
||||
|
||||
self.files = dest_paths
|
||||
|
||||
if dest:
|
||||
return self.move_to(dest)
|
||||
return self.copy_to(dest)
|
||||
|
||||
return self
|
||||
|
||||
|
@ -1,22 +1,42 @@
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import webbrowser
|
||||
from enum import Enum
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Generator, List, Optional, Type, Union
|
||||
|
||||
import click
|
||||
import pkg_resources
|
||||
import cv2
|
||||
import pptx
|
||||
from click import Context, Parameter
|
||||
from pydantic import BaseModel, PositiveInt, ValidationError
|
||||
from lxml import etree
|
||||
from pydantic import BaseModel, FilePath, PositiveInt, ValidationError
|
||||
from tqdm import tqdm
|
||||
|
||||
from . import data
|
||||
from .commons import folder_path_option, verbosity_option
|
||||
from .config import PresentationConfig
|
||||
from .logger import logger
|
||||
from .present import get_scenes_presentation_config
|
||||
|
||||
|
||||
def open_with_default(file: Path) -> None:
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
subprocess.call(("open", str(file)))
|
||||
elif system == "Windows":
|
||||
os.startfile(str(file)) # type: ignore[attr-defined]
|
||||
else:
|
||||
subprocess.call(("xdg-open", str(file)))
|
||||
|
||||
|
||||
def validate_config_option(
|
||||
ctx: Context, param: Parameter, value: Any
|
||||
) -> Dict[str, str]:
|
||||
|
||||
config = {}
|
||||
|
||||
for c_option in value:
|
||||
@ -34,9 +54,9 @@ def validate_config_option(
|
||||
class Converter(BaseModel): # type: ignore
|
||||
presentation_configs: List[PresentationConfig] = []
|
||||
assets_dir: str = "{basename}_assets"
|
||||
template: Optional[str] = None
|
||||
template: Optional[Path] = None
|
||||
|
||||
def convert_to(self, dest: str) -> None:
|
||||
def convert_to(self, dest: Path) -> None:
|
||||
"""Converts self, i.e., a list of presentations, into a given format."""
|
||||
raise NotImplementedError
|
||||
|
||||
@ -46,7 +66,7 @@ class Converter(BaseModel): # type: ignore
|
||||
An empty string is returned if no template is used."""
|
||||
return ""
|
||||
|
||||
def open(self, file: str) -> bool:
|
||||
def open(self, file: Path) -> Any:
|
||||
"""Opens a file, generated with converter, using appropriate application."""
|
||||
raise NotImplementedError
|
||||
|
||||
@ -55,6 +75,7 @@ class Converter(BaseModel): # type: ignore
|
||||
"""Returns the appropriate converter from a string name."""
|
||||
return {
|
||||
"html": RevealJS,
|
||||
"pptx": PowerPoint,
|
||||
}[s]
|
||||
|
||||
|
||||
@ -171,6 +192,13 @@ class TransitionSpeed(Str, Enum): # type: ignore
|
||||
slow = "slow"
|
||||
|
||||
|
||||
class BackgroundSize(Str, Enum): # type: ignore
|
||||
# From: https://developer.mozilla.org/en-US/docs/Web/CSS/background-size
|
||||
# TODO: support more background size
|
||||
contain = "contain"
|
||||
cover = "cover"
|
||||
|
||||
|
||||
BackgroundTransition = Transition
|
||||
|
||||
|
||||
@ -259,6 +287,7 @@ class RevealJS(Converter):
|
||||
focus_body_on_page_visibility_change: JsBool = JsBool.true
|
||||
transition: Transition = Transition.none
|
||||
transition_speed: TransitionSpeed = TransitionSpeed.default
|
||||
background_size: BackgroundSize = BackgroundSize.contain # Not in RevealJS
|
||||
background_transition: BackgroundTransition = BackgroundTransition.none
|
||||
pdf_max_pages_per_slide: Union[int, str] = "Number.POSITIVE_INFINITY"
|
||||
pdf_separate_fragments: JsBool = JsBool.true
|
||||
@ -278,12 +307,14 @@ class RevealJS(Converter):
|
||||
use_enum_values = True
|
||||
extra = "forbid"
|
||||
|
||||
def get_sections_iter(self) -> Generator[str, None, None]:
|
||||
def get_sections_iter(self, assets_dir: Path) -> Generator[str, None, None]:
|
||||
"""Generates a sequence of sections, one per slide, that will be included into the html template."""
|
||||
for presentation_config in self.presentation_configs:
|
||||
for slide_config in presentation_config.slides:
|
||||
file = presentation_config.files[slide_config.start_animation]
|
||||
file = os.path.join(self.assets_dir, os.path.basename(file))
|
||||
file = assets_dir / file.name
|
||||
|
||||
logger.debug(f"Writing video section with file {file}")
|
||||
|
||||
# TODO: document this
|
||||
# Videos are muted because, otherwise, the first slide never plays correctly.
|
||||
@ -292,40 +323,43 @@ class RevealJS(Converter):
|
||||
# Read more about this:
|
||||
# https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide#autoplay_and_autoplay_blocking
|
||||
if slide_config.is_loop():
|
||||
yield f'<section data-background-video="{file}" data-background-video-muted data-background-video-loop></section>'
|
||||
yield f'<section data-background-size={self.background_size.value} data-background-color="{presentation_config.background_color}" data-background-video="{file}" data-background-video-muted data-background-video-loop></section>'
|
||||
else:
|
||||
yield f'<section data-background-video="{file}" data-background-video-muted></section>'
|
||||
yield f'<section data-background-size={self.background_size.value} data-background-color="{presentation_config.background_color}" data-background-video="{file}" data-background-video-muted></section>'
|
||||
|
||||
def load_template(self) -> str:
|
||||
"""Returns the RevealJS HTML template as a string."""
|
||||
if isinstance(self.template, str):
|
||||
with open(self.template, "r") as f:
|
||||
return f.read()
|
||||
return pkg_resources.resource_string(
|
||||
__name__, "data/revealjs_template.html"
|
||||
).decode()
|
||||
if isinstance(self.template, Path):
|
||||
return self.template.read_text()
|
||||
|
||||
def open(self, file: str) -> bool:
|
||||
return webbrowser.open(file)
|
||||
if sys.version_info < (3, 9):
|
||||
return resources.read_text(data, "revealjs_template.html")
|
||||
|
||||
def convert_to(self, dest: str) -> None:
|
||||
return resources.files(data).joinpath("revealjs_template.html").read_text()
|
||||
|
||||
def open(self, file: Path) -> bool:
|
||||
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."""
|
||||
dirname = os.path.dirname(dest)
|
||||
basename, ext = os.path.splitext(os.path.basename(dest))
|
||||
dirname = dest.parent
|
||||
basename = dest.stem
|
||||
ext = dest.suffix
|
||||
|
||||
self.assets_dir = self.assets_dir.format(
|
||||
dirname=dirname, basename=basename, ext=ext
|
||||
assets_dir = Path(
|
||||
self.assets_dir.format(dirname=dirname, basename=basename, ext=ext)
|
||||
)
|
||||
full_assets_dir = os.path.join(dirname, self.assets_dir)
|
||||
full_assets_dir = dirname / assets_dir
|
||||
|
||||
logger.debug(f"Assets will be saved to: {full_assets_dir}")
|
||||
|
||||
os.makedirs(full_assets_dir, exist_ok=True)
|
||||
|
||||
for presentation_config in self.presentation_configs:
|
||||
presentation_config.concat_animations().move_to(full_assets_dir)
|
||||
presentation_config.concat_animations().copy_to(full_assets_dir)
|
||||
|
||||
with open(dest, "w") as f:
|
||||
|
||||
sections = "".join(self.get_sections_iter())
|
||||
sections = "".join(self.get_sections_iter(assets_dir))
|
||||
|
||||
revealjs_template = self.load_template()
|
||||
content = revealjs_template.format(sections=sections, **self.dict())
|
||||
@ -333,11 +367,96 @@ class RevealJS(Converter):
|
||||
f.write(content)
|
||||
|
||||
|
||||
class PowerPoint(Converter):
|
||||
left: PositiveInt = 0
|
||||
top: PositiveInt = 0
|
||||
width: PositiveInt = 1280
|
||||
height: PositiveInt = 720
|
||||
auto_play_media: bool = True
|
||||
poster_frame_image: Optional[FilePath] = None
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
extra = "forbid"
|
||||
|
||||
def open(self, file: Path) -> None:
|
||||
return open_with_default(file)
|
||||
|
||||
def convert_to(self, dest: Path) -> None:
|
||||
"""Converts this configuration into a PowerPoint presentation, saved to DEST."""
|
||||
prs = pptx.Presentation()
|
||||
prs.slide_width = self.width * 9525
|
||||
prs.slide_height = self.height * 9525
|
||||
|
||||
layout = prs.slide_layouts[6] # Should be blank
|
||||
|
||||
# From GitHub issue comment:
|
||||
# - https://github.com/scanny/python-pptx/issues/427#issuecomment-856724440
|
||||
def auto_play_media(
|
||||
media: pptx.shapes.picture.Movie, loop: bool = False
|
||||
) -> None:
|
||||
el_id = xpath(media.element, ".//p:cNvPr")[0].attrib["id"]
|
||||
el_cnt = xpath(
|
||||
media.element.getparent().getparent().getparent(),
|
||||
'.//p:timing//p:video//p:spTgt[@spid="%s"]' % el_id,
|
||||
)[0]
|
||||
cond = xpath(el_cnt.getparent().getparent(), ".//p:cond")[0]
|
||||
cond.set("delay", "0")
|
||||
|
||||
if loop:
|
||||
ctn = xpath(el_cnt.getparent().getparent(), ".//p:cTn")[0]
|
||||
ctn.set("repeatCount", "indefinite")
|
||||
|
||||
def xpath(el: etree.Element, query: str) -> etree.XPath:
|
||||
nsmap = {"p": "http://schemas.openxmlformats.org/presentationml/2006/main"}
|
||||
return etree.ElementBase.xpath(el, query, namespaces=nsmap)
|
||||
|
||||
def save_first_image_from_video_file(file: Path) -> Optional[str]:
|
||||
cap = cv2.VideoCapture(str(file))
|
||||
ret, frame = cap.read()
|
||||
|
||||
if ret:
|
||||
f = tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".png")
|
||||
cv2.imwrite(f.name, frame)
|
||||
return f.name
|
||||
else:
|
||||
logger.warn("Failed to read first image from video file")
|
||||
return None
|
||||
|
||||
for i, presentation_config in enumerate(self.presentation_configs):
|
||||
presentation_config.concat_animations()
|
||||
for slide_config in tqdm(
|
||||
presentation_config.slides,
|
||||
desc=f"Generating video slides for config {i + 1}",
|
||||
leave=False,
|
||||
):
|
||||
file = presentation_config.files[slide_config.start_animation]
|
||||
|
||||
if self.poster_frame_image is None:
|
||||
poster_frame_image = save_first_image_from_video_file(file)
|
||||
else:
|
||||
poster_frame_image = str(self.poster_frame_image)
|
||||
|
||||
slide = prs.slides.add_slide(layout)
|
||||
movie = slide.shapes.add_movie(
|
||||
str(file),
|
||||
self.left,
|
||||
self.top,
|
||||
self.width * 9525,
|
||||
self.height * 9525,
|
||||
poster_frame_image=poster_frame_image,
|
||||
mime_type="video/mp4",
|
||||
)
|
||||
if self.auto_play_media:
|
||||
auto_play_media(movie, loop=slide_config.is_loop())
|
||||
|
||||
prs.save(dest)
|
||||
|
||||
|
||||
def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Wraps a function to add a `--show-config` option."""
|
||||
|
||||
def callback(ctx: Context, param: Parameter, value: bool) -> None:
|
||||
|
||||
if not value or ctx.resilient_parsing:
|
||||
return
|
||||
|
||||
@ -349,7 +468,7 @@ def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
|
||||
ctx.exit()
|
||||
|
||||
return click.option(
|
||||
return click.option( # type: ignore
|
||||
"--show-config",
|
||||
is_flag=True,
|
||||
help="Show supported options for given format and exit.",
|
||||
@ -364,7 +483,6 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Wraps a function to add a `--show-template` option."""
|
||||
|
||||
def callback(ctx: Context, param: Parameter, value: bool) -> None:
|
||||
|
||||
if not value or ctx.resilient_parsing:
|
||||
return
|
||||
|
||||
@ -378,7 +496,7 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
|
||||
ctx.exit()
|
||||
|
||||
return click.option(
|
||||
return click.option( # type: ignore
|
||||
"--show-template",
|
||||
is_flag=True,
|
||||
help="Show the template (currently) used for a given conversion format and exit.",
|
||||
@ -392,10 +510,10 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@click.command()
|
||||
@click.argument("scenes", nargs=-1)
|
||||
@folder_path_option
|
||||
@click.argument("dest")
|
||||
@click.argument("dest", type=click.Path(dir_okay=False, path_type=Path))
|
||||
@click.option(
|
||||
"--to",
|
||||
type=click.Choice(["html"], case_sensitive=False),
|
||||
type=click.Choice(["html", "pptx"], case_sensitive=False),
|
||||
default="html",
|
||||
show_default=True,
|
||||
help="Set the conversion format to use.",
|
||||
@ -419,7 +537,7 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"--use-template",
|
||||
"template",
|
||||
metavar="FILE",
|
||||
type=click.Path(exists=True, dir_okay=False),
|
||||
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
||||
help="Use the template given by FILE instead of default one. To echo the default template, use `--show-template`.",
|
||||
)
|
||||
@show_template_option
|
||||
@ -427,13 +545,13 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@verbosity_option
|
||||
def convert(
|
||||
scenes: List[str],
|
||||
folder: str,
|
||||
dest: str,
|
||||
folder: Path,
|
||||
dest: Path,
|
||||
to: str,
|
||||
open_result: bool,
|
||||
force: bool,
|
||||
config_options: Dict[str, str],
|
||||
template: Optional[str],
|
||||
template: Optional[Path],
|
||||
) -> None:
|
||||
"""
|
||||
Convert SCENE(s) into a given format and writes the result in DEST.
|
||||
@ -454,7 +572,6 @@ def convert(
|
||||
converter.open(dest)
|
||||
|
||||
except ValidationError as e:
|
||||
|
||||
errors = e.errors()
|
||||
|
||||
msg = [
|
||||
|
0
manim_slides/data/__init__.py
Normal file
@ -1,2 +1,3 @@
|
||||
FOLDER_PATH: str = "./slides"
|
||||
CONFIG_PATH: str = ".manim-slides.json"
|
||||
FFMPEG_BIN: str = "ffmpeg"
|
||||
|
47
manim_slides/logger.py
Normal file
@ -0,0 +1,47 @@
|
||||
"""
|
||||
Logger utils, mostly copied from Manim Community:
|
||||
https://github.com/ManimCommunity/manim/blob/d5b65b844b8ce8ff5151a2f56f9dc98cebbc1db4/manim/_config/logger_utils.py#L29-L101
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from rich.console import Console
|
||||
from rich.logging import RichHandler
|
||||
from rich.theme import Theme
|
||||
|
||||
__all__ = ["logger", "make_logger"]
|
||||
|
||||
HIGHLIGHTED_KEYWORDS = [ # these keywords are highlighted specially
|
||||
"Played",
|
||||
"animations",
|
||||
"scene",
|
||||
"Reading",
|
||||
"Writing",
|
||||
"script",
|
||||
"arguments",
|
||||
"Invalid",
|
||||
"Aborting",
|
||||
"module",
|
||||
"File",
|
||||
"Rendering",
|
||||
"Rendered",
|
||||
]
|
||||
|
||||
|
||||
def make_logger() -> logging.Logger:
|
||||
"""
|
||||
Make a logger similar to the one used by Manim.
|
||||
"""
|
||||
RichHandler.KEYWORDS = HIGHLIGHTED_KEYWORDS
|
||||
rich_handler = RichHandler(
|
||||
show_time=True,
|
||||
console=Console(theme=Theme({"logging.level.perf": "magenta"})),
|
||||
)
|
||||
logging.addLevelName(5, "PERF")
|
||||
logger = logging.getLogger("manim-slides")
|
||||
logger.addHandler(rich_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
logger = logging.getLogger("manim-slides")
|
@ -3,12 +3,15 @@ import platform
|
||||
import sys
|
||||
import time
|
||||
from enum import Enum, IntEnum, auto, unique
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
import click
|
||||
import cv2
|
||||
import numpy as np
|
||||
from click import Context, Parameter
|
||||
from pydantic import ValidationError
|
||||
from pydantic.color import Color
|
||||
from PySide6.QtCore import Qt, QThread, Signal, Slot
|
||||
from PySide6.QtGui import QCloseEvent, QIcon, QImage, QKeyEvent, QPixmap, QResizeEvent
|
||||
from PySide6.QtWidgets import QApplication, QGridLayout, QLabel, QWidget
|
||||
@ -17,7 +20,7 @@ from tqdm import tqdm
|
||||
from .commons import config_path_option, verbosity_option
|
||||
from .config import DEFAULT_CONFIG, Config, PresentationConfig, SlideConfig
|
||||
from .defaults import FOLDER_PATH
|
||||
from .manim import logger
|
||||
from .logger import logger
|
||||
from .resources import * # noqa: F401, F403
|
||||
|
||||
os.environ.pop(
|
||||
@ -69,10 +72,9 @@ class Presentation:
|
||||
"""Creates presentation from a configuration object."""
|
||||
|
||||
def __init__(self, config: PresentationConfig) -> None:
|
||||
self.slides: List[SlideConfig] = config.slides
|
||||
self.files: List[str] = config.files
|
||||
self.config = config
|
||||
|
||||
self.current_slide_index: int = 0
|
||||
self.__current_slide_index: int = 0
|
||||
self.current_animation: int = self.current_slide.start_animation
|
||||
self.current_file: str = ""
|
||||
|
||||
@ -86,6 +88,71 @@ class Presentation:
|
||||
|
||||
self.reset()
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.slides)
|
||||
|
||||
@property
|
||||
def slides(self) -> List[SlideConfig]:
|
||||
"""Returns the list of slides."""
|
||||
return self.config.slides
|
||||
|
||||
@property
|
||||
def files(self) -> List[Path]:
|
||||
"""Returns the list of animation files."""
|
||||
return self.config.files
|
||||
|
||||
@property
|
||||
def resolution(self) -> Tuple[int, int]:
|
||||
"""Returns the resolution."""
|
||||
return self.config.resolution
|
||||
|
||||
@property
|
||||
def background_color(self) -> Color:
|
||||
"""Returns the background color."""
|
||||
return self.config.background_color
|
||||
|
||||
@property
|
||||
def current_slide_index(self) -> int:
|
||||
return self.__current_slide_index
|
||||
|
||||
@current_slide_index.setter
|
||||
def current_slide_index(self, value: Optional[int]) -> None:
|
||||
if value is not None:
|
||||
if -len(self) <= value < len(self):
|
||||
self.__current_slide_index = value
|
||||
self.current_animation = self.current_slide.start_animation
|
||||
logger.debug(f"Set current slide index to {value}")
|
||||
else:
|
||||
logger.error(
|
||||
f"Could not load slide number {value}, playing first slide instead."
|
||||
)
|
||||
|
||||
def set_current_animation_and_update_slide_number(
|
||||
self, value: Optional[int]
|
||||
) -> None:
|
||||
if value is not None:
|
||||
n_files = len(self.files)
|
||||
if -n_files <= value < n_files:
|
||||
if value < 0:
|
||||
value += n_files
|
||||
|
||||
for i, slide in enumerate(self.slides):
|
||||
if value < slide.end_animation:
|
||||
self.current_slide_index = i
|
||||
self.current_animation = value
|
||||
|
||||
logger.debug(f"Playing animation {value}, at slide index {i}")
|
||||
return
|
||||
|
||||
assert (
|
||||
False
|
||||
), f"An error occurred when setting the current animation to {value}, please create an issue on GitHub!"
|
||||
|
||||
else:
|
||||
logger.error(
|
||||
f"Could not load animation number {value}, playing first animation instead."
|
||||
)
|
||||
|
||||
@property
|
||||
def current_slide(self) -> SlideConfig:
|
||||
"""Returns currently playing slide."""
|
||||
@ -114,12 +181,11 @@ class Presentation:
|
||||
if (self.loaded_animation_cap != animation) or (
|
||||
self.reverse and self.reversed_animation != animation
|
||||
): # cap already loaded
|
||||
|
||||
logger.debug(f"Loading new cap for animation #{animation}")
|
||||
|
||||
self.release_cap()
|
||||
|
||||
file: str = self.files[animation]
|
||||
file: str = str(self.files[animation])
|
||||
|
||||
if self.reverse:
|
||||
file = "{}_reversed{}".format(*os.path.splitext(file))
|
||||
@ -127,7 +193,7 @@ class Presentation:
|
||||
|
||||
self.current_file = file
|
||||
|
||||
self.cap = cv2.VideoCapture(file)
|
||||
self.cap = cv2.VideoCapture(str(file))
|
||||
self.loaded_animation_cap = animation
|
||||
|
||||
@property
|
||||
@ -138,7 +204,9 @@ class Presentation:
|
||||
|
||||
def rewind_current_slide(self) -> None:
|
||||
"""Rewinds current slide to first frame."""
|
||||
logger.debug("Rewinding curring slide")
|
||||
logger.debug("Rewinding current slide")
|
||||
self.current_slide.terminated = False
|
||||
|
||||
if self.reverse:
|
||||
self.current_animation = self.current_slide.end_animation - 1
|
||||
else:
|
||||
@ -176,9 +244,10 @@ class Presentation:
|
||||
|
||||
def load_previous_slide(self) -> None:
|
||||
"""Loads previous slide."""
|
||||
logger.debug("Loading previous slide")
|
||||
logger.debug(f"Loading previous slide, current is {self.current_slide_index}")
|
||||
self.cancel_reverse()
|
||||
self.current_slide_index = max(0, self.current_slide_index - 1)
|
||||
logger.debug(f"Loading slide index {self.current_slide_index}")
|
||||
self.rewind_current_slide()
|
||||
|
||||
@property
|
||||
@ -189,7 +258,8 @@ class Presentation:
|
||||
logger.warn(
|
||||
f"Something is wrong with video file {self.current_file}, as the fps returned by frame {self.current_frame_number} is 0"
|
||||
)
|
||||
return max(fps, 1) # TODO: understand why we sometimes get 0 fps
|
||||
# TODO: understand why we sometimes get 0 fps
|
||||
return max(fps, 1) # type: ignore
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Rests current presentation."""
|
||||
@ -217,7 +287,7 @@ class Presentation:
|
||||
return self.current_animation + 1
|
||||
|
||||
@property
|
||||
def is_last_animation(self) -> int:
|
||||
def is_last_animation(self) -> bool:
|
||||
"""Returns True if current animation is the last one of current slide."""
|
||||
if self.reverse:
|
||||
return self.current_animation == self.current_slide.start_animation
|
||||
@ -280,16 +350,20 @@ class Display(QThread): # type: ignore
|
||||
|
||||
change_video_signal = Signal(np.ndarray)
|
||||
change_info_signal = Signal(dict)
|
||||
change_presentation_sigal = Signal()
|
||||
finished = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
presentations: List[PresentationConfig],
|
||||
presentations: List[Presentation],
|
||||
config: Config = DEFAULT_CONFIG,
|
||||
start_paused: bool = False,
|
||||
skip_all: bool = False,
|
||||
record_to: Optional[str] = None,
|
||||
exit_after_last_slide: bool = False,
|
||||
start_at_scene_number: Optional[int] = None,
|
||||
start_at_slide_number: Optional[int] = None,
|
||||
start_at_animation_number: Optional[int] = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.presentations = presentations
|
||||
@ -301,17 +375,53 @@ class Display(QThread): # type: ignore
|
||||
|
||||
self.state = State.PLAYING
|
||||
self.lastframe: Optional[np.ndarray] = None
|
||||
self.current_presentation_index = 0
|
||||
|
||||
self.__current_presentation_index = 0
|
||||
self.current_presentation_index = start_at_scene_number # type: ignore
|
||||
self.current_presentation.current_slide_index = start_at_slide_number # type: ignore
|
||||
self.current_presentation.set_current_animation_and_update_slide_number(
|
||||
start_at_animation_number
|
||||
)
|
||||
|
||||
self.run_flag = True
|
||||
|
||||
self.key = -1
|
||||
self.exit_after_last_slide = exit_after_last_slide
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.presentations)
|
||||
|
||||
@property
|
||||
def current_presentation_index(self) -> int:
|
||||
return self.__current_presentation_index
|
||||
|
||||
@current_presentation_index.setter
|
||||
def current_presentation_index(self, value: Optional[int]) -> None:
|
||||
if value is not None:
|
||||
if -len(self) <= value < len(self):
|
||||
self.__current_presentation_index = value
|
||||
self.current_presentation.release_cap()
|
||||
self.change_presentation_sigal.emit()
|
||||
else:
|
||||
logger.error(
|
||||
f"Could not load scene number {value}, playing first scene instead."
|
||||
)
|
||||
|
||||
@property
|
||||
def current_presentation(self) -> Presentation:
|
||||
"""Returns the current presentation."""
|
||||
return self.presentations[self.current_presentation_index]
|
||||
|
||||
@property
|
||||
def current_resolution(self) -> Tuple[int, int]:
|
||||
"""Returns the resolution of the current presentation."""
|
||||
return self.current_presentation.resolution
|
||||
|
||||
@property
|
||||
def current_background_color(self) -> Color:
|
||||
"""Returns the background color of the current presentation."""
|
||||
return self.current_presentation.background_color
|
||||
|
||||
def run(self) -> None:
|
||||
"""Runs a series of presentations until end or exit."""
|
||||
while self.run_flag:
|
||||
@ -338,6 +448,21 @@ class Display(QThread): # type: ignore
|
||||
|
||||
lag = now() - last_time
|
||||
sleep_time = 1 / self.current_presentation.fps
|
||||
|
||||
logger.log(
|
||||
5,
|
||||
f"Took {lag:.3f} seconds to process the current frame, that must play at a rate of one every {sleep_time:.3f} seconds.",
|
||||
)
|
||||
|
||||
if sleep_time - lag < 0:
|
||||
logger.warn(
|
||||
"The FPS rate could not be matched. "
|
||||
"This is normal when manually transitioning between slides.\n"
|
||||
"If you feel that the FPS are too low, "
|
||||
"consider checking this issue:\n"
|
||||
"https://github.com/jeertmans/manim-slides/issues/179."
|
||||
)
|
||||
|
||||
sleep_time = max(sleep_time - lag, 0)
|
||||
time.sleep(sleep_time)
|
||||
last_time = now()
|
||||
@ -346,7 +471,7 @@ class Display(QThread): # type: ignore
|
||||
if self.record_to is not None:
|
||||
self.record_movie()
|
||||
|
||||
logger.debug("Closing video thread gracully and exiting")
|
||||
logger.debug("Closing video thread gracefully and exiting")
|
||||
self.finished.emit()
|
||||
|
||||
def record_movie(self) -> None:
|
||||
@ -520,7 +645,6 @@ class App(QWidget): # type: ignore
|
||||
*args: Any,
|
||||
config: Config = DEFAULT_CONFIG,
|
||||
fullscreen: bool = False,
|
||||
resolution: Tuple[int, int] = (1980, 1080),
|
||||
hide_mouse: bool = False,
|
||||
aspect_ratio: AspectRatio = AspectRatio.auto,
|
||||
resize_mode: Qt.TransformationMode = Qt.SmoothTransformation,
|
||||
@ -532,7 +656,12 @@ class App(QWidget): # type: ignore
|
||||
self.setWindowTitle(WINDOW_NAME)
|
||||
self.icon = QIcon(":/icon.png")
|
||||
self.setWindowIcon(self.icon)
|
||||
self.display_width, self.display_height = resolution
|
||||
|
||||
# create the video capture thread
|
||||
kwargs["config"] = config
|
||||
self.thread = Display(*args, **kwargs)
|
||||
|
||||
self.display_width, self.display_height = self.thread.current_resolution
|
||||
self.aspect_ratio = aspect_ratio
|
||||
self.resize_mode = resize_mode
|
||||
self.hide_mouse = hide_mouse
|
||||
@ -546,15 +675,14 @@ class App(QWidget): # type: ignore
|
||||
self.label.setScaledContents(True)
|
||||
self.label.setAlignment(Qt.AlignCenter)
|
||||
self.label.resize(self.display_width, self.display_height)
|
||||
self.label.setStyleSheet(f"background-color: {background_color}")
|
||||
self.label.setStyleSheet(
|
||||
f"background-color: {self.thread.current_background_color}"
|
||||
)
|
||||
|
||||
self.pixmap = QPixmap(self.width(), self.height())
|
||||
self.label.setPixmap(self.pixmap)
|
||||
self.label.setMinimumSize(1, 1)
|
||||
|
||||
# create the video capture thread
|
||||
kwargs["config"] = config
|
||||
self.thread = Display(*args, **kwargs)
|
||||
# create the info dialog
|
||||
self.info = Info()
|
||||
self.info.show()
|
||||
@ -568,6 +696,7 @@ class App(QWidget): # type: ignore
|
||||
# connect signals
|
||||
self.thread.change_video_signal.connect(self.update_image)
|
||||
self.thread.change_info_signal.connect(self.info.update_info)
|
||||
self.thread.change_presentation_sigal.connect(self.update_canvas)
|
||||
self.thread.finished.connect(self.closeAll)
|
||||
self.send_key_signal.connect(self.thread.set_key)
|
||||
|
||||
@ -575,7 +704,6 @@ class App(QWidget): # type: ignore
|
||||
self.thread.start()
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None:
|
||||
|
||||
key = event.key()
|
||||
if self.config.HIDE_MOUSE.match(key):
|
||||
if self.hide_mouse:
|
||||
@ -622,47 +750,58 @@ class App(QWidget): # type: ignore
|
||||
|
||||
self.label.setPixmap(QPixmap.fromImage(qt_img))
|
||||
|
||||
@Slot()
|
||||
def update_canvas(self) -> None:
|
||||
"""Update the canvas when a presentation has changed."""
|
||||
logger.debug("Updating canvas")
|
||||
self.display_width, self.display_height = self.thread.current_resolution
|
||||
if not self.isFullScreen():
|
||||
self.resize(self.display_width, self.display_height)
|
||||
self.label.setStyleSheet(
|
||||
f"background-color: {self.thread.current_background_color}"
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--folder",
|
||||
metavar="DIRECTORY",
|
||||
default=FOLDER_PATH,
|
||||
type=click.Path(exists=True, file_okay=False),
|
||||
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
||||
help="Set slides folder.",
|
||||
show_default=True,
|
||||
)
|
||||
@click.help_option("-h", "--help")
|
||||
@verbosity_option
|
||||
def list_scenes(folder: str) -> None:
|
||||
def list_scenes(folder: Path) -> None:
|
||||
"""List available scenes."""
|
||||
|
||||
for i, scene in enumerate(_list_scenes(folder), start=1):
|
||||
click.secho(f"{i}: {scene}", fg="green")
|
||||
|
||||
|
||||
def _list_scenes(folder: str) -> List[str]:
|
||||
def _list_scenes(folder: Path) -> List[str]:
|
||||
"""Lists available scenes in given directory."""
|
||||
scenes = []
|
||||
|
||||
for file in os.listdir(folder):
|
||||
if file.endswith(".json"):
|
||||
filepath = os.path.join(folder, file)
|
||||
try:
|
||||
_ = PresentationConfig.parse_file(filepath)
|
||||
scenes.append(os.path.basename(file)[:-5])
|
||||
except Exception as e: # Could not parse this file as a proper presentation config
|
||||
logger.warn(
|
||||
f"Something went wrong with parsing presentation config `{filepath}`: {e}"
|
||||
)
|
||||
pass
|
||||
for filepath in folder.glob("*.json"):
|
||||
try:
|
||||
_ = PresentationConfig.parse_file(filepath)
|
||||
scenes.append(filepath.stem)
|
||||
except (
|
||||
Exception
|
||||
) as e: # Could not parse this file as a proper presentation config
|
||||
logger.warn(
|
||||
f"Something went wrong with parsing presentation config `{filepath}`: {e}"
|
||||
)
|
||||
pass
|
||||
|
||||
logger.debug(f"Found {len(scenes)} valid scene configuration files in `{folder}`.")
|
||||
|
||||
return scenes
|
||||
|
||||
|
||||
def prompt_for_scenes(folder: str) -> List[str]:
|
||||
def prompt_for_scenes(folder: Path) -> List[str]:
|
||||
"""Prompts the user to select scenes within a given folder."""
|
||||
|
||||
scene_choices = dict(enumerate(_list_scenes(folder), start=1))
|
||||
@ -691,13 +830,13 @@ def prompt_for_scenes(folder: str) -> List[str]:
|
||||
while True:
|
||||
try:
|
||||
scenes = click.prompt("Choice(s)", value_proc=value_proc)
|
||||
return scenes
|
||||
return scenes # type: ignore
|
||||
except ValueError as e:
|
||||
raise click.UsageError(str(e))
|
||||
|
||||
|
||||
def get_scenes_presentation_config(
|
||||
scenes: List[str], folder: str
|
||||
scenes: List[str], folder: Path
|
||||
) -> List[PresentationConfig]:
|
||||
"""Returns a list of presentation configurations based on the user input."""
|
||||
|
||||
@ -706,8 +845,8 @@ def get_scenes_presentation_config(
|
||||
|
||||
presentation_configs = []
|
||||
for scene in scenes:
|
||||
config_file = os.path.join(folder, f"{scene}.json")
|
||||
if not os.path.exists(config_file):
|
||||
config_file = folder / f"{scene}.json"
|
||||
if not config_file.exists():
|
||||
raise click.UsageError(
|
||||
f"File {config_file} does not exist, check the scene name and make sure to use Slide as your scene base class"
|
||||
)
|
||||
@ -719,6 +858,37 @@ def get_scenes_presentation_config(
|
||||
return presentation_configs
|
||||
|
||||
|
||||
def start_at_callback(
|
||||
ctx: Context, param: Parameter, values: str
|
||||
) -> Tuple[Optional[int], ...]:
|
||||
if values == "(None, None, None)":
|
||||
return (None, None, None)
|
||||
|
||||
def str_to_int_or_none(value: str) -> Optional[int]:
|
||||
if value.lower().strip() == "":
|
||||
return None
|
||||
else:
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
raise click.BadParameter(
|
||||
f"start index can only be an integer or an empty string, not `{value}`",
|
||||
ctx=ctx,
|
||||
param=param,
|
||||
)
|
||||
|
||||
values_tuple = values.split(",")
|
||||
n_values = len(values_tuple)
|
||||
if n_values == 3:
|
||||
return tuple(map(str_to_int_or_none, values_tuple))
|
||||
|
||||
raise click.BadParameter(
|
||||
f"exactly 3 arguments are expected but you gave {n_values}, please use commas to separate them",
|
||||
ctx=ctx,
|
||||
param=param,
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("scenes", nargs=-1)
|
||||
@config_path_option
|
||||
@ -726,7 +896,7 @@ def get_scenes_presentation_config(
|
||||
"--folder",
|
||||
metavar="DIRECTORY",
|
||||
default=FOLDER_PATH,
|
||||
type=click.Path(exists=True, file_okay=False),
|
||||
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
||||
help="Set slides folder.",
|
||||
show_default=True,
|
||||
)
|
||||
@ -736,23 +906,22 @@ def get_scenes_presentation_config(
|
||||
"-s",
|
||||
"--skip-all",
|
||||
is_flag=True,
|
||||
help="Skip all slides, useful the test if slides are working. Automatically sets `--skip-after-last-slide` to True.",
|
||||
help="Skip all slides, useful the test if slides are working. Automatically sets `--exit-after-last-slide` to True.",
|
||||
)
|
||||
@click.option(
|
||||
"-r",
|
||||
"--resolution",
|
||||
metavar="<WIDTH HEIGHT>",
|
||||
type=(int, int),
|
||||
default=(1920, 1080),
|
||||
default=None,
|
||||
help="Window resolution WIDTH HEIGHT used if fullscreen is not set. You may manually resize the window afterward.",
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
"--to",
|
||||
"--record-to",
|
||||
"record_to",
|
||||
metavar="FILE",
|
||||
type=click.Path(dir_okay=False),
|
||||
type=click.Path(dir_okay=False, path_type=Path),
|
||||
default=None,
|
||||
help="If set, the presentation will be recorded into a AVI video file with given name.",
|
||||
)
|
||||
@ -786,26 +955,67 @@ def get_scenes_presentation_config(
|
||||
"background_color",
|
||||
metavar="COLOR",
|
||||
type=str,
|
||||
default="black",
|
||||
help='Set the background color for borders when using "keep" resize mode. Can be any valid CSS color, e.g., "green", "#FF6500" or "rgba(255, 255, 0, .5)".',
|
||||
default=None,
|
||||
help='Set the background color for borders when using "keep" resize mode. Can be any valid CSS color, e.g., "green", "#FF6500" or "rgba(255, 255, 0, .5)". If not set, it defaults to the background color configured in the Manim scene.',
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
"--sa",
|
||||
"--start-at",
|
||||
"start_at",
|
||||
metavar="<SCENE,SLIDE,ANIMATION>",
|
||||
type=str,
|
||||
callback=start_at_callback,
|
||||
default=(None, None, None),
|
||||
help="Start presenting at (x, y, z), equivalent to --sacn x --sasn y --saan z, and overrides values if not None.",
|
||||
)
|
||||
@click.option(
|
||||
"--sacn",
|
||||
"--start-at-scene-number",
|
||||
"start_at_scene_number",
|
||||
metavar="INDEX",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Start presenting at a given scene number (0 is first, -1 is last).",
|
||||
)
|
||||
@click.option(
|
||||
"--sasn",
|
||||
"--start-at-slide-number",
|
||||
"start_at_slide_number",
|
||||
metavar="INDEX",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Start presenting at a given slide number (0 is first, -1 is last).",
|
||||
)
|
||||
@click.option(
|
||||
"--saan",
|
||||
"--start-at-animation-number",
|
||||
"start_at_animation_number",
|
||||
metavar="INDEX",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Start presenting at a given animation number (0 is first, -1 is last). This conflicts with slide number since animation number is absolute to the presentation.",
|
||||
)
|
||||
@click.help_option("-h", "--help")
|
||||
@verbosity_option
|
||||
def present(
|
||||
scenes: List[str],
|
||||
config_path: str,
|
||||
folder: str,
|
||||
config_path: Path,
|
||||
folder: Path,
|
||||
start_paused: bool,
|
||||
fullscreen: bool,
|
||||
skip_all: bool,
|
||||
resolution: Tuple[int, int],
|
||||
record_to: Optional[str],
|
||||
resolution: Optional[Tuple[int, int]],
|
||||
record_to: Optional[Path],
|
||||
exit_after_last_slide: bool,
|
||||
hide_mouse: bool,
|
||||
aspect_ratio: str,
|
||||
resize_mode: str,
|
||||
background_color: str,
|
||||
background_color: Optional[str],
|
||||
start_at: Tuple[Optional[int], Optional[int], Optional[int]],
|
||||
start_at_scene_number: Optional[int],
|
||||
start_at_slide_number: Optional[int],
|
||||
start_at_animation_number: Optional[int],
|
||||
) -> None:
|
||||
"""
|
||||
Present SCENE(s), one at a time, in order.
|
||||
@ -820,12 +1030,22 @@ def present(
|
||||
if skip_all:
|
||||
exit_after_last_slide = True
|
||||
|
||||
presentation_configs = get_scenes_presentation_config(scenes, folder)
|
||||
|
||||
if resolution is not None:
|
||||
for presentation_config in presentation_configs:
|
||||
presentation_config.resolution = resolution
|
||||
|
||||
if background_color is not None:
|
||||
for presentation_config in presentation_configs:
|
||||
presentation_config.background_color = background_color
|
||||
|
||||
presentations = [
|
||||
Presentation(presentation_config)
|
||||
for presentation_config in get_scenes_presentation_config(scenes, folder)
|
||||
for presentation_config in presentation_configs
|
||||
]
|
||||
|
||||
if os.path.exists(config_path):
|
||||
if config_path.exists():
|
||||
try:
|
||||
config = Config.parse_file(config_path)
|
||||
except ValidationError as e:
|
||||
@ -835,12 +1055,21 @@ def present(
|
||||
config = Config()
|
||||
|
||||
if record_to is not None:
|
||||
_, ext = os.path.splitext(record_to)
|
||||
ext = record_to.suffix
|
||||
if ext.lower() != ".avi":
|
||||
raise click.UsageError(
|
||||
"Recording only support '.avi' extension. For other video formats, please convert the resulting '.avi' file afterwards."
|
||||
)
|
||||
|
||||
if start_at[0]:
|
||||
start_at_scene_number = start_at[0]
|
||||
|
||||
if start_at[1]:
|
||||
start_at_slide_number = start_at[1]
|
||||
|
||||
if start_at[2]:
|
||||
start_at_animation_number = start_at[2]
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Manim Slides")
|
||||
a = App(
|
||||
@ -849,13 +1078,14 @@ def present(
|
||||
start_paused=start_paused,
|
||||
fullscreen=fullscreen,
|
||||
skip_all=skip_all,
|
||||
resolution=resolution,
|
||||
record_to=record_to,
|
||||
exit_after_last_slide=exit_after_last_slide,
|
||||
hide_mouse=hide_mouse,
|
||||
aspect_ratio=ASPECT_RATIO_MODES[aspect_ratio],
|
||||
resize_mode=RESIZE_MODES[resize_mode],
|
||||
background_color=background_color,
|
||||
start_at_scene_number=start_at_scene_number,
|
||||
start_at_slide_number=start_at_slide_number,
|
||||
start_at_animation_number=start_at_animation_number,
|
||||
)
|
||||
a.show()
|
||||
sys.exit(app.exec_())
|
||||
|
@ -2,7 +2,8 @@ import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Any, List, Optional
|
||||
from typing import Any, List, Optional, Tuple
|
||||
from warnings import warn
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
@ -46,15 +47,31 @@ class Slide(Scene): # type:ignore
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.output_folder = output_folder
|
||||
self.slides: List[SlideConfig] = []
|
||||
self.current_slide = 1
|
||||
self.current_animation = 0
|
||||
self.loop_start_animation: Optional[int] = None
|
||||
self.pause_start_animation = 0
|
||||
self.__output_folder = output_folder
|
||||
self.__slides: List[SlideConfig] = []
|
||||
self.__current_slide = 1
|
||||
self.__current_animation = 0
|
||||
self.__loop_start_animation: Optional[int] = None
|
||||
self.__pause_start_animation = 0
|
||||
|
||||
@property
|
||||
def partial_movie_files(self) -> List[str]:
|
||||
def __background_color(self) -> str:
|
||||
"""Returns the scene's background color."""
|
||||
if MANIMGL:
|
||||
return self.camera_config["background_color"].hex # type: ignore
|
||||
else:
|
||||
return config["background_color"].hex # type: ignore
|
||||
|
||||
@property
|
||||
def __resolution(self) -> Tuple[int, int]:
|
||||
"""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[str]:
|
||||
"""Returns a list of partial movie files, a.k.a animations."""
|
||||
if MANIMGL:
|
||||
from manimlib.utils.file_ops import get_sorted_integer_files
|
||||
@ -63,100 +80,192 @@ class Slide(Scene): # type:ignore
|
||||
"remove_non_integer_files": True,
|
||||
"extension": self.file_writer.movie_file_extension,
|
||||
}
|
||||
return get_sorted_integer_files(
|
||||
return get_sorted_integer_files( # type: ignore
|
||||
self.file_writer.partial_movie_directory, **kwargs
|
||||
)
|
||||
else:
|
||||
return self.renderer.file_writer.partial_movie_files
|
||||
return self.renderer.file_writer.partial_movie_files # type: ignore
|
||||
|
||||
@property
|
||||
def show_progress_bar(self) -> bool:
|
||||
def __show_progress_bar(self) -> bool:
|
||||
"""Returns True if progress bar should be displayed."""
|
||||
if MANIMGL:
|
||||
return getattr(super(Scene, self), "show_progress_bar", True)
|
||||
return getattr(self, "show_progress_bar", True)
|
||||
else:
|
||||
return config["progress_bar"] != "none"
|
||||
return config["progress_bar"] != "none" # type: ignore
|
||||
|
||||
@property
|
||||
def leave_progress_bar(self) -> bool:
|
||||
def __leave_progress_bar(self) -> bool:
|
||||
"""Returns True if progress bar should be left after completed."""
|
||||
if MANIMGL:
|
||||
return getattr(super(Scene, self), "leave_progress_bars", False)
|
||||
return getattr(self, "leave_progress_bars", False)
|
||||
else:
|
||||
return config["progress_bar"] == "leave"
|
||||
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
|
||||
|
||||
def play(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Overloads `self.play` and increment animation count."""
|
||||
super().play(*args, **kwargs)
|
||||
self.current_animation += 1
|
||||
self.__current_animation += 1
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Creates a new slide with previous animations."""
|
||||
self.slides.append(
|
||||
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;
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class Example(Slide):
|
||||
def construct(self):
|
||||
text = Text("Hello World!")
|
||||
|
||||
self.next_slide()
|
||||
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"
|
||||
|
||||
self.__slides.append(
|
||||
SlideConfig(
|
||||
type=SlideType.slide,
|
||||
start_animation=self.pause_start_animation,
|
||||
end_animation=self.current_animation,
|
||||
number=self.current_slide,
|
||||
start_animation=self.__pause_start_animation,
|
||||
end_animation=self.__current_animation,
|
||||
number=self.__current_slide,
|
||||
)
|
||||
)
|
||||
self.current_slide += 1
|
||||
self.pause_start_animation = self.current_animation
|
||||
self.__current_slide += 1
|
||||
self.__pause_start_animation = self.__current_animation
|
||||
|
||||
def add_last_slide(self) -> None:
|
||||
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 self.current_animation == self.slides[-1].end_animation:
|
||||
self.slides[-1].type = SlideType.last
|
||||
if (
|
||||
len(self.__slides) > 0
|
||||
and self.__current_animation == self.__slides[-1].end_animation
|
||||
):
|
||||
self.__slides[-1].type = SlideType.last
|
||||
return
|
||||
|
||||
self.slides.append(
|
||||
self.__slides.append(
|
||||
SlideConfig(
|
||||
type=SlideType.last,
|
||||
start_animation=self.pause_start_animation,
|
||||
end_animation=self.current_animation,
|
||||
number=self.current_slide,
|
||||
start_animation=self.__pause_start_animation,
|
||||
end_animation=self.__current_animation,
|
||||
number=self.__current_slide,
|
||||
)
|
||||
)
|
||||
|
||||
def start_loop(self) -> None:
|
||||
"""Starts a loop."""
|
||||
assert self.loop_start_animation is None, "You cannot nest loops"
|
||||
self.loop_start_animation = self.current_animation
|
||||
"""
|
||||
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.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
The following contains one slide that will loop endlessly.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class Example(Slide):
|
||||
def construct(self):
|
||||
dot = Dot(color=BLUE)
|
||||
|
||||
self.start_loop()
|
||||
|
||||
self.play(Indicate(dot))
|
||||
|
||||
self.end_loop()
|
||||
"""
|
||||
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."""
|
||||
"""Ends an existing loop. See :func:`start_loop` for more details."""
|
||||
assert (
|
||||
self.loop_start_animation is not None
|
||||
self.__loop_start_animation is not None
|
||||
), "You have to start a loop before ending it"
|
||||
self.slides.append(
|
||||
self.__slides.append(
|
||||
SlideConfig(
|
||||
type=SlideType.loop,
|
||||
start_animation=self.loop_start_animation,
|
||||
end_animation=self.current_animation,
|
||||
number=self.current_slide,
|
||||
start_animation=self.__loop_start_animation,
|
||||
end_animation=self.__current_animation,
|
||||
number=self.__current_slide,
|
||||
)
|
||||
)
|
||||
self.current_slide += 1
|
||||
self.loop_start_animation = None
|
||||
self.pause_start_animation = self.current_animation
|
||||
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:
|
||||
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()
|
||||
self.__add_last_slide()
|
||||
|
||||
if not os.path.exists(self.output_folder):
|
||||
os.mkdir(self.output_folder)
|
||||
if not os.path.exists(self.__output_folder):
|
||||
os.mkdir(self.__output_folder)
|
||||
|
||||
files_folder = os.path.join(self.output_folder, "files")
|
||||
files_folder = os.path.join(self.__output_folder, "files")
|
||||
if not os.path.exists(files_folder):
|
||||
os.mkdir(files_folder)
|
||||
|
||||
scene_name = type(self).__name__
|
||||
scene_name = str(self)
|
||||
scene_files_folder = os.path.join(files_folder, scene_name)
|
||||
|
||||
old_animation_files = set()
|
||||
@ -171,12 +280,18 @@ class Slide(Scene): # type:ignore
|
||||
|
||||
files = []
|
||||
for src_file in tqdm(
|
||||
self.partial_movie_files,
|
||||
self.__partial_movie_files,
|
||||
desc=f"Copying animation files to '{scene_files_folder}' and generating reversed animations",
|
||||
leave=self.leave_progress_bar,
|
||||
leave=self.__leave_progress_bar,
|
||||
ascii=True if platform.system() == "Windows" else None,
|
||||
disable=not self.show_progress_bar,
|
||||
disable=not self.__show_progress_bar,
|
||||
):
|
||||
if src_file is None and not MANIMGL:
|
||||
# This happens if rendering with -na,b (manim only)
|
||||
# where animations not in [a,b] will be skipped
|
||||
# but animations before a will have a None src_file
|
||||
continue
|
||||
|
||||
filename = os.path.basename(src_file)
|
||||
rev_filename = "{}_reversed{}".format(*os.path.splitext(filename))
|
||||
|
||||
@ -196,14 +311,30 @@ class Slide(Scene): # type:ignore
|
||||
|
||||
files.append(dst_file)
|
||||
|
||||
if offset := self.__start_at_animation_number:
|
||||
self.__slides = [
|
||||
slide for slide in self.__slides if slide.end_animation > offset
|
||||
]
|
||||
|
||||
for slide in self.__slides:
|
||||
slide.start_animation -= offset
|
||||
slide.end_animation -= offset
|
||||
|
||||
logger.info(
|
||||
f"Copied {len(files)} animations to '{os.path.abspath(scene_files_folder)}' and generated reversed animations"
|
||||
)
|
||||
|
||||
slide_path = os.path.join(self.output_folder, "%s.json" % (scene_name,))
|
||||
slide_path = os.path.join(self.__output_folder, "%s.json" % (scene_name,))
|
||||
|
||||
with open(slide_path, "w") as f:
|
||||
f.write(PresentationConfig(slides=self.slides, files=files).json(indent=2))
|
||||
f.write(
|
||||
PresentationConfig(
|
||||
slides=self.__slides,
|
||||
files=files,
|
||||
resolution=self.__resolution,
|
||||
background_color=self.__background_color,
|
||||
).json(indent=2)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Slide '{scene_name}' configuration written in '{os.path.abspath(slide_path)}'"
|
||||
@ -212,11 +343,11 @@ class Slide(Scene): # type:ignore
|
||||
def run(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""MANIMGL renderer"""
|
||||
super().run(*args, **kwargs)
|
||||
self.save_slides(use_cache=False)
|
||||
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 intermidiate files
|
||||
# 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")
|
||||
|
||||
@ -224,7 +355,7 @@ class Slide(Scene): # type:ignore
|
||||
|
||||
config["max_files_cached"] = max_files_cached
|
||||
|
||||
self.save_slides()
|
||||
self.__save_slides()
|
||||
|
||||
|
||||
class ThreeDSlide(Slide, ThreeDScene): # type: ignore
|
||||
|
@ -21,7 +21,7 @@ from PySide6.QtWidgets import (
|
||||
from .commons import config_options, verbosity_option
|
||||
from .config import Config, Key
|
||||
from .defaults import CONFIG_PATH
|
||||
from .manim import logger
|
||||
from .logger import logger
|
||||
from .resources import * # noqa: F401, F403
|
||||
|
||||
WINDOW_NAME: str = "Configuration Wizard"
|
||||
@ -51,7 +51,6 @@ class KeyInput(QDialog): # type: ignore
|
||||
|
||||
class Wizard(QWidget): # type: ignore
|
||||
def __init__(self, config: Config):
|
||||
|
||||
super().__init__()
|
||||
|
||||
self.setWindowTitle(WINDOW_NAME)
|
||||
|
BIN
paper/docs.png
Normal file
After Width: | Height: | Size: 4.3 MiB |
53
paper/paper.bib
Normal file
@ -0,0 +1,53 @@
|
||||
@online{manim-announcement,
|
||||
author = {Grant Sanderson},
|
||||
title = {{Q}\&{A} with {G}rant {S}anderson (3blue1brown)},
|
||||
year = {2018},
|
||||
organization = {YouTube},
|
||||
url = {https://www.youtube.com/watch?v=Qe6o9j4IjTo\&ab_channel=3Blue1Brown}
|
||||
}
|
||||
|
||||
@misc{revealjs,
|
||||
author = {Hakim El Hattab},
|
||||
title = {The HTML Presentation Framework},
|
||||
year = {2022},
|
||||
publisher = {GitHub},
|
||||
journal = {GitHub repository},
|
||||
url = {https://github.com/hakimel/reveal.js}
|
||||
}
|
||||
|
||||
@misc{manim-presentation,
|
||||
author = {Federico Galatolo},
|
||||
title = {Tool for live presentations using manim},
|
||||
year = {2021},
|
||||
publisher = {GitHub},
|
||||
journal = {GitHub repository},
|
||||
url = {https://github.com/galatolofederico/manim-presentation}
|
||||
}
|
||||
|
||||
@misc{manimgl,
|
||||
author = {Grant Sanderson},
|
||||
title = {Animation engine for explanatory math videos},
|
||||
year = {2022},
|
||||
publisher = {GitHub},
|
||||
journal = {GitHub repository},
|
||||
url = {https://github.com/3b1b/manim}
|
||||
}
|
||||
|
||||
@misc{manim-editor,
|
||||
author = {Christopher Besch},
|
||||
title = {Web Presenter for Mathematical Animations using Manim},
|
||||
year = {2022},
|
||||
publisher = {GitHub},
|
||||
journal = {GitHub repository},
|
||||
url = {https://github.com/ManimCommunity/manim_editor}
|
||||
}
|
||||
|
||||
@software{manimce,
|
||||
author = {{The Manim Community Developers}},
|
||||
license = {MIT},
|
||||
month = {12},
|
||||
title = {{Manim – Mathematical Animation Framework}},
|
||||
url = {https://www.manim.community/},
|
||||
version = {v0.17.2},
|
||||
year = {2022}
|
||||
}
|
174
paper/paper.md
Normal file
@ -0,0 +1,174 @@
|
||||
---
|
||||
title: 'Manim Slides: A Python package for presenting Manim content anywhere'
|
||||
tags:
|
||||
- Python
|
||||
- manim
|
||||
- animations
|
||||
- teaching
|
||||
- conference presentations
|
||||
- tool
|
||||
authors:
|
||||
- name: Jérome Eertmans
|
||||
orcid: 0000-0002-5579-5360
|
||||
affiliation: 1
|
||||
affiliations:
|
||||
- name: ICTEAM, UCLouvain, Belgium
|
||||
index: 1
|
||||
date: 2 March 2023
|
||||
bibliography: paper.bib
|
||||
---
|
||||
|
||||
# Summary
|
||||
|
||||
Manim Slides is a Python package that makes presenting Manim animations
|
||||
straightforward. With minimal changes required to pre-existing code, one can
|
||||
slide through animations in a *PowerPoint-like* manner, or share its slides
|
||||
*online* using ReavealJS' power.
|
||||
|
||||
# Introduction
|
||||
|
||||
Presenting educational content has always been a difficult task, especially
|
||||
when it uses temporal or iterative concepts. During the last decades, the
|
||||
presence of computers in classrooms for educational purposes has increased
|
||||
enormously, allowing teachers to show animated or interactive content.
|
||||
|
||||
With the democratization of YouTube, many people have decided to use this
|
||||
platform to share educational content. Among these people, Grant Sanderson, a
|
||||
YouTuber presenting videos on the theme of mathematics, quickly became known
|
||||
for his original and quality animations. In 2018, Grant announced in a video
|
||||
that he creates his animations using a self-developed Python tool called Manim
|
||||
[@manim-announcement]. In 2019, he made the Manim source code public [@manimgl],
|
||||
so anyone can use his tool. Very quickly, the community came together and, in
|
||||
2020, created a "fork" of the original GitHub repository [@manimce], in order to
|
||||
create a more accessible and better documented version of this tool. Since then,
|
||||
the two versions are differentiated by using ManimGL for Grant Sanderson's
|
||||
version, as it uses OpenGL for rendering, and ManimCE for the community edition
|
||||
(CE).
|
||||
|
||||
Despite the many advantages of the Manim tool in terms of illustrating
|
||||
mathematical concepts, one cannot help but notice that most presentations,
|
||||
whether in the classroom or at a conference, are mainly done with PowerPoint
|
||||
or PDF slides. One of the many advantages of these formats, as opposed to videos
|
||||
created with Manim, is the ability to pause, rewind, etc., whenever you want.
|
||||
|
||||
To face this problem, in 2021, the manim-presentation tool was created
|
||||
[@manim-presentation]. This tool allows you to present Manim animations as you
|
||||
would present slides: with pauses, slide jumps, etc. However, this tool has
|
||||
evolved very little since its inception and does not work with ManimGL.
|
||||
|
||||
In 2022, Manim Slides has been created from manim-presentation, with the aim
|
||||
to make it a more complete tool, better documented, and usable on all platforms
|
||||
and with ManimCE or ManimGL. After almost a year of existence, Manim Slides has
|
||||
evolved a lot (see
|
||||
[comparison section](#comparison-with-manim-presentation)),
|
||||
has built a small community of contributors, and continues to
|
||||
provide new features on a regular basis.
|
||||
|
||||
# Easy to Use Commitment
|
||||
|
||||
Manim Slides is commited to be an easy-to-use tool, with minimal installation
|
||||
procedure and few modifications required. It can either be used locally with its
|
||||
graphical user interface (GUI), or shared via one of the two formats it can
|
||||
convert to:
|
||||
|
||||
* an HTML page thanks to the RevealJS Javascript package [@revealjs];
|
||||
* or a PowerPoint (`.pptx`) file.
|
||||
|
||||
This work has a very similar syntax to Manim and offers a comprehensive
|
||||
documentation hosted on [GitHub pages](https://jeertmans.github.io/manim-slides/), see
|
||||
\autoref{fig:docs}.
|
||||
|
||||

|
||||
|
||||
# Example usage
|
||||
|
||||
We have used manim-presentation for our presentation at the COST
|
||||
Interact, hosted in Lyon, 2022, and
|
||||
[available online](https://web.archive.org/web/20230507184944/https://eertmans.be/posts/cost-interact-presentation/).
|
||||
This experience highly motivated the development of Manim Slides, and our
|
||||
EuCAP 2023 presentation slides are already
|
||||
[available online](https://web.archive.org/web/20230507211243/https://eertmans.be/posts/eucap-presentation/), thanks
|
||||
to Manim Slides' HTML feature.
|
||||
|
||||
Also, one of our users created a short
|
||||
[video tutorial](https://www.youtube.com/watch?v=Oc9g89VzKsY&ab_channel=TheoremofBeethoven)
|
||||
and posted it on YouTube.
|
||||
|
||||
# Stability and releases
|
||||
|
||||
Manim Slides is continously tested on most recent Python versions, both ManimCE
|
||||
and ManimGL, and on all major platforms: **Ubuntu**, **macOS** and **Windows**. Due to Manim
|
||||
Slide's exposed API being very minimal, and the variety of tests that are
|
||||
performed, this tool can be considered stable over time.
|
||||
|
||||
New releases are very frequent, as they mostly introduce enhancements or small
|
||||
documention fixes, and the command-line tool automatically notifies for new
|
||||
available updates. Therefore, regularly updating Manim Slides is highly
|
||||
recommended.
|
||||
|
||||
# Statement of Need
|
||||
|
||||
Similar tools to Manim Slides also exist, such as its predecessor,
|
||||
manim-presentation [@manim-presentation], or the web-based alternative, Manim
|
||||
Editor [@manim-editor], but none of them provide the documentation level nor the
|
||||
amount of features that Manim Slides strives to. This work makes the task of
|
||||
presenting Manim content in front of an audience much easier than before,
|
||||
allowing presenters to focus more on the content of their slides, rather than on
|
||||
how to actually present them efficiently.
|
||||
|
||||
## Target Audience
|
||||
|
||||
Manim Slides was developed with the goal of making educational content more
|
||||
accessible than ever. We believe that researchers, professors, teaching
|
||||
assistants and anyone else who needs to teach scientific content can benefit
|
||||
from using this tool. The ability to pace your presentation yourself is
|
||||
essential, and Manim Slides gives you that ability.
|
||||
|
||||
## A Need for Portability
|
||||
|
||||
One of the major concerns with presenting content in a non-standard format
|
||||
(i.e., not just a plain PDF) is the issue of portability.
|
||||
Depending on the programs available, the power of the target computer,
|
||||
or the access to the internet, not all solutions are equal.
|
||||
From the same configuration file, Manim Slides offers a series of solutions to
|
||||
share your slides, which we discuss on our
|
||||
[Sharing your slides](https://jeertmans.github.io/manim-slides/reference/sharing.html)
|
||||
page.
|
||||
|
||||
## Comparison with manim-presentation
|
||||
|
||||
Starting from [@manim-presentation]'s original work, Manim Slides now provides
|
||||
numerous additional features.
|
||||
A non-exhaustive list of those new features is as follows:
|
||||
|
||||
* ManimGL compatibility;
|
||||
* playing slides in reverse;
|
||||
* exporting slides to HTML and PowerPoint;
|
||||
* 3D scene support;
|
||||
* multiple key inputs can map to the same action
|
||||
(e.g., useful when using a pointer);
|
||||
* optionally hiding mouse cursor when presenting;
|
||||
* recording your presentation;
|
||||
* multiple video scaling methods (for speed-vs-quality tradeoff);
|
||||
* and automatic detection of some scene parameters
|
||||
(e.g., resolution or background color).
|
||||
|
||||
The complete and up-to-date set of features Manim Slide supports is
|
||||
available in the
|
||||
[online documentation](https://jeertmans.github.io/manim-slides/).
|
||||
For new feature requests, we highly encourage users to
|
||||
[create an issue](https://github.com/jeertmans/manim-slides/issues/new/choose)
|
||||
with the appropriate template.
|
||||
|
||||
# Acknowledgements
|
||||
|
||||
We acknowledge the work of [@manim-presentation] that paved the initial structure
|
||||
of Manim Slides with the manim-presentation Python package.
|
||||
|
||||
We also acknowledge Grant Sanderson for his tremendous work on Manim, as
|
||||
well as the Manim Community contributors.
|
||||
|
||||
Finally, we also acknowledge contributions from the GitHub contributors on the
|
||||
Manim Slides repository.
|
||||
|
||||
# References
|
1480
poetry.lock
generated
@ -10,28 +10,10 @@ profile = "black"
|
||||
py_version = 38
|
||||
|
||||
[tool.mypy]
|
||||
check-untyped-defs = true
|
||||
# Disallow dynamic typing
|
||||
disallow-any-generics = true
|
||||
disallow-incomplete-defs = true
|
||||
disallow-subclassing-any = true
|
||||
# Disallow untyped definitions and calls
|
||||
disallow-untyped-defs = true
|
||||
ignore-missing-imports = true
|
||||
install-types = true
|
||||
# None and optional handling
|
||||
no-implicit-optional = true
|
||||
no-warn-return-any = true
|
||||
non-interactive = true
|
||||
disallow_untyped_decorators = false
|
||||
install_types = true
|
||||
python_version = "3.8"
|
||||
# Strict equality
|
||||
strict-equality = true
|
||||
warn-no-return = true
|
||||
warn-redundant-casts = true
|
||||
# Config file
|
||||
warn-unused-configs = true
|
||||
# Configuring warnings
|
||||
warn-unused-ignores = true
|
||||
strict = true
|
||||
|
||||
[tool.poetry]
|
||||
authors = [
|
||||
@ -61,19 +43,28 @@ packages = [
|
||||
]
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/jeertmans/manim-slides"
|
||||
version = "4.8.3"
|
||||
version = "4.13.2"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
click = "^8.1.3"
|
||||
click-default-group = "^1.2.2"
|
||||
lxml = "^4.9.2"
|
||||
manim = {version = "^0.17.0", optional = true}
|
||||
manimgl = {version = "^1.6.1", optional = true}
|
||||
numpy = "^1.19"
|
||||
opencv-python = "^4.6.0.66"
|
||||
pydantic = "^1.10.2"
|
||||
pyside6 = "^6.4.1"
|
||||
python = ">=3.8.1,<3.12"
|
||||
python-pptx = "^0.6.21"
|
||||
requests = "^2.28.1"
|
||||
rich = "^13.3.2"
|
||||
tqdm = "^4.64.1"
|
||||
|
||||
[tool.poetry.extras]
|
||||
manim = ["manim"]
|
||||
manimgl = ["manimgl"]
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = "^22.10.0"
|
||||
bump2version = "^1.0.1"
|
||||
|
BIN
static/logo.png
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 110 KiB |
@ -1,17 +1,29 @@
|
||||
# flake8: noqa: F403, F405
|
||||
# type: ignore
|
||||
import os
|
||||
|
||||
from manim import *
|
||||
|
||||
THEME = os.environ.get("MANIM_SLIDES_THEME", "light").lower().replace("_", "-")
|
||||
|
||||
|
||||
class ManimSlidesLogo(Scene):
|
||||
def construct(self):
|
||||
tex_template = TexTemplate()
|
||||
tex_template.add_to_preamble(r"\usepackage{graphicx}\usepackage{fontawesome5}")
|
||||
self.camera.background_color = "#ffffff"
|
||||
self.camera.background_color = {
|
||||
"light": "#ffffff",
|
||||
"dark-docs": "#131416",
|
||||
"dark-github": "#0d1117",
|
||||
}[THEME]
|
||||
logo_green = "#87c2a5"
|
||||
logo_blue = "#525893"
|
||||
logo_red = "#e07a5f"
|
||||
logo_black = "#343434"
|
||||
logo_black = {
|
||||
"light": "#343434",
|
||||
"dark-docs": "#d0d0d0",
|
||||
"dark-github": "#c9d1d9",
|
||||
}[THEME]
|
||||
ds_m = MathTex(r"\mathbb{M}", fill_color=logo_black).scale(7)
|
||||
ds_m.shift(2.25 * LEFT + 1.5 * UP)
|
||||
slides = MathTex(r"\mathbb{S}\text{lides}", fill_color=logo_black).scale(4)
|
||||
|
BIN
static/logo_dark_docs.png
Normal file
After Width: | Height: | Size: 112 KiB |
BIN
static/logo_dark_github.png
Normal file
After Width: | Height: | Size: 113 KiB |
BIN
static/logo_dark_transparent.png
Normal file
After Width: | Height: | Size: 124 KiB |
BIN
static/logo_light_transparent.png
Normal file
After Width: | Height: | Size: 116 KiB |
21
static/make_logo.sh
Executable file
@ -0,0 +1,21 @@
|
||||
#! /bin/bash
|
||||
|
||||
MANIM_SLIDES_THEME=light poetry run manim render -qk -s --format png --resolution 2560,1280 static/logo.py && mv media/images/logo/*.png static/logo.png
|
||||
|
||||
ln -f -r -s static/logo.png docs/source/_static/logo.png
|
||||
|
||||
MANIM_SLIDES_THEME=dark_docs poetry run manim render -qk -s --format png --resolution 2560,1280 static/logo.py && mv media/images/logo/*.png static/logo_dark_docs.png
|
||||
|
||||
ln -f -r -s static/logo_dark_docs.png docs/source/_static/logo_dark_docs.png
|
||||
|
||||
MANIM_SLIDES_THEME=dark_github poetry run manim render -qk -s --format png --resolution 2560,1280 static/logo.py && mv media/images/logo/*.png static/logo_dark_github.png
|
||||
|
||||
ln -f -r -s static/logo_dark_github.png docs/source/_static/logo_dark_github.png
|
||||
|
||||
MANIM_SLIDES_THEME=light poetry run manim render -t -qk -s --format png --resolution 2560,1280 static/logo.py && mv media/images/logo/*.png static/logo_light_transparent.png
|
||||
|
||||
ln -f -r -s static/logo_light_transparent.png docs/source/_static/logo_light_transparent.png
|
||||
|
||||
MANIM_SLIDES_THEME=dark_docs poetry run manim render -t -qk -s --format png --resolution 2560,1280 static/logo.py && mv media/images/logo/*.png static/logo_dark_transparent.png
|
||||
|
||||
ln -f -r -s static/logo_dark_transparent.png docs/source/_static/logo_dark_transparent.png
|
BIN
static/wizard_dark.png
Normal file
After Width: | Height: | Size: 26 KiB |
BIN
static/wizard_light.png
Normal file
After Width: | Height: | Size: 26 KiB |