Compare commits
109 Commits
v4.16.0
...
v5.1.0-rc3
Author | SHA1 | Date | |
---|---|---|---|
e60edcc960 | |||
c1d2dc26b2 | |||
0db4a8c260 | |||
cb4f6f552c | |||
9cd50e73b2 | |||
cea3d0b0c3 | |||
75126d8bab | |||
be227bbdea | |||
62560ea41f | |||
4b8f90c1fa | |||
8b1c45b84e | |||
34cc66d8be | |||
57e11a0ea7 | |||
de342b32bb | |||
6bd431d748 | |||
2e996c03a7 | |||
d9715ccb96 | |||
e3b3dd677f | |||
db91de1412 | |||
bf67a7b695 | |||
ea2d352fc1 | |||
74ddefe519 | |||
20b7ef4110 | |||
19a31a9136 | |||
d2925340aa | |||
afeaa0d793 | |||
af3c4971ae | |||
b3ed127e31 | |||
fc200f22f5 | |||
050ee0ae78 | |||
a9b8081167 | |||
dc58d498a8 | |||
f898dd3054 | |||
b09a000c17 | |||
eb8efa8e3d | |||
1f0c94dc5c | |||
2771aac4d0 | |||
ce799aeded | |||
891273b2fc | |||
2b25b6a89d | |||
fc594533e9 | |||
422102524f | |||
739cbcee0a | |||
3b3e3109a3 | |||
8921c3b8f9 | |||
5eb23dc5c1 | |||
a890832a4d | |||
106c7d4c06 | |||
6c52906037 | |||
2853ed08e1 | |||
760ceb8ce1 | |||
2fa0301935 | |||
61b983db3a | |||
56b1ffe430 | |||
541bf96945 | |||
626764146a | |||
0322dae743 | |||
7928f6020c | |||
f3dfa782b0 | |||
e13ca7e0dc | |||
6c9505b98a | |||
7b3a5c4824 | |||
5daa94b823 | |||
498e33ad8d | |||
860ab231b5 | |||
c075904a27 | |||
387d0f76b5 | |||
1126a20785 | |||
802f6406ae | |||
685f871186 | |||
da14b5f24a | |||
18a9906ae5 | |||
a0ee723c89 | |||
2b7bd0a68d | |||
497e4e964f | |||
86fc774a3d | |||
ce14c79230 | |||
6272d3f7ec | |||
bbe8b96030 | |||
1bc8423381 | |||
67147442f3 | |||
859d48ad2e | |||
9a23296fa2 | |||
0f5b374bce | |||
2dc4c1ab99 | |||
f2ee29ad58 | |||
d127af9dd2 | |||
05ebf40543 | |||
9cc7957e35 | |||
b72b7bc256 | |||
4a1b8aea87 | |||
c2b12d16eb | |||
bb5b294f40 | |||
9a3a343231 | |||
0f07d36f52 | |||
806b7d00f6 | |||
3d9522cbb0 | |||
28c5336b83 | |||
48614105bd | |||
933afdd465 | |||
599f9f22ae | |||
5490a0a5ef | |||
c875363b40 | |||
bd9bf06876 | |||
4d76f2ccc1 | |||
8cf05ea44d | |||
638616c94f | |||
b321161717 | |||
7363281ff0 |
@ -1,5 +1,9 @@
|
||||
[bumpversion]
|
||||
current_version = 4.16.0
|
||||
current_version = 5.1.0-rc3
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(-rc(?P<release>\d+))?
|
||||
serialize =
|
||||
{major}.{minor}.{patch}-rc{release}
|
||||
{major}.{minor}.{patch}
|
||||
commit = True
|
||||
message = chore(version): bump {current_version} to {new_version}
|
||||
|
||||
@ -7,10 +11,6 @@ message = chore(version): bump {current_version} to {new_version}
|
||||
search = __version__ = "{current_version}"
|
||||
replace = __version__ = "{new_version}"
|
||||
|
||||
[bumpversion:file:pyproject.toml]
|
||||
search = version = "{current_version}"
|
||||
replace = version = "{new_version}"
|
||||
|
||||
[bumpversion:file:CITATION.cff]
|
||||
search = version: v{current_version}
|
||||
replace = version: v{new_version}
|
||||
|
2
.github/workflows/clearcache.yml
vendored
@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Cleanup
|
||||
run: |
|
||||
|
8
.github/workflows/codeql-analysis.yml
vendored
@ -38,11 +38,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@ -56,7 +56,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@ -69,4 +69,4 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
uses: github/codeql-action/analyze@v3
|
||||
|
48
.github/workflows/coverage.yml
vendored
@ -1,48 +0,0 @@
|
||||
on: [push]
|
||||
|
||||
name: Code Coverage
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
QT_QPA_PLATFORM: offscreen
|
||||
MANIM_SLIDES_VERBOSITY: debug
|
||||
PYTHONFAULTHANDLER: 1
|
||||
DISPLAY: :99
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Poetry
|
||||
run: pipx install poetry
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: poetry
|
||||
|
||||
- name: Install manim dependencies on Ubuntu
|
||||
run: |
|
||||
sudo apt-get install libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev
|
||||
|
||||
- name: Install xvfb on Ubuntu
|
||||
run: |
|
||||
sudo apt-get install xvfb
|
||||
nohup Xvfb $DISPLAY &
|
||||
|
||||
- name: Install Manim Slides
|
||||
run: |
|
||||
poetry install --with test
|
||||
|
||||
- name: Run pytest and coverage
|
||||
run: poetry run pytest --cov-report xml --cov=manim_slides tests/
|
||||
|
||||
- name: Upload to codecov.io
|
||||
uses: codecov/codecov-action@v3
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
fail_ci_if_error: true
|
4
.github/workflows/draft-pdf.yml
vendored
@ -16,7 +16,7 @@ jobs:
|
||||
name: Paper Draft
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Build draft PDF
|
||||
uses: openjournals/openjournals-draft-action@master
|
||||
with:
|
||||
@ -24,7 +24,7 @@ jobs:
|
||||
# This should be the path to the paper within your repo.
|
||||
paper-path: paper/paper.md
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: paper
|
||||
# This is the output path where Pandoc will write the compiled
|
||||
|
2
.github/workflows/languagetool.yml
vendored
@ -8,7 +8,7 @@ jobs:
|
||||
languagetool_check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: reviewdog/action-languagetool@v1
|
||||
with:
|
||||
reporter: github-pr-review
|
||||
|
13
.github/workflows/latest_tag.yml
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
name: Keep the versions up-to-date
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published, edited]
|
||||
|
||||
jobs:
|
||||
actions-tagger:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: Actions-R-Us/actions-tagger@latest
|
||||
with:
|
||||
publish_latest_tag: true
|
57
.github/workflows/pages.yml
vendored
@ -32,65 +32,66 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Install Poetry
|
||||
run: pipx install poetry
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install PDM
|
||||
uses: pdm-project/setup-pdm@v3
|
||||
with:
|
||||
python-version: '3.9'
|
||||
cache: poetry
|
||||
python-version: '3.10'
|
||||
cache: true
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v3
|
||||
uses: actions/configure-pages@v4
|
||||
|
||||
- name: Install Linux Dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev
|
||||
|
||||
- name: Setup Pandoc
|
||||
uses: nikeee/setup-pandoc@v1
|
||||
|
||||
- name: Install local Python package
|
||||
run: poetry install --with docs
|
||||
run: pdm install -dGdocs -dGgithub-action
|
||||
|
||||
- name: Install IPython kernel
|
||||
run: poetry run ipython kernel install --name "manim-slides" --user
|
||||
run: pdm run ipython kernel install --name "manim-slides" --user
|
||||
|
||||
- name: Restore cached media
|
||||
id: cache-media-restore
|
||||
uses: actions/cache/restore@v3
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: media
|
||||
key: ${{ runner.os }}-media
|
||||
- name: Build animations
|
||||
run: |
|
||||
poetry run manim example.py ConvertExample BasicExample ThreeDExample
|
||||
- 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'
|
||||
path: docs/media
|
||||
key: ${{ runner.os }}-docs-media
|
||||
|
||||
- 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
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: media
|
||||
path: docs/media
|
||||
key: ${{ steps.cache-media-restore.outputs.cache-primary-key }}
|
||||
|
||||
- name: Build docs
|
||||
run: cd docs && poetry run make html
|
||||
run: cd docs && pdm run make html
|
||||
|
||||
- name: Upload artifact
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-pages-artifact@v2
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
# Upload docs/build/html dir
|
||||
path: docs/build/html/
|
||||
|
||||
- name: Show docs/build/html/_static/ dir content (video only)
|
||||
run: tree -L 3 docs/build/html/_static/ -P '*.mp4'
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/deploy-pages@v2
|
||||
uses: actions/deploy-pages@v4
|
||||
|
71
.github/workflows/publish.yml
vendored
Normal file
@ -0,0 +1,71 @@
|
||||
name: Upload Packages
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
publish-python:
|
||||
name: Build and release
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
# IMPORTANT: this permission is mandatory for trusted publishing
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install PDM
|
||||
uses: pdm-project/setup-pdm@v3
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: true
|
||||
|
||||
- name: Publish to PyPI
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
run: pdm publish
|
||||
|
||||
publish-docker:
|
||||
name: Publish Docker image
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}`
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: create_release
|
||||
shell: python
|
||||
env:
|
||||
tag_act: ${{ github.ref }}
|
||||
run: |
|
||||
import os
|
||||
ref_tag = os.getenv('tag_act').split('/')[-1]
|
||||
with open(os.getenv('GITHUB_OUTPUT'), 'w') as f:
|
||||
print(f"tag_name={ref_tag}", file=f)
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
platforms: linux/arm64,linux/amd64
|
||||
file: docker/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
jeertmans/manim-slides:latest
|
||||
jeertmans/manim-slides:${{ steps.create_release.outputs.tag_name }}
|
34
.github/workflows/python-publish.yml
vendored
@ -1,34 +0,0 @@
|
||||
name: Upload Python Package
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build_and_release:
|
||||
name: Build and release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Poetry
|
||||
run: pipx install poetry
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: poetry
|
||||
|
||||
- name: Build wheels
|
||||
run: poetry build
|
||||
|
||||
- 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
|
143
.github/workflows/tests.yml
vendored
@ -1,4 +1,7 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@ -9,91 +12,24 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
pyversion: ['3.8', '3.9', '3.10', '3.11']
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
QT_QPA_PLATFORM: offscreen
|
||||
MANIM_SLIDES_VERBOSITY: debug
|
||||
PYTHONFAULTHANDLER: 1
|
||||
DISPLAY: :99
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Poetry
|
||||
run: pipx install poetry
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.pyversion }}
|
||||
cache: poetry
|
||||
|
||||
- name: Run apt-get update on Ubuntu
|
||||
run: sudo apt-get update
|
||||
|
||||
- name: Install manim dependencies on Ubuntu
|
||||
run: |
|
||||
sudo apt-get install libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev
|
||||
|
||||
- name: Install xvfb on Ubuntu
|
||||
run: |
|
||||
sudo apt-get install xvfb
|
||||
nohup Xvfb $DISPLAY &
|
||||
|
||||
- name: Install Manim Slides
|
||||
run: |
|
||||
poetry install --with test
|
||||
|
||||
- name: Run pytest
|
||||
run: poetry run pytest -x
|
||||
|
||||
build-examples:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
manim: [manim, manimgl]
|
||||
os: [macos-latest, ubuntu-latest, windows-latest]
|
||||
pyversion: ['3.8', '3.9', '3.10', '3.11']
|
||||
exclude:
|
||||
# excludes manimgl on Windows because if throws errors
|
||||
# related to OpenGL, which seems hard to fix:
|
||||
# Your graphics drivers do not support OpenGL 2.0.
|
||||
- os: windows-latest
|
||||
manim: manimgl
|
||||
# We only test Python 3.11 on Windows and MacOS
|
||||
- os: windows-latest
|
||||
pyversion: '3.8'
|
||||
- os: windows-latest
|
||||
pyversion: '3.9'
|
||||
- os: windows-latest
|
||||
pyversion: '3.10'
|
||||
manim: manim
|
||||
- os: macos-latest
|
||||
pyversion: '3.8'
|
||||
- os: macos-latest
|
||||
pyversion: '3.9'
|
||||
- os: macos-latest
|
||||
pyversion: '3.10'
|
||||
manim: manim
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
QT_QPA_PLATFORM: offscreen
|
||||
MANIM_SLIDES_VERBOSITY: debug
|
||||
MANIM_SLIDES_VERBOSITY: error
|
||||
PYTHONFAULTHANDLER: 1
|
||||
DISPLAY: :99
|
||||
GITHUB_WORKFLOWS: 1
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Poetry
|
||||
run: pipx install poetry
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v4
|
||||
- name: Install PDM
|
||||
uses: pdm-project/setup-pdm@v3
|
||||
with:
|
||||
python-version: ${{ matrix.pyversion }}
|
||||
cache: poetry
|
||||
cache: true
|
||||
|
||||
# Path related stuff
|
||||
- name: Append to Path on MacOS
|
||||
@ -111,31 +47,15 @@ jobs:
|
||||
run: echo "${HOME}/.local/bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
|
||||
# OS depedencies
|
||||
- name: Install manim dependencies on MacOs
|
||||
if: matrix.os == 'macos-latest' && matrix.manim == 'manim'
|
||||
- name: Install manim dependencies on MacOS
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: brew install ffmpeg py3cairo
|
||||
|
||||
- name: Install manimgl dependencies on MacOS
|
||||
if: matrix.os == 'macos-latest' && matrix.manim == 'manimgl'
|
||||
run: brew install ffmpeg
|
||||
|
||||
- name: Run apt-get update on Ubuntu
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: sudo apt-get update
|
||||
|
||||
- name: Install manim dependencies on Ubuntu
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.manim == 'manim'
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev
|
||||
|
||||
- name: Install manimgl dependencies on Ubuntu
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.manim == 'manimgl'
|
||||
run: |
|
||||
sudo apt-get install libpango1.0-dev ffmpeg freeglut3-dev
|
||||
|
||||
- name: Install xvfb on Ubuntu
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.manim == 'manimgl'
|
||||
run: |
|
||||
sudo apt-get install xvfb
|
||||
nohup Xvfb $DISPLAY &
|
||||
|
||||
@ -143,27 +63,26 @@ jobs:
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: choco install ffmpeg
|
||||
|
||||
# Install Manim Slides
|
||||
- name: Install Mesa
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: ssciwr/setup-mesa-dist-win@v1
|
||||
|
||||
- name: Install Manim Slides
|
||||
run: |
|
||||
poetry install --extras ${{ matrix.manim }}
|
||||
pdm install -dGgithub-action -dGtest
|
||||
|
||||
# Render slides
|
||||
- name: Render slides
|
||||
if: matrix.manim == 'manim'
|
||||
run: poetry run manim -ql example.py BasicExample ThreeDExample
|
||||
- name: Run pytest
|
||||
if: matrix.os != 'ubuntu-latest' || matrix.pyversion != '3.11'
|
||||
run: pdm run pytest
|
||||
|
||||
- name: Render slides
|
||||
if: matrix.manim == 'manimgl'
|
||||
run: poetry run -v manimgl -l example.py BasicExample ThreeDExample
|
||||
- name: Run pytest and coverage
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.pyversion == '3.11'
|
||||
run: pdm run pytest --cov-report xml --cov=manim_slides tests/
|
||||
|
||||
# Play slides
|
||||
- name: Test slides
|
||||
run: poetry run manim-slides BasicExample ThreeDExample --skip-all
|
||||
|
||||
# Test slides to html
|
||||
- name: Test convert on Ubuntu
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.manim == 'manim'
|
||||
run: |
|
||||
poetry run manim -ql example.py ConvertExample
|
||||
poetry run manim-slides convert --to=html ConvertExample index.html
|
||||
- name: Upload to codecov.io
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.pyversion == '3.11'
|
||||
uses: codecov/codecov-action@v3
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
|
6
.gitignore
vendored
@ -4,10 +4,12 @@ __pycache__/
|
||||
/build
|
||||
/dist
|
||||
*.egg-info/
|
||||
.pdm-python
|
||||
|
||||
# Manim files
|
||||
images/
|
||||
/media
|
||||
tests/data/media/
|
||||
docs/source/media/
|
||||
|
||||
# ManimGL files
|
||||
@ -17,7 +19,7 @@ videos/
|
||||
.manim-slides.toml
|
||||
|
||||
slides/
|
||||
!tests/slides/
|
||||
!tests/data/slides/
|
||||
|
||||
slides_assets/
|
||||
|
||||
@ -44,3 +46,5 @@ paper/media/
|
||||
|
||||
# Others
|
||||
coverage.xml
|
||||
|
||||
rendering_times.csv
|
||||
|
@ -1,34 +1,31 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.4.0
|
||||
rev: v4.5.0
|
||||
hooks:
|
||||
- id: check-yaml
|
||||
- id: check-toml
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.12.0
|
||||
hooks:
|
||||
- id: isort
|
||||
name: isort (python)
|
||||
- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
|
||||
rev: v2.10.0
|
||||
rev: v2.12.0
|
||||
hooks:
|
||||
- id: pretty-format-yaml
|
||||
args: [--autofix]
|
||||
- id: pretty-format-toml
|
||||
exclude: poetry.lock
|
||||
args: [--autofix]
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.7.0
|
||||
args: [--autofix, --trailing-commas]
|
||||
- repo: https://github.com/keewis/blackdoc
|
||||
rev: v0.3.9
|
||||
hooks:
|
||||
- id: black
|
||||
- id: blackdoc
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.0.282
|
||||
rev: v0.1.13
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.4.1
|
||||
rev: v1.8.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies: [types-requests, types-setuptools]
|
||||
|
173
CHANGELOG.md
Normal file
@ -0,0 +1,173 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
<!-- start changelog -->
|
||||
|
||||
(v5.1)=
|
||||
## [v5.1 (Unreleased)](https://github.com/jeertmans/manim-slides/compare/v5.0.0...HEAD)
|
||||
|
||||
(v5.1-added)=
|
||||
### Added
|
||||
|
||||
- Added the `--hide-info-window` option to `manim-slides present`.
|
||||
[#313](https://github.com/jeertmans/manim-slides/pull/313)
|
||||
- Added the `manim-slides render` command
|
||||
to render slides using correct Manim installation.
|
||||
[#317](https://github.com/jeertmans/manim-slides/pull/317)
|
||||
- Added the `playback-rate` and `reversed-playback-rate` options
|
||||
to slide config.
|
||||
[#320](https://github.com/jeertmans/manim-slides/pull/320)
|
||||
- Added the speaker notes option.
|
||||
[#322](https://github.com/jeertmans/manim-slides/pull/322)
|
||||
- Added `auto` option for conversion format, which is the default.
|
||||
This is somewhat a **breaking change**, but changes to the CLI
|
||||
API are not considered to be very important.
|
||||
[#325](https://github.com/jeertmans/manim-slides/pull/325)
|
||||
- Added `return_animation` option to slide animations `self.wipe`
|
||||
and `self.zoom`.
|
||||
[#331](https://github.com/jeertmans/manim-slides/pull/331)
|
||||
- Created a Docker image, published on GitHub.
|
||||
[#355](https://github.com/jeertmans/manim-slides/pull/355)
|
||||
|
||||
(v5.1-modified)=
|
||||
### Modified
|
||||
|
||||
- Modified the internal logic to simplify adding configuration options.
|
||||
[#321](https://github.com/jeertmans/manim-slides/pull/321)
|
||||
- Remove `reversed` file assets when exporting to HTML, as it was not used.
|
||||
[#336](https://github.com/jeertmans/manim-slides/pull/336)
|
||||
|
||||
(v5.1-chore)=
|
||||
### Chore
|
||||
|
||||
- Removed subrocess calls to FFMPEG with direct `libav` bindings using
|
||||
the `av` Python module. This should enhance rendering speed and security.
|
||||
[#335](https://github.com/jeertmans/manim-slides/pull/335)
|
||||
- Changed build backend to PDM and reflected on docs.
|
||||
[#354](https://github.com/jeertmans/manim-slides/pull/354)
|
||||
|
||||
## [v5](https://github.com/jeertmans/manim-slides/compare/v4.16.0...v5.0.0)
|
||||
|
||||
Prior to v5, there was no real CHANGELOG other than the GitHub releases,
|
||||
with most of the content automatically generated by GitHub from merged
|
||||
pull requests.
|
||||
|
||||
In an effort to better document changes, this CHANGELOG document is now created.
|
||||
|
||||
(v5-added)=
|
||||
### Added
|
||||
|
||||
- Added the following option aliases to `manim-slides present`:
|
||||
`-F` and `--full-screen` for `fullscreen`,
|
||||
`-H` for `--hide-mouse`,
|
||||
and `-S` for `--screen-number`.
|
||||
[#243](https://github.com/jeertmans/manim-slides/pull/243)
|
||||
- Added a full screen key binding (defaults to <kbd>F</kbd>) in the
|
||||
presenter.
|
||||
[#243](https://github.com/jeertmans/manim-slides/pull/243)
|
||||
- Added support for including code from a file in Manim Slides
|
||||
Sphinx directive.
|
||||
[#261](https://github.com/jeertmans/manim-slides/pull/261)
|
||||
- Added the `manim_slides.slide.animation` module and created the
|
||||
`Wipe` and `Zoom` classes, that return a new animation.
|
||||
[#285](https://github.com/jeertmans/manim-slides/pull/285)
|
||||
- Added two environ variables, `MANIM_API` and `FORCE_MANIM_API`,
|
||||
to specify the `MANIM_API` to be used: `manim` and `manimce` will
|
||||
import `manim`, while `manimgl` and `manimlib` will import `manimlib`.
|
||||
If one of the two APIs is already imported, use `FORCE_MANIM_API=1` to
|
||||
override this.
|
||||
[#285](https://github.com/jeertmans/manim-slides/pull/285)
|
||||
- Added a working `ThreeDSlide` class compatible with `manimlib`.
|
||||
[#285](https://github.com/jeertmans/manim-slides/pull/285)
|
||||
- Added `loop` option to `Slide`'s `next_slide` method.
|
||||
Calling `next_slide` will never fail anymore.
|
||||
[#294](https://github.com/jeertmans/manim-slides/pull/294)
|
||||
- Added `Slide.next_section` for compatibility with `manim`'s
|
||||
`Scene.next_section` method.
|
||||
[#295](https://github.com/jeertmans/manim-slides/pull/295)
|
||||
- Added `--next-terminates-loop` option to `manim-slides present` for turn a
|
||||
looping slide into a normal one, so that it ends nicely. This is useful to
|
||||
have a smooth transition with the next slide.
|
||||
[#299](https://github.com/jeertmans/manim-slides/pull/299)
|
||||
- Added `--playback-rate` option to `manim-slides present` for testing purposes.
|
||||
[#300](https://github.com/jeertmans/manim-slides/pull/300)
|
||||
- Added `auto_next` option to `Slide`'s `next_slide` method to automatically
|
||||
play the next slide upon terminating. Supported by `present` and
|
||||
`convert --to=html` commands.
|
||||
[#304](https://github.com/jeertmans/manim-slides/pull/304)
|
||||
|
||||
(v5-changed)=
|
||||
### Changed
|
||||
|
||||
- Automatically concatenate all animations from a slide into one.
|
||||
This is a **breaking change** because the config file format is
|
||||
different from the previous one. For migration help, see associated PR.
|
||||
[#242](https://github.com/jeertmans/manim-slides/pull/242)
|
||||
- Changed the player interface to only use PySide6, and not a combination of
|
||||
PySide6 and OpenCV. A few features have been removed (see removed section),
|
||||
but the new player should be much easier to maintain and more performant,
|
||||
than its predecessor.
|
||||
[#243](https://github.com/jeertmans/manim-slides/pull/243)
|
||||
- Changed the slide config format to exclude unecessary information.
|
||||
`StypeType` is removed in favor to one boolean `loop` field. This is
|
||||
a **breaking change** and one should re-render the slides to apply changes.
|
||||
[#243](https://github.com/jeertmans/manim-slides/pull/243)
|
||||
- Renamed key bindings in the config. This is a **breaking change** and one
|
||||
should either manually rename them (see list below) or re-init a config.
|
||||
List of changes: `CONTINUE` to `NEXT`, `BACK` to `PREVIOUS`, and
|
||||
`REWIND` to `REPLAY`.
|
||||
[#243](https://github.com/jeertmans/manim-slides/pull/243)
|
||||
- Conversion to HTML now uses Jinja2 templating. The template file has
|
||||
been modified accordingly, and old templates will not work anymore.
|
||||
This is a **breaking change**.
|
||||
[#271](https://github.com/jeertmans/manim-slides/pull/271)
|
||||
- Bumped RevealJS' default version to v4.6.1, and added three new themes.
|
||||
[#272](https://github.com/jeertmans/manim-slides/pull/272)
|
||||
- Changed the logger such that `make_logger` is called at module import,
|
||||
and we do not use Manim's logger anymore.
|
||||
[#285](https://github.com/jeertmans/manim-slides/pull/285)
|
||||
- Changed `Slide.wipe` and `Slide.zoom` to automatically call `self.play`.
|
||||
This is a **breaking change** as calling `self.play(self.wipe(...))` now
|
||||
raises an error (because `None` is not an animation).
|
||||
[#285](https://github.com/jeertmans/manim-slides/pull/285)
|
||||
- Changed the `manim_slides.slide` module to contain submodules, i.e.,
|
||||
`slide.manim`, `slide.manimlib`, `slide.animation`.
|
||||
Only `slide.animation` is part of the public API.
|
||||
Rules for choosing the Manim API (either `manim` or `manimlib`) has changed,
|
||||
and defaults to the currently imported module, with a preference for `manim`.
|
||||
[#285](https://github.com/jeertmans/manim-slides/pull/285)
|
||||
|
||||
(v5-fixed)=
|
||||
### Fixed
|
||||
|
||||
- Patched enums in `manim_slides/convert.py` to correctly call `str`'s
|
||||
`__str__` method, and not the `Enum` one.
|
||||
This bug was discovered by
|
||||
[@alexanderskulikov](https://github.com/alexanderskulikov) in
|
||||
[#253](https://github.com/jeertmans/manim-slides/discussions/253), caused by
|
||||
Python 3.11's change in how `Enum` work.
|
||||
[#257](https://github.com/jeertmans/manim-slides/pull/257).
|
||||
- Fixed potential non-existing parent path issue in
|
||||
`manim convert`'s destination path.
|
||||
[#262](https://github.com/jeertmans/manim-slides/pull/262)
|
||||
|
||||
(v5-removed)=
|
||||
### Removed
|
||||
|
||||
- Removed `--start-at-animation-number` option from `manim-slides present`.
|
||||
[#242](https://github.com/jeertmans/manim-slides/pull/242)
|
||||
- Removed the following options from `manim-slides present`:
|
||||
`--resolution`, `--record-to`, `--resize-mode`, and `--background-color`.
|
||||
[#243](https://github.com/jeertmans/manim-slides/pull/243)
|
||||
- Removed `PERF` verbosity level because not used anymore.
|
||||
[#245](https://github.com/jeertmans/manim-slides/pull/245)
|
||||
- Remove `Slide`'s method `start_loop` and `self.end_loop`
|
||||
in favor to `self.next_slide(loop=True)`.
|
||||
This is a **breaking change**.
|
||||
[#294](https://github.com/jeertmans/manim-slides/pull/294)
|
||||
|
||||
<!-- end changelog -->
|
@ -29,4 +29,4 @@ keywords:
|
||||
- PowerPoint
|
||||
- Python
|
||||
license: MIT
|
||||
version: v4.16.0
|
||||
version: v5.1.0-rc3
|
||||
|
@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Jérome Eertmans
|
||||
Copyright (c) 2022-2024 Jérome Eertmans
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
156
README.md
@ -14,19 +14,18 @@
|
||||
|
||||
# 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!
|
||||
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)
|
||||
* [Pip install](#pip-install)
|
||||
* [Install From Repository](#install-from-repository)
|
||||
- [Usage](#usage)
|
||||
* [Basic Example](#basic-example)
|
||||
* [Key Bindings](#key-bindings)
|
||||
* [Interactive Tutorial](#interactive-tutorial)
|
||||
* [Other Examples](#other-examples)
|
||||
- [Comparison with Similar Tools](#comparison-with-similar-tools)
|
||||
- [F.A.Q](#faq)
|
||||
* [How to increase quality on Windows](#how-to-increase-quality-on-windows)
|
||||
@ -37,67 +36,36 @@ Tool for live presentations using either [Manim (community edition)](https://www
|
||||
|
||||
## Installation
|
||||
|
||||
<!-- start install -->
|
||||
|
||||
While installing Manim Slides and its dependencies on your global Python is fine, I recommend using a virtual environment (e.g., [venv](https://docs.python.org/3/tutorial/venv.html)) for a local installation.
|
||||
|
||||
### Dependencies
|
||||
|
||||
<!-- start deps -->
|
||||
|
||||
Manim Slides requires either Manim or ManimGL to be installed. Having both packages installed is fine too.
|
||||
|
||||
If none of those packages are installed, please refer to their specific installation guidelines:
|
||||
- [Manim](https://docs.manim.community/en/stable/installation.html)
|
||||
- [ManimGL](https://3b1b.github.io/manim/getting_started/installation.html)
|
||||
|
||||
<!-- end deps -->
|
||||
|
||||
### Pip Install
|
||||
|
||||
The recommended way to install the latest release is to use pip:
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
<!-- end install -->
|
||||
Manim Slides requires either Manim or ManimGL to be installed, along
|
||||
with their dependencies. Please checkout the
|
||||
[documentation](https://eertmans.be/manim-slides/installation.html)
|
||||
for detailed install instructions.
|
||||
|
||||
## Usage
|
||||
|
||||
<!-- 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.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.
|
||||
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
|
||||
*PowerPoint* presentation.
|
||||
|
||||
The documentation is available [online](https://eertmans.be/manim-slides/).
|
||||
|
||||
### Basic Example
|
||||
|
||||
Wrap a series of animations between `self.start_loop()` and `self.stop_loop()` when you want to loop them (until input to continue):
|
||||
Call `self.next_slide()` everytime you want to create a pause between
|
||||
animations, and `self.next_slide(loop=True)` if you want the next slide to loop
|
||||
over animations until the user presses continue:
|
||||
|
||||
```python
|
||||
# example.py
|
||||
from manim import * # or: from manimlib import *
|
||||
|
||||
from manim import *
|
||||
# or: from manimlib import *
|
||||
from manim_slides import Slide
|
||||
|
||||
|
||||
class BasicExample(Slide):
|
||||
def construct(self):
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
@ -106,29 +74,33 @@ class BasicExample(Slide):
|
||||
self.play(GrowFromCenter(circle))
|
||||
self.next_slide() # Waits user to press continue to go to the next slide
|
||||
|
||||
self.start_loop() # Start loop
|
||||
self.next_slide(loop=True) # 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.next_slide() # This will start a new non-looping slide
|
||||
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
self.next_slide() # Waits user to press continue to go to the next slide
|
||||
```
|
||||
|
||||
First, render the animation files:
|
||||
|
||||
```bash
|
||||
manim example.py BasicExample
|
||||
# or
|
||||
manimgl example.py BasicExample
|
||||
manim-slides render example.py BasicExample
|
||||
# or use ManimGL
|
||||
manim-slides render --GL example.py BasicExample
|
||||
```
|
||||
|
||||
To start the presentation using `Scene1`, `Scene2` and so on simply run:
|
||||
> [!NOTE]
|
||||
> Using `manim-slides render` makes sure the use the `manim`
|
||||
> (or `manimlib`) library that was installed in the Python same environment.
|
||||
> Put simply, this is a wrapper of `manim render [ARGS]...` (or `manimgl [ARGS]...`).
|
||||
|
||||
To start the presentation using `Scene1`, `Scene2` and so on, run:
|
||||
|
||||
```bash
|
||||
manim-slides [OPTIONS] Scene1 Scene2...
|
||||
```
|
||||
|
||||
Or in this example:
|
||||
In our example:
|
||||
|
||||
```bash
|
||||
manim-slides BasicExample
|
||||
@ -136,49 +108,30 @@ manim-slides BasicExample
|
||||
|
||||
<!-- end usage -->
|
||||
|
||||
## Key Bindings
|
||||
<p align="center">
|
||||
<img alt="Example GIF" src="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/example.gif">
|
||||
</p>
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
manim-slides wizard
|
||||
```
|
||||
|
||||
A default file can be created with:
|
||||
|
||||
```bash
|
||||
manim-slides init
|
||||
```
|
||||
|
||||
> **_NOTE:_** `manim-slides` uses key codes, which are platform dependent. Using the configuration wizard is therefore highly recommended.
|
||||
For detailed usage documentation, run `manim-slides --help`, or go to the
|
||||
[documentation](https://eertmans.be/manim-slides/reference/cli.html).
|
||||
|
||||
## Interactive Tutorial
|
||||
|
||||
Click on the image to watch a slides presentation that explains you how to use Manim Slides.
|
||||
Click on the image to watch a slides presentation that explains to you how
|
||||
to use Manim Slides.
|
||||
|
||||
[](https://eertmans.be/manim-slides/)
|
||||
|
||||
## Other Examples
|
||||
|
||||
Other examples are available in the [`example.py`](https://github.com/jeertmans/manim-slides/blob/main/example.py) file, if you downloaded the git repository.
|
||||
|
||||
Below is a small recording of me playing with the slides back and forth.
|
||||
|
||||

|
||||
## More Examples
|
||||
|
||||
More examples are available in the
|
||||
[`example.py`](https://github.com/jeertmans/manim-slides/blob/main/example.py)
|
||||
file, if you downloaded the git repository.
|
||||
|
||||
## Comparison with Similar Tools
|
||||
|
||||
There exists are variety of tools that allows to create slides presentations containing Manim animations.
|
||||
There exists a variety of tools that allows to create slides presentations
|
||||
containing Manim animations.
|
||||
|
||||
Below is a comparison of the most used ones with Manim Slides:
|
||||
|
||||
@ -196,16 +149,23 @@ Below is a comparison of the most used ones with Manim Slides:
|
||||
|
||||
### How to increase quality on Windows
|
||||
|
||||
On Windows platform, one may encounter a lower image resolution than expected. Usually, this is observed because Windows rescales every application to fit the screen.
|
||||
As found by [@arashash](https://github.com/arashash), in [#20](https://github.com/jeertmans/manim-slides/issues/20), the problem can be addressed by changing the scaling factor to 100%:
|
||||
On Windows platform, one may encounter a lower image resolution than expected.
|
||||
Usually, this is observed because Windows rescales every application to
|
||||
fit the screen.
|
||||
As found by [@arashash](https://github.com/arashash),
|
||||
in [#20](https://github.com/jeertmans/manim-slides/issues/20),
|
||||
the problem can be addressed by changing the scaling factor to 100%:
|
||||
|
||||

|
||||
<p align="center">
|
||||
<img alt="Windows Fix Scaling" src="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/windows_quality_fix.png">
|
||||
</p>
|
||||
|
||||
in *Settings*->*Display*.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are more than welcome! Please read through [our contributing section](https://eertmans.be/manim-slides/contributing/index.html).
|
||||
Contributions are more than welcome! Please read through
|
||||
[our contributing section](https://eertmans.be/manim-slides/contributing/index.html).
|
||||
|
||||
### Reporting an Issue
|
||||
|
||||
|
2
custom_config.yml
Normal file
@ -0,0 +1,2 @@
|
||||
style:
|
||||
background_color: '#000000'
|
55
docker/Dockerfile
Normal file
@ -0,0 +1,55 @@
|
||||
# Mostly a copy from https://github.com/ManimCommunity/manim/blob/68bd79093e1ebc1ed9f8051942ffe6e72a9e66a7/docker/Dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
RUN apt-get update -qq \
|
||||
&& apt-get install --no-install-recommends -y \
|
||||
ffmpeg \
|
||||
build-essential \
|
||||
gcc \
|
||||
cmake \
|
||||
libcairo2-dev \
|
||||
libffi-dev \
|
||||
libpango1.0-dev \
|
||||
freeglut3-dev \
|
||||
pkg-config \
|
||||
make \
|
||||
wget \
|
||||
ghostscript
|
||||
|
||||
# setup a minimal texlive installation
|
||||
COPY docker/texlive-profile.txt /tmp/
|
||||
ENV PATH=/usr/local/texlive/bin/armhf-linux:/usr/local/texlive/bin/aarch64-linux:/usr/local/texlive/bin/x86_64-linux:$PATH
|
||||
RUN wget -O /tmp/install-tl-unx.tar.gz http://mirror.ctan.org/systems/texlive/tlnet/install-tl-unx.tar.gz && \
|
||||
mkdir /tmp/install-tl && \
|
||||
tar -xzf /tmp/install-tl-unx.tar.gz -C /tmp/install-tl --strip-components=1 && \
|
||||
/tmp/install-tl/install-tl --profile=/tmp/texlive-profile.txt \
|
||||
&& tlmgr install \
|
||||
amsmath babel-english cbfonts-fd cm-super ctex doublestroke dvisvgm everysel \
|
||||
fontspec frcursive fundus-calligra gnu-freefont jknapltx latex-bin \
|
||||
mathastext microtype ms physics preview ragged2e relsize rsfs \
|
||||
setspace standalone tipa wasy wasysym xcolor xetex xkeyval
|
||||
|
||||
# clone and build manim-slides
|
||||
COPY . /opt/manim-slides
|
||||
WORKDIR /opt/manim-slides
|
||||
RUN pip install --no-cache manim[jupyterlab] .[sphinx-directive]
|
||||
|
||||
ARG NB_USER=manimslidesuser
|
||||
ARG NB_UID=1000
|
||||
ENV USER ${NB_USER}
|
||||
ENV NB_UID ${NB_UID}
|
||||
ENV HOME /manim-slides
|
||||
|
||||
RUN adduser --disabled-password \
|
||||
--gecos "Default user" \
|
||||
--uid ${NB_UID} \
|
||||
${NB_USER}
|
||||
|
||||
# create working directory for user to mount local directory into
|
||||
WORKDIR ${HOME}
|
||||
USER root
|
||||
RUN chown -R ${NB_USER}:${NB_USER} ${HOME}
|
||||
RUN chmod 777 ${HOME}
|
||||
USER ${NB_USER}
|
||||
|
||||
CMD [ "/bin/bash" ]
|
15
docker/README.md
Normal file
@ -0,0 +1,15 @@
|
||||
# Docker Image
|
||||
|
||||
Manim Slides Docker image, highly inspired from the Manim Community Docker image.
|
||||
|
||||
Building the image can be done with:
|
||||
|
||||
```bash
|
||||
docker build -t manim-slide/manin-slide:TAG -f docker/Dockerfile .
|
||||
```
|
||||
|
||||
from the root directory of the repository.
|
||||
|
||||
> [!WARNING]
|
||||
> If you run the command above from another place,
|
||||
> Docker will not be able to find expected files.
|
10
docker/texlive-profile.txt
Normal file
@ -0,0 +1,10 @@
|
||||
selected_scheme scheme-minimal
|
||||
TEXDIR /usr/local/texlive
|
||||
TEXMFCONFIG ~/.texlive/texmf-config
|
||||
TEXMFHOME ~/texmf
|
||||
TEXMFLOCAL /usr/local/texlive/texmf-local
|
||||
TEXMFSYSCONFIG /usr/local/texlive/texmf-config
|
||||
TEXMFSYSVAR /usr/local/texlive/texmf-var
|
||||
TEXMFVAR ~/.texlive/texmf-var
|
||||
option_doc 0
|
||||
option_src 0
|
@ -1,3 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
Changes between releases are listed in Manim Slides' [Github releases](https://github.com/jeertmans/manim-slides/releases). You can read the [latest release here](https://github.com/jeertmans/manim-slides/releases).
|
||||
```{include} ../../CHANGELOG.md
|
||||
:start-after: <!-- start changelog -->
|
||||
:end-before: <!-- end changelog -->
|
||||
```
|
||||
|
@ -4,12 +4,20 @@
|
||||
# For the full list of built-in configuration values, see the documentation:
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
|
||||
import sys
|
||||
from datetime import date
|
||||
|
||||
from manim_slides import __version__
|
||||
|
||||
assert sys.version_info >= (3, 10), "Building docs requires Python 3.10"
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
||||
|
||||
project = "Manim Slides"
|
||||
copyright = "2023, Jérome Eertmans"
|
||||
copyright = f"2024-{date.today().year}, Jérome Eertmans"
|
||||
author = "Jérome Eertmans"
|
||||
version = __version__
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||
@ -29,9 +37,7 @@ extensions = [
|
||||
"manim_slides.docs.manim_slides_directive",
|
||||
]
|
||||
|
||||
typehints_defaults = "comma"
|
||||
typehints_use_signature = True
|
||||
typehints_use_signature_return = True
|
||||
autodoc_typehints = "both"
|
||||
|
||||
myst_enable_extensions = [
|
||||
"colon_fence",
|
||||
@ -41,6 +47,8 @@ myst_enable_extensions = [
|
||||
templates_path = ["_templates"]
|
||||
exclude_patterns = []
|
||||
|
||||
# Removes the 'package.module' part from package.module.Class
|
||||
add_module_names = False
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
|
||||
@ -74,6 +82,7 @@ intersphinx_mapping = {
|
||||
"python": ("https://docs.python.org/3", None),
|
||||
"manim": ("https://docs.manim.community/en/stable/", None),
|
||||
"manimlib": ("https://3b1b.github.io/manim/", None),
|
||||
"numpy": ("https://numpy.org/doc/stable/", None),
|
||||
}
|
||||
|
||||
# -- OpenGraph settings
|
||||
|
@ -4,7 +4,7 @@ This document is there to help you recreate a working environment for Manim Slid
|
||||
|
||||
## Dependencies
|
||||
|
||||
```{include} ../../../README.md
|
||||
```{include} ../quickstart.md
|
||||
:start-after: <!-- start deps -->
|
||||
:end-before: <!-- end deps -->
|
||||
```
|
||||
@ -20,64 +20,110 @@ Useful links:
|
||||
|
||||
Once you feel comfortable with git and GitHub, [fork](https://github.com/jeertmans/manim-slides/fork) the repository, and clone it locally.
|
||||
|
||||
As for every Python project, using virtual environment is recommended to avoid conflicts between modules. For Manim Slides, we use [Poetry](https://python-poetry.org/docs/#installing-with-the-official-installer). If not already, please install it.
|
||||
As for every Python project, using virtual environment is recommended to avoid
|
||||
conflicts between modules.
|
||||
For this project, we use [PDM](https://pdm-project.org/) to easily manage project
|
||||
and development dependencies. If not already, please install this tool.
|
||||
|
||||
## Installing Python modules
|
||||
|
||||
With Poetry, installation becomes straightforward:
|
||||
With PDM, installation becomes straightforward:
|
||||
|
||||
```bash
|
||||
poetry install
|
||||
pdm 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:
|
||||
If you would like to install Manim or ManimGL,
|
||||
as documented in the [quickstart](../quickstart),
|
||||
you can use the `-G|--group` option:
|
||||
|
||||
```bash
|
||||
poetry install --extras manim # For Manim
|
||||
pdm install -Gmanim # For Manim
|
||||
# or
|
||||
poetry install --extras manimgl # For ManimGL
|
||||
pdm install -Gmanimgl # For ManimGL
|
||||
```
|
||||
|
||||
Additionnally, Manim Slides comes with group dependencies for development purposes:
|
||||
Additionnally, Manim Slides comes with groups of dependencies for development purposes:
|
||||
|
||||
```bash
|
||||
poetry install --with dev # For linters and formatters
|
||||
pdm install -dGdev # For linters and formatters
|
||||
# or
|
||||
poetry install --with docs # To build the documentation locally
|
||||
pdm install --dGdocs # To build the documentation locally
|
||||
# or
|
||||
pdm install --dGtests # To run tests
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
You can also install everything with `pdm install -G:all`.
|
||||
:::
|
||||
|
||||
## Running commands
|
||||
|
||||
As modules were installed in a new Python environment, you cannot use them directly in the shell.
|
||||
Instead, you either need to prepend `poetry run` to any command, e.g.:
|
||||
Because modules are installed in a new Python environment,
|
||||
you cannot use them directly in the shell.
|
||||
Instead, you either need to prepend `pdm run` to any command, e.g.:
|
||||
|
||||
```bash
|
||||
poetry run manim-slides wizard
|
||||
pdm run manim-slides wizard
|
||||
```
|
||||
|
||||
or enter a new shell that uses this new Python environment:
|
||||
or [enter a new shell](https://pdm-project.org/latest/usage/venv/#activate-a-virtualenv)
|
||||
that uses this new Python environment:
|
||||
|
||||
```
|
||||
poetry shell
|
||||
```bash
|
||||
eval $(pdm venv activate) # Click on the link above to see shell-specific command
|
||||
manim-slides wizard
|
||||
```
|
||||
|
||||
## Testing your code
|
||||
|
||||
Most of the tests are done with GitHub actions, thus not on your computer. The only command you should run locally is `pre-commit run --all-files`: this runs a few linter and formatter to make sure the code quality and style stay constant across time. If a warning or an error is displayed, please fix it before going to next step.
|
||||
Most of the tests are done with GitHub actions, thus not on your computer.
|
||||
The only command you should run locally is:
|
||||
|
||||
```bash
|
||||
pdm run pre-commit run --all-files
|
||||
```
|
||||
|
||||
This runs a few linter and formatter to make sure the code quality and style stay
|
||||
constant across time.
|
||||
If a warning or an error is displayed, please fix it before going to next step.
|
||||
|
||||
For testing your code, simply run:
|
||||
|
||||
```bash
|
||||
pdm run pytest
|
||||
```
|
||||
|
||||
## Building the documentation
|
||||
|
||||
The documentation is generated using Sphinx, based on the content
|
||||
in `docs/source` and in the `manim_slides` Python package.
|
||||
|
||||
To generate the documentation, run the following:
|
||||
|
||||
```bash
|
||||
cd docs
|
||||
pdm run make html
|
||||
```
|
||||
|
||||
Then, the output index file is located at `docs/build/html/index.html` and
|
||||
can be opened with any modern browser.
|
||||
|
||||
:::{warning}
|
||||
Building the documentation can take quite some time, especially
|
||||
the first time as it needs to render all the animations.
|
||||
|
||||
Further builds should run faster.
|
||||
:::
|
||||
|
||||
## Proposing changes
|
||||
|
||||
Once you feel ready and think your contribution is ready to be reviewed, create a [pull request](https://github.com/jeertmans/manim-slides/pulls) and wait for a reviewer to check your work!
|
||||
Once you feel ready and think your contribution is ready to be reviewed,
|
||||
create a [pull request](https://github.com/jeertmans/manim-slides/pulls)
|
||||
and wait for a reviewer to check your work!
|
||||
|
||||
Many thanks to you!
|
||||
|
2
docs/source/docutils.conf
Normal file
@ -0,0 +1,2 @@
|
||||
[restructuredtext parser]
|
||||
syntax_highlight = short
|
@ -13,7 +13,6 @@ The following summarizes the different presentation features Manim Slides offers
|
||||
| Pause animation | Yes | No | No | N/A |
|
||||
| Play slide in reverse | Yes | No | No | N/A |
|
||||
| Slide count | Yes | Yes (optional) | Yes (optional) | N/A |
|
||||
| Animation count | Yes | No | No | N/A |
|
||||
| Needs Python with Manim Slides installed | Yes | No | No | No
|
||||
| Requires internet access | No | Yes | No | No |
|
||||
| Auto. play slides | Yes | Yes | Yes | N/A |
|
||||
@ -23,5 +22,8 @@ The following summarizes the different presentation features Manim Slides offers
|
||||
| Works cross-platforms | Yes | Yes | Partly[^1][^2] | Yes |
|
||||
:::
|
||||
|
||||
[^1]: If you encounter a problem where slides do not automatically play or loops do not work, please [file an issue on GitHub](https://github.com/jeertmans/manim-slides/issues/new/choose).
|
||||
[^2]: PowerPoint online does not seem to support automatic playing of videos, so you need LibreOffice Impress on Linux platforms.
|
||||
[^1]: If you encounter a problem where slides do not automatically play or loops do not work,
|
||||
please
|
||||
[file an issue on GitHub](https://github.com/jeertmans/manim-slides/issues/new/choose).
|
||||
[^2]: PowerPoint online does not seem to support automatic playing of videos,
|
||||
so you need LibreOffice Impress on Linux platforms.
|
||||
|
@ -23,23 +23,26 @@ og:description: Manim Slides makes creating slides with Manim super easy!
|
||||
|
||||
Manim Slides makes creating slides with Manim super easy!
|
||||
|
||||
In a [very few steps](./quickstart), you can create slides and present them either using the GUI, or your browser.
|
||||
In a [very few steps](./quickstart),
|
||||
you can create slides and present them either using the GUI, or your browser.
|
||||
|
||||
Slide through the demo below to get a quick glimpse on what you can do with
|
||||
Manim Slides.
|
||||
|
||||
Slide through the demo below to get a quick glimpse on what you can do with Manim Slides.
|
||||
|
||||
|
||||
<!-- From: https://faq.dailymotion.com/hc/en-us/articles/360022841393-How-to-preserve-the-player-aspect-ratio-on-a-responsive-page -->
|
||||
|
||||
<div style="position:relative;padding-bottom:56.25%;"> <iframe style="width:100%;height:100%;position:absolute;left:0px;top:0px;" frameborder="0" width="100%" height="100%" allowfullscreen allow="autoplay" src="_static/slides.html"></iframe></div>
|
||||
|
||||
```{eval-rst}
|
||||
.. manim-slides:: ../../example.py:ConvertExample
|
||||
:hide_source:
|
||||
:quality: high
|
||||
```
|
||||
|
||||
```{toctree}
|
||||
:hidden:
|
||||
|
||||
quickstart
|
||||
installation
|
||||
reference/index
|
||||
features_table
|
||||
manim_or_manimgl
|
||||
```
|
||||
|
||||
```{toctree}
|
||||
|
104
docs/source/installation.md
Normal file
@ -0,0 +1,104 @@
|
||||
# Installation
|
||||
|
||||
While installing Manim Slides and its dependencies on your global Python is fine,
|
||||
we recommend using a virtual environment
|
||||
(e.g., [venv](https://docs.python.org/3/tutorial/venv.html)) for a local installation.
|
||||
|
||||
Therefore, the following documentation will install Manim Slides using
|
||||
[pipx](https://pipx.pypa.io/). This tool is a drop-in replacement
|
||||
for installing Python packages that ship with one or more executable.
|
||||
|
||||
The benefit of using pipx is that it will automatically create a new virtual
|
||||
environment for every package you install.
|
||||
|
||||
:::{note}
|
||||
Everytime you read `pipx install`, you can use `pip install` instead,
|
||||
if you are working in a virtual environment or else.
|
||||
:::
|
||||
|
||||
## Dependencies
|
||||
|
||||
Manim Slides requires either Manim or ManimGL to be installed, along
|
||||
with their dependencies.
|
||||
Having both packages installed is fine too.
|
||||
|
||||
If none of those packages are installed,
|
||||
please refer to their specific installation guidelines:
|
||||
- [Manim](https://docs.manim.community/en/stable/installation.html)
|
||||
- [ManimGL](https://3b1b.github.io/manim/getting_started/installation.html)
|
||||
|
||||
:::{warning}
|
||||
If you install Manim from its git repository, as suggested by ManimGL,
|
||||
make sure to first check out a supported version (e.g., `git checkout tags/v1.6.1`
|
||||
for ManimGL), otherwise it might install an unsupported version of Manim!
|
||||
See [#314](https://github.com/jeertmans/manim-slides/issues/314).
|
||||
:::
|
||||
|
||||
## Pip Install
|
||||
|
||||
The recommended way to install the latest release is to use pip:
|
||||
|
||||
```bash
|
||||
pipx install -U manim-slides
|
||||
```
|
||||
|
||||
:::{tip}
|
||||
While not necessary, the `-U` indicates that we would
|
||||
like to upgrade to the latest version available,
|
||||
if Manim Slides is already installed.
|
||||
:::
|
||||
|
||||
Optionally, you can also install Manim or ManimGL using extras[^1]:
|
||||
|
||||
```bash
|
||||
pipx install -U "manim-slides[manim]" # For Manim
|
||||
# or
|
||||
pipx install -U "manim-slides[manimgl]" # For ManimGL
|
||||
```
|
||||
|
||||
You can check that Manim Slides was correctly installed with:
|
||||
|
||||
```bash
|
||||
manim-slides --version
|
||||
```
|
||||
|
||||
:::{warning}
|
||||
If you are installing with pipx, this is mandatory to at least include
|
||||
either `manim` or `manimgl`.
|
||||
:::
|
||||
|
||||
[^1]: You still need to have Manim or ManimGL platform-specific dependencies
|
||||
installed on your computer.
|
||||
|
||||
## Optional Dependencies
|
||||
|
||||
Along with the optional dependencies for Manim and ManimGL,
|
||||
Manim Slides offers additional *extras*, that can be activated
|
||||
using optional dependencies:
|
||||
|
||||
- `magic`, to include a Jupyter magic to render
|
||||
animations inside notebooks. This automatically installs `manim`,
|
||||
and does not work with ManimGL;
|
||||
- `manim` and `manimgl`, for installing the corresponding
|
||||
dependencies;
|
||||
- `sphinx-directive`, to generate presentation inside your Sphinx
|
||||
documentation. This automatically installs `manim`,
|
||||
and does not work with ManimGL;
|
||||
|
||||
Installing those extras can be done with the following syntax:
|
||||
|
||||
```bash
|
||||
pipx install -U "manim-slides[extra1,extra2]"
|
||||
```
|
||||
|
||||
:::{note}
|
||||
The quotes `"` are added because not all shell support unquoted
|
||||
brackets (e.g., zsh) or commas (e.g., Windows).
|
||||
:::
|
||||
|
||||
## Install From Repository
|
||||
|
||||
An alternative way to install Manim Slides is to clone the git repository,
|
||||
and install from there: read the
|
||||
[contributing guide](./contributing/workflow)
|
||||
to know how to process.
|
71
docs/source/manim_or_manimgl.md
Normal file
@ -0,0 +1,71 @@
|
||||
# Manim or ManimGL
|
||||
|
||||
Manim Slides supports both Manim (Community Edition) and ManimGL (by 3b1b).
|
||||
|
||||
Because both modules have slightly different APIs, Manim Slides needs to know
|
||||
which Manim API you are using, to import the correct module.
|
||||
|
||||
## Default Behavior
|
||||
|
||||
By default, Manim Slides looks at {py:data}`sys.modules` and chooses the first
|
||||
Manim package that is already imported: `manim` for Manim,
|
||||
`manimlib` for ManimGL. This works pretty well when rendering
|
||||
the slides.
|
||||
|
||||
If both modules are present in {py:data}`sys.modules`, then Manim Slides will
|
||||
prefer using `manim`.
|
||||
|
||||
|
||||
### Usage
|
||||
|
||||
The simplest way to use Manim Slides with the correct Manim API is to:
|
||||
|
||||
1. first import the Manim API;
|
||||
2. and, then, import `manim_slides`.
|
||||
|
||||
Example for `manim`:
|
||||
|
||||
```python
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
```
|
||||
|
||||
Example for `manimlib`:
|
||||
|
||||
```python
|
||||
from manimlib import *
|
||||
from manim_slides import Slide
|
||||
```
|
||||
|
||||
### Example of Default Import
|
||||
|
||||
The following code shows how Manim Slides detected that `manimlib`
|
||||
was imported, so the {py:class}`Slide<manim_slides.slide.Slide>`
|
||||
automatically subclasses the class from ManimGL, not Manim.
|
||||
|
||||
```python
|
||||
from manimlib import Scene
|
||||
from manim_slides import Slide
|
||||
|
||||
assert issubclass(Slide, Scene) # Slide subclasses Scene from ManimGL
|
||||
|
||||
from manim import Scene
|
||||
|
||||
assert not issubclass(Slide, Scene) # but not Scene from Manim
|
||||
```
|
||||
|
||||
## Custom Manim API
|
||||
|
||||
If you want to override the default Manim API, you can set the `MANIM_API`
|
||||
environment variable to:
|
||||
|
||||
- `manim` or `manimce` to import `manim`;
|
||||
- `manimlib` or `manimgl` to import `manimlib`;
|
||||
|
||||
prior to importing `manim_slides`.
|
||||
|
||||
Note that Manim Slides will still first look at {py:data}`sys.modules` to check
|
||||
if any of the two modules is already imported.
|
||||
|
||||
If you want to force Manim Slides to obey the `MANIM_API` environment variable,
|
||||
you must also set `FORCE_MANIM_API=1`.
|
@ -1,11 +1,7 @@
|
||||
# Quickstart
|
||||
|
||||
## Installation
|
||||
|
||||
```{include} ../../README.md
|
||||
:start-after: <!-- start install -->
|
||||
:end-before: <!-- end install -->
|
||||
```
|
||||
If not already, install Manim Slides, along with either Manim or ManimGL,
|
||||
see [installation](./installation).
|
||||
|
||||
## Creating your first slides
|
||||
|
||||
@ -16,6 +12,10 @@
|
||||
|
||||
The output slides should look this this:
|
||||
|
||||
<div style="position:relative;padding-bottom:56.25%;"> <iframe style="width:100%;height:100%;position:absolute;left:0px;top:0px;" frameborder="0" width="100%" height="100%" allowfullscreen allow="autoplay" src="_static/basic_example.html"></iframe></div>
|
||||
```{eval-rst}
|
||||
.. manim-slides:: ../../example.py:BasicExample
|
||||
:hide_source:
|
||||
:quality: high
|
||||
```
|
||||
|
||||
For more advanced examples, see the [Examples](reference/examples) section.
|
||||
|
@ -6,22 +6,35 @@ and `ThreeDSlide`, which are subclasses of `Scene` and `ThreeDScene` from Manim.
|
||||
Therefore, we only document here the methods we think the end-user will ever
|
||||
use, not the methods used internally when rendering.
|
||||
|
||||
## Slide
|
||||
|
||||
```{eval-rst}
|
||||
.. autoclass:: manim_slides.Slide
|
||||
.. autoclass:: manim_slides.slide.Slide
|
||||
:members:
|
||||
add_to_canvas,
|
||||
canvas,
|
||||
canvas_mobjects,
|
||||
end_loop,
|
||||
mobjects_without_canvas,
|
||||
next_section,
|
||||
next_slide,
|
||||
pause,
|
||||
remove_from_canvas,
|
||||
start_loop,
|
||||
wait_time_between_slides,
|
||||
wipe,
|
||||
zoom,
|
||||
```
|
||||
|
||||
.. autoclass:: manim_slides.ThreeDSlide
|
||||
## 3D Slide
|
||||
|
||||
```{eval-rst}
|
||||
.. autoclass:: manim_slides.slide.ThreeDSlide
|
||||
:members:
|
||||
```
|
||||
|
||||
## Animations
|
||||
|
||||
```{eval-rst}
|
||||
.. automodule:: manim_slides.slide.animation
|
||||
:members:
|
||||
Wipe,
|
||||
Zoom,
|
||||
```
|
||||
|
@ -29,9 +29,11 @@ where `-ccontrols=true` indicates that we want to display the blue navigation ar
|
||||
|
||||
Basic example from quickstart.
|
||||
|
||||
<div style="position:relative;padding-bottom:56.25%;"> <iframe style="width:100%;height:100%;position:absolute;left:0px;top:0px;" frameborder="0" width="100%" height="100%" allowfullscreen allow="autoplay" src="../_static/basic_example.html"></iframe></div>
|
||||
|
||||
```{eval-rst}
|
||||
.. manim-slides:: ../../../example.py:BasicExample
|
||||
:hide_source:
|
||||
:quality: high
|
||||
|
||||
.. literalinclude:: ../../../example.py
|
||||
:language: python
|
||||
:linenos:
|
||||
@ -40,13 +42,16 @@ Basic example from quickstart.
|
||||
|
||||
## 3D Example
|
||||
|
||||
Example using 3D camera. As Manim and ManimGL handle 3D differently, definitions are slightly different.
|
||||
|
||||
<div style="position:relative;padding-bottom:56.25%;"> <iframe style="width:100%;height:100%;position:absolute;left:0px;top:0px;" frameborder="0" width="100%" height="100%" allowfullscreen allow="autoplay" src="../_static/three_d_example.html"></iframe></div>
|
||||
Example using 3D camera. As Manim and ManimGL handle 3D differently,
|
||||
definitions are slightly different.
|
||||
|
||||
### With Manim
|
||||
|
||||
```{eval-rst}
|
||||
.. manim-slides:: ../../../example.py:ThreeDExample
|
||||
:hide_source:
|
||||
:quality: high
|
||||
|
||||
.. literalinclude:: ../../../example.py
|
||||
:language: python
|
||||
:linenos:
|
||||
@ -95,19 +100,23 @@ And later use this class anywhere in your code:
|
||||
:linenos:
|
||||
|
||||
class SubclassExample(MovingCameraSlide):
|
||||
"""Example taken from ManimCE's docs."""
|
||||
|
||||
def construct(self):
|
||||
eq1 = MathTex("x", "=", "1")
|
||||
eq2 = MathTex("x", "=", "2")
|
||||
self.camera.frame.save_state()
|
||||
|
||||
self.play(Write(eq1))
|
||||
ax = Axes(x_range=[-1, 10], y_range=[-1, 10])
|
||||
graph = ax.plot(lambda x: np.sin(x), color=WHITE, x_range=[0, 3 * PI])
|
||||
|
||||
dot_1 = Dot(ax.i2gp(graph.t_min, graph))
|
||||
dot_2 = Dot(ax.i2gp(graph.t_max, graph))
|
||||
self.add(ax, graph, dot_1, dot_2)
|
||||
|
||||
self.play(self.camera.frame.animate.scale(0.5).move_to(dot_1))
|
||||
self.next_slide()
|
||||
|
||||
self.play(
|
||||
TransformMatchingTex(eq1, eq2),
|
||||
self.camera.frame.animate.scale(0.5)
|
||||
)
|
||||
|
||||
self.play(self.camera.frame.animate.move_to(dot_2))
|
||||
self.next_slide()
|
||||
self.play(Restore(self.camera.frame))
|
||||
self.wait()
|
||||
```
|
||||
|
||||
@ -116,13 +125,46 @@ If you do not plan to reuse `MovingCameraSlide` more than once, then you can
|
||||
directly write the `construct` method in the body of `MovingCameraSlide`.
|
||||
:::
|
||||
|
||||
```{eval-rst}
|
||||
.. manim-slides:: SubclassExample
|
||||
:hide_source:
|
||||
:quality: high
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
|
||||
class MovingCameraSlide(Slide, MovingCameraScene):
|
||||
pass
|
||||
|
||||
class SubclassExample(MovingCameraSlide):
|
||||
def construct(self):
|
||||
self.camera.frame.save_state()
|
||||
|
||||
ax = Axes(x_range=[-1, 10], y_range=[-1, 10])
|
||||
graph = ax.plot(lambda x: np.sin(x), color=WHITE, x_range=[0, 3 * PI])
|
||||
|
||||
dot_1 = Dot(ax.i2gp(graph.t_min, graph))
|
||||
dot_2 = Dot(ax.i2gp(graph.t_max, graph))
|
||||
self.add(ax, graph, dot_1, dot_2)
|
||||
|
||||
self.play(self.camera.frame.animate.scale(0.5).move_to(dot_1))
|
||||
self.next_slide()
|
||||
self.play(self.camera.frame.animate.move_to(dot_2))
|
||||
self.next_slide()
|
||||
self.play(Restore(self.camera.frame))
|
||||
self.wait()
|
||||
```
|
||||
|
||||
## Advanced Example
|
||||
|
||||
A more advanced example is `ConvertExample`, which is used as demo slide and tutorial.
|
||||
|
||||
<div style="position:relative;padding-bottom:56.25%;"> <iframe style="width:100%;height:100%;position:absolute;left:0px;top:0px;" frameborder="0" width="100%" height="100%" allowfullscreen allow="autoplay" src="../_static/slides.html"></iframe></div>
|
||||
|
||||
```{eval-rst}
|
||||
.. manim-slides:: ../../../example.py:ConvertExample
|
||||
:hide_source:
|
||||
:quality: high
|
||||
|
||||
.. literalinclude:: ../../../example.py
|
||||
:language: python
|
||||
:linenos:
|
||||
|
@ -55,13 +55,12 @@
|
||||
" Text(\"Press\"),\n",
|
||||
" Text(\"and\"),\n",
|
||||
" Text(\"loop\"),\n",
|
||||
" ).arrange(DOWN, buff=1.)\n",
|
||||
" \n",
|
||||
" ).arrange(DOWN, buff=1.0)\n",
|
||||
"\n",
|
||||
" self.play(Write(text))\n",
|
||||
" self.next_slide(loop=True)\n",
|
||||
" self.play(Indicate(text[-1], scale_factor=2.0, run_time=0.5))\n",
|
||||
" self.next_slide()\n",
|
||||
" self.start_loop()\n",
|
||||
" self.play(Indicate(text[-1], scale_factor=2., run_time=.5))\n",
|
||||
" self.end_loop()\n",
|
||||
" self.play(FadeOut(text))"
|
||||
]
|
||||
},
|
||||
|
@ -9,8 +9,10 @@ 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
|
||||
:::{note}
|
||||
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
|
||||
|
||||
@ -32,8 +34,8 @@ the key bindings might not be the same.
|
||||
Example:
|
||||
|
||||
```bash
|
||||
# If you use ManimGl, replace `manim` with `manimgl`
|
||||
manim example.py BasicExample
|
||||
# If you use ManimGl, add `--GL` after `render`
|
||||
manim-slides render example.py BasicExample
|
||||
|
||||
# This or `manim-slides BasicExample` works since
|
||||
# `present` is implied by default
|
||||
@ -128,25 +130,34 @@ 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.
|
||||
If you set the `data_uri` option to `true` (with `-cdata_uri=true`),
|
||||
all animations will be data URI encoded, making the HTML a self-contained
|
||||
presentation file that can be shared on its own.
|
||||
|
||||
### Over the internet
|
||||
|
||||
Finally, HTML conversion makes it convenient to play your presentation on a
|
||||
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
|
||||
:::{warning}
|
||||
Keep in mind that playing large video files over the internet network
|
||||
can take some time, and *glitches* may occur between slide transitions for this
|
||||
reason.
|
||||
:::
|
||||
|
||||
### Using the Github starter template
|
||||
|
||||
A [starter template](https://github.com/jeertmans/manim-slides-starter) is
|
||||
available which allows to quickly get going with a new Manim slides
|
||||
presentation on your Github account. The template comes ready with
|
||||
functionality to automate tasks using Github actions and publish to Github
|
||||
Pages. Please refer to the template page for usage instructions.
|
||||
|
||||
### With PowerPoint (*EXPERIMENTAL*)
|
||||
|
||||
|
101
example.py
@ -1,19 +1,13 @@
|
||||
# flake8: noqa: F403, F405
|
||||
# type: ignore
|
||||
import sys
|
||||
|
||||
if "manim" in sys.modules:
|
||||
from manim import *
|
||||
|
||||
MANIMGL = False
|
||||
elif "manimlib" in sys.modules:
|
||||
from manimlib import *
|
||||
|
||||
MANIMGL = True
|
||||
else:
|
||||
raise ImportError("This script must be run with either `manim` or `manimgl`")
|
||||
|
||||
from manim_slides import Slide, ThreeDSlide
|
||||
from manim_slides.slide import MANIM, MANIMGL
|
||||
|
||||
if MANIM:
|
||||
from manim import *
|
||||
elif MANIMGL:
|
||||
from manimlib import *
|
||||
|
||||
|
||||
class BasicExample(Slide):
|
||||
@ -22,51 +16,12 @@ class BasicExample(Slide):
|
||||
dot = Dot()
|
||||
|
||||
self.play(GrowFromCenter(circle))
|
||||
self.next_slide() # Waits user to press continue to go to the next slide
|
||||
|
||||
self.start_loop() # Start loop
|
||||
self.next_slide(loop=True)
|
||||
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
|
||||
self.end_loop() # This will loop until user inputs a key
|
||||
self.next_slide()
|
||||
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
self.next_slide() # Waits user to press continue to go to the next slide
|
||||
|
||||
|
||||
class MultipleAnimationsInLastSlide(Slide):
|
||||
"""This is used to check against solution for issue #161."""
|
||||
|
||||
def construct(self):
|
||||
circle = Circle(color=BLUE)
|
||||
dot = Dot()
|
||||
|
||||
self.play(GrowFromCenter(circle))
|
||||
self.play(FadeIn(dot))
|
||||
self.next_slide()
|
||||
|
||||
self.play(dot.animate.move_to(RIGHT))
|
||||
self.play(dot.animate.move_to(UP))
|
||||
self.play(dot.animate.move_to(LEFT))
|
||||
self.play(dot.animate.move_to(DOWN))
|
||||
|
||||
self.next_slide()
|
||||
|
||||
|
||||
class TestFileTooLong(Slide):
|
||||
"""This is used to check against solution for issue #123."""
|
||||
|
||||
def construct(self):
|
||||
import random
|
||||
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
dot = Dot()
|
||||
self.play(GrowFromCenter(circle), run_time=0.1)
|
||||
|
||||
for _ in range(30):
|
||||
direction = (random.random() - 0.5) * LEFT + (random.random() - 0.5) * UP
|
||||
self.play(dot.animate.move_to(direction), run_time=0.1)
|
||||
self.play(dot.animate.move_to(ORIGIN), run_time=0.1)
|
||||
|
||||
self.next_slide()
|
||||
|
||||
|
||||
class ConvertExample(Slide):
|
||||
@ -181,9 +136,9 @@ class Example(Slide):
|
||||
def construct(self):
|
||||
dot = Dot()
|
||||
self.add(dot)
|
||||
self.start_loop()
|
||||
self.next_slide(loop=True)
|
||||
self.play(Indicate(dot, scale_factor=2))
|
||||
self.end_loop()
|
||||
self.next_slide()
|
||||
square = Square()
|
||||
self.play(Transform(dot, square))
|
||||
self.next_slide()
|
||||
@ -209,7 +164,7 @@ class Example(Slide):
|
||||
language="console",
|
||||
).shift(DOWN)
|
||||
|
||||
self.play(self.wipe(title, code))
|
||||
self.wipe(title, code)
|
||||
self.next_slide()
|
||||
|
||||
self.play(FadeIn(step, shift=RIGHT))
|
||||
@ -230,26 +185,25 @@ class Example(Slide):
|
||||
|
||||
self.play(Transform(step, step_5))
|
||||
self.play(Transform(code, code_step_5))
|
||||
self.next_slide()
|
||||
self.next_slide(auto_next=True)
|
||||
|
||||
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.next_slide()
|
||||
|
||||
watch_text = Text("Watch result on next slides!").shift(2 * DOWN).scale(0.5)
|
||||
|
||||
self.start_loop()
|
||||
self.next_slide(loop=True)
|
||||
self.play(FadeIn(watch_text))
|
||||
self.play(FadeOut(watch_text))
|
||||
self.end_loop()
|
||||
self.next_slide()
|
||||
self.clear()
|
||||
|
||||
dot = Dot()
|
||||
self.add(dot)
|
||||
self.start_loop()
|
||||
self.next_slide(loop=True)
|
||||
self.play(Indicate(dot, scale_factor=2))
|
||||
self.end_loop()
|
||||
self.next_slide()
|
||||
square = Square()
|
||||
self.play(Transform(dot, square))
|
||||
self.remove(dot)
|
||||
@ -289,9 +243,9 @@ if not MANIMGL:
|
||||
|
||||
self.next_slide()
|
||||
|
||||
self.start_loop()
|
||||
self.next_slide(loop=True)
|
||||
self.play(MoveAlongPath(dot, circle), run_time=4, rate_func=linear)
|
||||
self.end_loop()
|
||||
self.next_slide()
|
||||
|
||||
self.stop_ambient_camera_rotation()
|
||||
self.move_camera(phi=75 * DEGREES, theta=30 * DEGREES)
|
||||
@ -302,9 +256,9 @@ if not MANIMGL:
|
||||
self.play(dot.animate.move_to(RIGHT * 3))
|
||||
self.next_slide()
|
||||
|
||||
self.start_loop()
|
||||
self.next_slide(loop=True)
|
||||
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
|
||||
self.end_loop()
|
||||
self.next_slide()
|
||||
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
|
||||
@ -313,11 +267,7 @@ else:
|
||||
# [manimgl-3d]
|
||||
# WARNING: 3b1b's manim change how ThreeDScene work,
|
||||
# this is why things have to be managed differently.
|
||||
class ThreeDExample(Slide):
|
||||
CONFIG = {
|
||||
"camera_class": ThreeDCamera,
|
||||
}
|
||||
|
||||
class ThreeDExample(ThreeDSlide):
|
||||
def construct(self):
|
||||
axes = ThreeDAxes()
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
@ -329,7 +279,6 @@ else:
|
||||
frame.set_euler_angles(
|
||||
theta=30 * DEGREES,
|
||||
phi=75 * DEGREES,
|
||||
gamma=0,
|
||||
)
|
||||
|
||||
self.play(GrowFromCenter(circle))
|
||||
@ -341,9 +290,9 @@ else:
|
||||
|
||||
self.next_slide()
|
||||
|
||||
self.start_loop()
|
||||
self.next_slide(loop=True)
|
||||
self.play(MoveAlongPath(dot, circle), run_time=4, rate_func=linear)
|
||||
self.end_loop()
|
||||
self.next_slide()
|
||||
|
||||
frame.remove_updater(updater)
|
||||
self.play(frame.animate.set_theta(30 * DEGREES))
|
||||
@ -353,9 +302,9 @@ else:
|
||||
self.play(dot.animate.move_to(RIGHT * 3))
|
||||
self.next_slide()
|
||||
|
||||
self.start_loop()
|
||||
self.next_slide(loop=True)
|
||||
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
|
||||
self.end_loop()
|
||||
self.next_slide()
|
||||
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
|
||||
|
@ -1,4 +1,3 @@
|
||||
# flake8: noqa: F401
|
||||
import sys
|
||||
from types import ModuleType
|
||||
from typing import Any, List
|
||||
@ -6,7 +5,7 @@ from typing import Any, List
|
||||
from .__version__ import __version__
|
||||
|
||||
|
||||
class module(ModuleType):
|
||||
class Module(ModuleType):
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
if name == "Slide" or name == "ThreeDSlide":
|
||||
module = __import__(
|
||||
@ -48,7 +47,7 @@ class module(ModuleType):
|
||||
|
||||
|
||||
old_module = sys.modules["manim_slides"]
|
||||
new_module = sys.modules["manim_slides"] = module("manim_slides")
|
||||
new_module = sys.modules["manim_slides"] = Module("manim_slides")
|
||||
|
||||
new_module.__dict__.update(
|
||||
{
|
||||
|
@ -6,8 +6,9 @@ from click_default_group import DefaultGroup
|
||||
|
||||
from .__version__ import __version__
|
||||
from .convert import convert
|
||||
from .logger import make_logger
|
||||
from .logger import logger
|
||||
from .present import list_scenes, present
|
||||
from .render import render
|
||||
from .wizard import init, wizard
|
||||
|
||||
|
||||
@ -27,16 +28,13 @@ 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:
|
||||
manim_info_url = "https://pypi.org/pypi/manim-slides/json"
|
||||
warn_prompt = "Cannot check if latest release of Manim Slides is installed"
|
||||
try:
|
||||
req_info: requests.models.Response = requests.get(
|
||||
manim_info_url, timeout=10
|
||||
)
|
||||
req_info: requests.models.Response = requests.get(manim_info_url, timeout=2)
|
||||
req_info.raise_for_status()
|
||||
stable = req_info.json()["info"]["version"]
|
||||
if stable != __version__:
|
||||
@ -68,6 +66,7 @@ cli.add_command(convert)
|
||||
cli.add_command(init)
|
||||
cli.add_command(list_scenes)
|
||||
cli.add_command(present)
|
||||
cli.add_command(render)
|
||||
cli.add_command(wizard)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
@ -1 +1 @@
|
||||
__version__ = "4.16.0"
|
||||
__version__ = "5.1.0-rc3"
|
||||
|
@ -12,7 +12,7 @@ Wrapper = Callable[[F], F]
|
||||
|
||||
|
||||
def config_path_option(function: F) -> F:
|
||||
"""Wraps a function to add configuration path option."""
|
||||
"""Wrap a function to add configuration path option."""
|
||||
wrapper: Wrapper = click.option(
|
||||
"-c",
|
||||
"--config",
|
||||
@ -27,7 +27,7 @@ def config_path_option(function: F) -> F:
|
||||
|
||||
|
||||
def config_options(function: F) -> F:
|
||||
"""Wraps a function to add configuration options."""
|
||||
"""Wrap a function to add configuration options."""
|
||||
function = config_path_option(function)
|
||||
function = click.option(
|
||||
"-f", "--force", is_flag=True, help="Overwrite any existing configuration file."
|
||||
@ -42,7 +42,7 @@ def config_options(function: F) -> F:
|
||||
|
||||
|
||||
def verbosity_option(function: F) -> F:
|
||||
"""Wraps a function to add verbosity option."""
|
||||
"""Wrap a function to add verbosity option."""
|
||||
|
||||
def callback(ctx: Context, param: Parameter, value: str) -> None:
|
||||
if not value or ctx.resilient_parsing:
|
||||
@ -54,10 +54,10 @@ def verbosity_option(function: F) -> F:
|
||||
"-v",
|
||||
"--verbosity",
|
||||
type=click.Choice(
|
||||
["PERF", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
||||
["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
||||
case_sensitive=False,
|
||||
),
|
||||
help="Verbosity of CLI output. PERF will log performances (timing) information.",
|
||||
help="Verbosity of CLI output.",
|
||||
default=None,
|
||||
expose_value=False,
|
||||
envvar="MANIM_SLIDES_VERBOSITY",
|
||||
@ -69,7 +69,7 @@ def verbosity_option(function: F) -> F:
|
||||
|
||||
|
||||
def folder_path_option(function: F) -> F:
|
||||
"""Wraps a function to add folder path option."""
|
||||
"""Wrap a function to add folder path option."""
|
||||
wrapper: Wrapper = click.option(
|
||||
"--folder",
|
||||
metavar="DIRECTORY",
|
||||
|
@ -1,11 +1,10 @@
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from enum import Enum
|
||||
from functools import wraps
|
||||
from inspect import Parameter, signature
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple, Union
|
||||
from textwrap import dedent
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
|
||||
|
||||
import rtoml
|
||||
from pydantic import (
|
||||
@ -13,42 +12,40 @@ from pydantic import (
|
||||
Field,
|
||||
FilePath,
|
||||
PositiveInt,
|
||||
PrivateAttr,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from pydantic_extra_types.color import Color
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from .defaults import FFMPEG_BIN
|
||||
from .logger import logger
|
||||
|
||||
|
||||
def merge_basenames(files: List[FilePath]) -> Path:
|
||||
"""
|
||||
Merge multiple filenames by concatenating basenames.
|
||||
"""
|
||||
logger.info(f"Generating a new filename for animations: {files}")
|
||||
|
||||
dirname: Path = files[0].parent
|
||||
ext = files[0].suffix
|
||||
|
||||
basenames = (file.stem for file in files)
|
||||
|
||||
basenames_str = ",".join(f"{len(b)}:{b}" for b in basenames)
|
||||
|
||||
# We use hashes to prevent too-long filenames, see issue #123:
|
||||
# https://github.com/jeertmans/manim-slides/issues/123
|
||||
basename = hashlib.sha256(basenames_str.encode()).hexdigest()
|
||||
|
||||
return dirname.joinpath(basename + ext)
|
||||
Receiver = Callable[..., Any]
|
||||
|
||||
|
||||
class Key(BaseModel): # type: ignore
|
||||
class Signal(BaseModel): # type: ignore[misc]
|
||||
__receivers: List[Receiver] = PrivateAttr(default_factory=list)
|
||||
|
||||
def connect(self, receiver: Receiver) -> None:
|
||||
self.__receivers.append(receiver)
|
||||
|
||||
def disconnect(self, receiver: Receiver) -> None:
|
||||
self.__receivers.remove(receiver)
|
||||
|
||||
def emit(self, *args: Any) -> None:
|
||||
for receiver in self.__receivers:
|
||||
receiver(*args)
|
||||
|
||||
|
||||
class Key(BaseModel): # type: ignore[misc]
|
||||
"""Represents a list of key codes, with optionally a name."""
|
||||
|
||||
ids: List[PositiveInt] = Field(unique=True)
|
||||
name: Optional[str] = None
|
||||
|
||||
__signal: Signal = PrivateAttr(default_factory=Signal)
|
||||
|
||||
@field_validator("ids")
|
||||
@classmethod
|
||||
def ids_is_non_empty_set(cls, ids: Set[Any]) -> Set[Any]:
|
||||
@ -67,17 +64,26 @@ class Key(BaseModel): # type: ignore
|
||||
|
||||
return m
|
||||
|
||||
@property
|
||||
def signal(self) -> Signal:
|
||||
return self.__signal
|
||||
|
||||
class Keys(BaseModel): # type: ignore
|
||||
def connect(self, function: Receiver) -> None:
|
||||
self.__signal.connect(function)
|
||||
|
||||
|
||||
class Keys(BaseModel): # type: ignore[misc]
|
||||
QUIT: Key = Key(ids=[Qt.Key_Q], name="QUIT")
|
||||
CONTINUE: Key = Key(ids=[Qt.Key_Right], name="CONTINUE / NEXT")
|
||||
BACK: Key = Key(ids=[Qt.Key_Left], name="BACK")
|
||||
REVERSE: Key = Key(ids=[Qt.Key_V], name="REVERSE")
|
||||
REWIND: Key = Key(ids=[Qt.Key_R], name="REWIND")
|
||||
PLAY_PAUSE: Key = Key(ids=[Qt.Key_Space], name="PLAY / PAUSE")
|
||||
NEXT: Key = Key(ids=[Qt.Key_Right], name="NEXT")
|
||||
PREVIOUS: Key = Key(ids=[Qt.Key_Left], name="PREVIOUS")
|
||||
REVERSE: Key = Key(ids=[Qt.Key_V], name="REVERSE")
|
||||
REPLAY: Key = Key(ids=[Qt.Key_R], name="REPLAY")
|
||||
FULL_SCREEN: Key = Key(ids=[Qt.Key_F], name="TOGGLE FULL SCREEN")
|
||||
HIDE_MOUSE: Key = Key(ids=[Qt.Key_H], name="HIDE / SHOW MOUSE")
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def ids_are_unique_across_keys(cls, values: Dict[str, Key]) -> Dict[str, Key]:
|
||||
ids: Set[int] = set()
|
||||
|
||||
@ -98,38 +104,121 @@ class Keys(BaseModel): # type: ignore
|
||||
|
||||
return self
|
||||
|
||||
def dispatch_key_function(self) -> Callable[[PositiveInt], None]:
|
||||
_dispatch = {}
|
||||
|
||||
class Config(BaseModel): # type: ignore
|
||||
"""General Manim Slides config"""
|
||||
for _, key in self:
|
||||
for _id in key.ids:
|
||||
_dispatch[_id] = key.signal
|
||||
|
||||
def dispatch(key: PositiveInt) -> None:
|
||||
if signal := _dispatch.get(key, None):
|
||||
signal.emit()
|
||||
|
||||
return dispatch
|
||||
|
||||
|
||||
class Config(BaseModel): # type: ignore[misc]
|
||||
"""General Manim Slides config."""
|
||||
|
||||
keys: Keys = Keys()
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: Path) -> "Config":
|
||||
"""Reads a configuration from a file."""
|
||||
"""Read a configuration from a file."""
|
||||
return cls.model_validate(rtoml.load(path)) # type: ignore
|
||||
|
||||
def to_file(self, path: Path) -> None:
|
||||
"""Dumps the configuration to a file."""
|
||||
"""Dump the configuration to a file."""
|
||||
rtoml.dump(self.model_dump(), path, pretty=True)
|
||||
|
||||
def merge_with(self, other: "Config") -> "Config":
|
||||
"""Merge with another config."""
|
||||
self.keys = self.keys.merge_with(other.keys)
|
||||
return self
|
||||
|
||||
|
||||
class SlideType(str, Enum):
|
||||
slide = "slide"
|
||||
loop = "loop"
|
||||
last = "last"
|
||||
class BaseSlideConfig(BaseModel): # type: ignore
|
||||
"""Base class for slide config."""
|
||||
|
||||
loop: bool = False
|
||||
auto_next: bool = False
|
||||
playback_rate: float = 1.0
|
||||
reversed_playback_rate: float = 1.0
|
||||
notes: str = ""
|
||||
dedent_notes: bool = True
|
||||
|
||||
@classmethod
|
||||
def wrapper(cls, arg_name: str) -> Callable[..., Any]:
|
||||
"""
|
||||
Wrap a function to transform keyword argument into an instance of this class.
|
||||
|
||||
The function signature is updated to reflect the new keyword-only arguments.
|
||||
|
||||
The wrapped function must follow two criteria:
|
||||
- its last parameter must be ``**kwargs`` (or equivalent);
|
||||
- and its second last parameter must be ``<arg_name>``.
|
||||
"""
|
||||
|
||||
def _wrapper_(fun: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@wraps(fun)
|
||||
def __wrapper__(*args: Any, **kwargs: Any) -> Any: # noqa: N807
|
||||
fun_kwargs = {
|
||||
key: value
|
||||
for key, value in kwargs.items()
|
||||
if key not in cls.__fields__
|
||||
}
|
||||
fun_kwargs[arg_name] = cls(**kwargs)
|
||||
return fun(*args, **fun_kwargs)
|
||||
|
||||
sig = signature(fun)
|
||||
parameters = list(sig.parameters.values())
|
||||
parameters[-2:-1] = [
|
||||
Parameter(
|
||||
field_name,
|
||||
Parameter.KEYWORD_ONLY,
|
||||
default=field_info.default,
|
||||
annotation=field_info.annotation,
|
||||
)
|
||||
for field_name, field_info in cls.__fields__.items()
|
||||
]
|
||||
|
||||
sig = sig.replace(parameters=parameters)
|
||||
__wrapper__.__signature__ = sig # type: ignore[attr-defined]
|
||||
|
||||
return __wrapper__
|
||||
|
||||
return _wrapper_
|
||||
|
||||
@model_validator(mode="after")
|
||||
@classmethod
|
||||
def apply_dedent_notes(
|
||||
cls, base_slide_config: "BaseSlideConfig"
|
||||
) -> "BaseSlideConfig":
|
||||
if base_slide_config.dedent_notes:
|
||||
base_slide_config.notes = dedent(base_slide_config.notes)
|
||||
|
||||
return base_slide_config
|
||||
|
||||
|
||||
class SlideConfig(BaseModel): # type: ignore
|
||||
type: SlideType
|
||||
class PreSlideConfig(BaseSlideConfig):
|
||||
"""Slide config to be used prior to rendering."""
|
||||
|
||||
start_animation: int
|
||||
end_animation: int
|
||||
number: int
|
||||
terminated: bool = Field(False, exclude=True)
|
||||
|
||||
@classmethod
|
||||
def from_base_slide_config_and_animation_indices(
|
||||
cls,
|
||||
base_slide_config: BaseSlideConfig,
|
||||
start_animation: int,
|
||||
end_animation: int,
|
||||
) -> "PreSlideConfig":
|
||||
return cls(
|
||||
start_animation=start_animation,
|
||||
end_animation=end_animation,
|
||||
**base_slide_config.dict(),
|
||||
)
|
||||
|
||||
@field_validator("start_animation", "end_animation")
|
||||
@classmethod
|
||||
@ -138,19 +227,13 @@ class SlideConfig(BaseModel): # type: ignore
|
||||
raise ValueError("Animation index (start or end) cannot be negative")
|
||||
return v
|
||||
|
||||
@field_validator("number")
|
||||
@model_validator(mode="after")
|
||||
@classmethod
|
||||
def number_is_strictly_posint(cls, v: int) -> int:
|
||||
if v <= 0:
|
||||
raise ValueError("Slide number cannot be negative or zero")
|
||||
return v
|
||||
|
||||
@model_validator(mode="before")
|
||||
def start_animation_is_before_end(
|
||||
cls, values: Dict[str, Union[SlideType, int, bool]]
|
||||
) -> Dict[str, Union[SlideType, int, bool]]:
|
||||
if values["start_animation"] >= values["end_animation"]: # type: ignore
|
||||
if values["start_animation"] == values["end_animation"] == 0:
|
||||
cls, pre_slide_config: "PreSlideConfig"
|
||||
) -> "PreSlideConfig":
|
||||
if pre_slide_config.start_animation >= pre_slide_config.end_animation:
|
||||
if pre_slide_config.start_animation == pre_slide_config.end_animation == 0:
|
||||
raise ValueError(
|
||||
"You have to play at least one animation (e.g., `self.wait()`) before pausing. If you want to start paused, use the approriate command-line option when presenting. IMPORTANT: when using ManimGL, `self.wait()` is not considered to be an animation, so prefer to directly use `self.play(...)`."
|
||||
)
|
||||
@ -159,146 +242,88 @@ class SlideConfig(BaseModel): # type: ignore
|
||||
"Start animation index must be strictly lower than end animation index"
|
||||
)
|
||||
|
||||
return values
|
||||
return pre_slide_config
|
||||
|
||||
def is_slide(self) -> bool:
|
||||
return self.type == SlideType.slide
|
||||
@model_validator(mode="after")
|
||||
@classmethod
|
||||
def loop_and_auto_next_disallowed(
|
||||
cls, pre_slide_config: "PreSlideConfig"
|
||||
) -> "PreSlideConfig":
|
||||
if pre_slide_config.loop and pre_slide_config.auto_next:
|
||||
raise ValueError(
|
||||
"You cannot have both `loop=True` and `auto_next=True`, "
|
||||
"because a looping slide has no ending. "
|
||||
"This may be supported in the future if "
|
||||
"https://github.com/jeertmans/manim-slides/pull/299 gets merged."
|
||||
)
|
||||
|
||||
def is_loop(self) -> bool:
|
||||
return self.type == SlideType.loop
|
||||
|
||||
def is_last(self) -> bool:
|
||||
return self.type == SlideType.last
|
||||
return pre_slide_config
|
||||
|
||||
@property
|
||||
def slides_slice(self) -> slice:
|
||||
return slice(self.start_animation, self.end_animation)
|
||||
|
||||
|
||||
class PresentationConfig(BaseModel): # type: ignore
|
||||
class SlideConfig(BaseSlideConfig):
|
||||
"""Slide config to be used after rendering."""
|
||||
|
||||
file: FilePath
|
||||
rev_file: FilePath
|
||||
|
||||
@classmethod
|
||||
def from_pre_slide_config_and_files(
|
||||
cls, pre_slide_config: PreSlideConfig, file: Path, rev_file: Path
|
||||
) -> "SlideConfig":
|
||||
return cls(file=file, rev_file=rev_file, **pre_slide_config.dict())
|
||||
|
||||
|
||||
class PresentationConfig(BaseModel): # type: ignore[misc]
|
||||
slides: List[SlideConfig] = Field(min_length=1)
|
||||
files: List[FilePath]
|
||||
resolution: Tuple[PositiveInt, PositiveInt] = (1920, 1080)
|
||||
background_color: Color = "black"
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: Path) -> "PresentationConfig":
|
||||
"""Reads a presentation configuration from a file."""
|
||||
with open(path, "r") as f:
|
||||
"""Read a presentation configuration from a file."""
|
||||
with open(path) as f:
|
||||
obj = json.load(f)
|
||||
|
||||
if files := obj.get("files", None):
|
||||
# First parent is ../slides
|
||||
# so we take the parent of this parent
|
||||
parent = Path(path).parents[1]
|
||||
for i in range(len(files)):
|
||||
files[i] = parent / files[i]
|
||||
slides = obj.setdefault("slides", [])
|
||||
parent = path.parent.parent # Never fails, but parents[1] can fail
|
||||
|
||||
for slide in slides:
|
||||
if file := slide.get("file", None):
|
||||
slide["file"] = parent / file
|
||||
|
||||
if rev_file := slide.get("rev_file", None):
|
||||
slide["rev_file"] = parent / rev_file
|
||||
|
||||
return cls.model_validate(obj) # type: ignore
|
||||
|
||||
def to_file(self, path: Path) -> None:
|
||||
"""Dumps the presentation configuration to a file."""
|
||||
"""Dump the presentation configuration to a file."""
|
||||
with open(path, "w") as f:
|
||||
f.write(self.model_dump_json(indent=2))
|
||||
|
||||
@model_validator(mode="after")
|
||||
def animation_indices_match_files(
|
||||
cls, config: "PresentationConfig"
|
||||
def copy_to(
|
||||
self, folder: Path, use_cached: bool = True, include_reversed: bool = True
|
||||
) -> "PresentationConfig":
|
||||
files = config.files
|
||||
slides = config.slides
|
||||
"""Copy the files to a given directory."""
|
||||
for slide_config in self.slides:
|
||||
file = slide_config.file
|
||||
rev_file = slide_config.rev_file
|
||||
|
||||
n_files = len(files)
|
||||
dest = folder / file.name
|
||||
rev_dest = folder / rev_file.name
|
||||
|
||||
for slide in slides:
|
||||
if slide.end_animation > n_files:
|
||||
raise ValueError(
|
||||
f"The following slide's contains animations not listed in files {files}: {slide}"
|
||||
)
|
||||
slide_config.file = dest
|
||||
slide_config.rev_file = rev_dest
|
||||
|
||||
return config
|
||||
if not use_cached or not dest.exists():
|
||||
shutil.copy(file, dest)
|
||||
|
||||
def copy_to(self, dest: Path, use_cached: bool = True) -> "PresentationConfig":
|
||||
"""
|
||||
Copy the files to a given directory.
|
||||
"""
|
||||
n = len(self.files)
|
||||
for i in range(n):
|
||||
file = self.files[i]
|
||||
dest_path = dest / self.files[i].name
|
||||
self.files[i] = dest_path
|
||||
if use_cached and dest_path.exists():
|
||||
logger.debug(f"Skipping copy of {file}, using cached copy")
|
||||
continue
|
||||
logger.debug(f"Copying {file} to {dest_path}")
|
||||
shutil.copy(file, dest_path)
|
||||
|
||||
return self
|
||||
|
||||
def concat_animations(
|
||||
self, dest: Optional[Path] = None, use_cached: bool = True
|
||||
) -> "PresentationConfig":
|
||||
"""
|
||||
Concatenate animations such that each slide contains one animation.
|
||||
"""
|
||||
|
||||
dest_paths = []
|
||||
|
||||
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 '{path.absolute()}'\n" for path in files)
|
||||
f.close()
|
||||
|
||||
command: List[str] = [
|
||||
str(FFMPEG_BIN),
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
f.name,
|
||||
"-c",
|
||||
"copy",
|
||||
str(dest_path),
|
||||
"-y",
|
||||
]
|
||||
logger.debug(" ".join(command))
|
||||
process = subprocess.Popen(
|
||||
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
output, error = process.communicate()
|
||||
|
||||
if output:
|
||||
logger.debug(output.decode())
|
||||
|
||||
if error:
|
||||
logger.debug(error.decode())
|
||||
|
||||
if not dest_path.exists():
|
||||
raise ValueError(
|
||||
"could not properly concatenate animations, use `-v INFO` for more details"
|
||||
)
|
||||
|
||||
else:
|
||||
dest_paths.append(files[0])
|
||||
|
||||
self.files = dest_paths
|
||||
|
||||
if dest:
|
||||
return self.copy_to(dest)
|
||||
if include_reversed and (not use_cached or not rev_dest.exists()):
|
||||
shutil.copy(rev_file, rev_dest)
|
||||
|
||||
return self
|
||||
|
||||
|
@ -9,55 +9,36 @@ from base64 import b64encode
|
||||
from enum import Enum
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Generator, List, Optional, Type, Union
|
||||
from typing import Any, Callable, Dict, List, Optional, Type, Union
|
||||
|
||||
import click
|
||||
import cv2
|
||||
import pptx
|
||||
from click import Context, Parameter
|
||||
from jinja2 import Template
|
||||
from lxml import etree
|
||||
from PIL import Image
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
FilePath,
|
||||
GetCoreSchemaHandler,
|
||||
PositiveFloat,
|
||||
PositiveInt,
|
||||
ValidationError,
|
||||
conlist,
|
||||
)
|
||||
from pydantic_core import CoreSchema, core_schema
|
||||
from pydantic_extra_types.color import Color
|
||||
from tqdm import tqdm
|
||||
|
||||
from . import data
|
||||
from . import templates
|
||||
from .commons import folder_path_option, verbosity_option
|
||||
from .config import PresentationConfig
|
||||
from .logger import logger
|
||||
from .present import get_scenes_presentation_config
|
||||
|
||||
DATA_URI_FIX = r"""
|
||||
// Fix found by @t-fritsch on GitHub
|
||||
// see: https://github.com/hakimel/reveal.js/discussions/3362#discussioncomment-6651475.
|
||||
function fixBase64VideoBackground(event) {
|
||||
// event.previousSlide, event.currentSlide, event.indexh, event.indexv
|
||||
if (event.currentSlide.getAttribute('data-background-video')) {
|
||||
const background = Reveal.getSlideBackground(event.indexh, event.indexv),
|
||||
video = background.querySelector('video'),
|
||||
sources = video.querySelectorAll('source');
|
||||
|
||||
sources.forEach((source, i) => {
|
||||
const src = source.getAttribute('src');
|
||||
if(src.match(/^data:video.*;base64$/)){
|
||||
const nextSrc = sources[i+1]?.getAttribute('src');
|
||||
video.setAttribute('src', `${src},${nextSrc}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reveal.on( 'ready', fixBase64VideoBackground );
|
||||
Reveal.on( 'slidechanged', fixBase64VideoBackground );
|
||||
"""
|
||||
|
||||
|
||||
def open_with_default(file: Path) -> None:
|
||||
system = platform.system()
|
||||
@ -80,44 +61,55 @@ def validate_config_option(
|
||||
config[key] = value
|
||||
except ValueError:
|
||||
raise click.BadParameter(
|
||||
f"Configuration options `{c_option}` could not be parsed into a proper (key, value) pair. Please use an `=` sign to separate key from value."
|
||||
)
|
||||
f"Configuration options `{c_option}` could not be parsed into "
|
||||
"a proper (key, value) pair. "
|
||||
"Please use an `=` sign to separate key from value."
|
||||
) from None
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def data_uri(file: Path) -> str:
|
||||
"""
|
||||
Reads a video and returns the corresponding data-uri.
|
||||
"""
|
||||
def file_to_data_uri(file: Path) -> str:
|
||||
"""Read a video and return the corresponding data-uri."""
|
||||
b64 = b64encode(file.read_bytes()).decode("ascii")
|
||||
mime_type = mimetypes.guess_type(file)[0] or "video/mp4"
|
||||
|
||||
return f"data:{mime_type};base64,{b64}"
|
||||
|
||||
|
||||
def get_duration_ms(file: Path) -> float:
|
||||
"""Read a video and return its duration in milliseconds."""
|
||||
cap = cv2.VideoCapture(str(file))
|
||||
fps: int = cap.get(cv2.CAP_PROP_FPS)
|
||||
frame_count: int = cap.get(cv2.CAP_PROP_FRAME_COUNT)
|
||||
|
||||
return 1000 * frame_count / fps
|
||||
|
||||
|
||||
class Converter(BaseModel): # type: ignore
|
||||
presentation_configs: List[PresentationConfig] = []
|
||||
presentation_configs: conlist(PresentationConfig, min_length=1) # type: ignore[valid-type]
|
||||
assets_dir: str = "{basename}_assets"
|
||||
template: Optional[Path] = None
|
||||
|
||||
def convert_to(self, dest: Path) -> None:
|
||||
"""Converts self, i.e., a list of presentations, into a given format."""
|
||||
"""Convert self, i.e., a list of presentations, into a given format."""
|
||||
raise NotImplementedError
|
||||
|
||||
def load_template(self) -> str:
|
||||
"""Returns the template as a string.
|
||||
"""
|
||||
Return the template as a string.
|
||||
|
||||
An empty string is returned if no template is used."""
|
||||
An empty string is returned if no template is used.
|
||||
"""
|
||||
return ""
|
||||
|
||||
def open(self, file: Path) -> Any:
|
||||
"""Opens a file, generated with converter, using appropriate application."""
|
||||
"""Open a file, generated with converter, using appropriate application."""
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, s: str) -> Type["Converter"]:
|
||||
"""Returns the appropriate converter from a string name."""
|
||||
"""Return the appropriate converter from a string name."""
|
||||
return {
|
||||
"html": RevealJS,
|
||||
"pdf": PDF,
|
||||
@ -138,45 +130,50 @@ class Str(str):
|
||||
return core_schema.str_schema()
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Ensures that the string is correctly quoted."""
|
||||
"""Ensure that the string is correctly quoted."""
|
||||
if self in ["true", "false", "null"]:
|
||||
return super().__str__()
|
||||
return self
|
||||
else:
|
||||
return f"'{super().__str__()}'"
|
||||
|
||||
|
||||
class StrEnum(Enum):
|
||||
def __str__(self) -> str:
|
||||
return str(self.value)
|
||||
|
||||
|
||||
Function = str # Basically, anything
|
||||
|
||||
|
||||
class JsTrue(str, Enum):
|
||||
class JsTrue(str, StrEnum):
|
||||
true = "true"
|
||||
|
||||
|
||||
class JsFalse(str, Enum):
|
||||
class JsFalse(str, StrEnum):
|
||||
false = "false"
|
||||
|
||||
|
||||
class JsBool(Str, Enum): # type: ignore
|
||||
class JsBool(Str, StrEnum): # type: ignore
|
||||
true = "true"
|
||||
false = "false"
|
||||
|
||||
|
||||
class JsNull(Str, Enum): # type: ignore
|
||||
class JsNull(Str, StrEnum): # type: ignore
|
||||
null = "null"
|
||||
|
||||
|
||||
class ControlsLayout(Str, Enum): # type: ignore
|
||||
class ControlsLayout(Str, StrEnum): # type: ignore
|
||||
edges = "edges"
|
||||
bottom_right = "bottom-right"
|
||||
|
||||
|
||||
class ControlsBackArrows(Str, Enum): # type: ignore
|
||||
class ControlsBackArrows(Str, StrEnum): # type: ignore
|
||||
faded = "faded"
|
||||
hidden = "hidden"
|
||||
visibly = "visibly"
|
||||
|
||||
|
||||
class SlideNumber(Str, Enum): # type: ignore
|
||||
class SlideNumber(Str, StrEnum): # type: ignore
|
||||
true = "true"
|
||||
false = "false"
|
||||
hdotv = "h.v"
|
||||
@ -185,24 +182,24 @@ class SlideNumber(Str, Enum): # type: ignore
|
||||
candt = "c/t"
|
||||
|
||||
|
||||
class ShowSlideNumber(Str, Enum): # type: ignore
|
||||
class ShowSlideNumber(Str, StrEnum): # type: ignore
|
||||
all = "all"
|
||||
print = "print"
|
||||
speaker = "speaker"
|
||||
|
||||
|
||||
class KeyboardCondition(Str, Enum): # type: ignore
|
||||
class KeyboardCondition(Str, StrEnum): # type: ignore
|
||||
null = "null"
|
||||
focused = "focused"
|
||||
|
||||
|
||||
class NavigationMode(Str, Enum): # type: ignore
|
||||
class NavigationMode(Str, StrEnum): # type: ignore
|
||||
default = "default"
|
||||
linear = "linear"
|
||||
grid = "grid"
|
||||
|
||||
|
||||
class AutoPlayMedia(Str, Enum): # type: ignore
|
||||
class AutoPlayMedia(Str, StrEnum): # type: ignore
|
||||
null = "null"
|
||||
true = "true"
|
||||
false = "false"
|
||||
@ -211,25 +208,25 @@ class AutoPlayMedia(Str, Enum): # type: ignore
|
||||
PreloadIframes = AutoPlayMedia
|
||||
|
||||
|
||||
class AutoAnimateMatcher(Str, Enum): # type: ignore
|
||||
class AutoAnimateMatcher(Str, StrEnum): # type: ignore
|
||||
null = "null"
|
||||
|
||||
|
||||
class AutoAnimateEasing(Str, Enum): # type: ignore
|
||||
class AutoAnimateEasing(Str, StrEnum): # type: ignore
|
||||
ease = "ease"
|
||||
|
||||
|
||||
AutoSlide = Union[PositiveInt, JsFalse]
|
||||
|
||||
|
||||
class AutoSlideMethod(Str, Enum): # type: ignore
|
||||
class AutoSlideMethod(Str, StrEnum): # type: ignore
|
||||
null = "null"
|
||||
|
||||
|
||||
MouseWheel = Union[JsNull, float]
|
||||
|
||||
|
||||
class Transition(Str, Enum): # type: ignore
|
||||
class Transition(Str, StrEnum): # type: ignore
|
||||
none = "none"
|
||||
fade = "fade"
|
||||
slide = "slide"
|
||||
@ -238,13 +235,13 @@ class Transition(Str, Enum): # type: ignore
|
||||
zoom = "zoom"
|
||||
|
||||
|
||||
class TransitionSpeed(Str, Enum): # type: ignore
|
||||
class TransitionSpeed(Str, StrEnum): # type: ignore
|
||||
default = "default"
|
||||
fast = "fast"
|
||||
slow = "slow"
|
||||
|
||||
|
||||
class BackgroundSize(Str, Enum): # type: ignore
|
||||
class BackgroundSize(Str, StrEnum): # type: ignore
|
||||
# From: https://developer.mozilla.org/en-US/docs/Web/CSS/background-size
|
||||
# TODO: support more background size
|
||||
contain = "contain"
|
||||
@ -254,11 +251,11 @@ class BackgroundSize(Str, Enum): # type: ignore
|
||||
BackgroundTransition = Transition
|
||||
|
||||
|
||||
class Display(Str, Enum): # type: ignore
|
||||
class Display(Str, StrEnum): # type: ignore
|
||||
block = "block"
|
||||
|
||||
|
||||
class RevealTheme(str, Enum):
|
||||
class RevealTheme(str, StrEnum):
|
||||
black = "black"
|
||||
white = "white"
|
||||
league = "league"
|
||||
@ -270,6 +267,9 @@ class RevealTheme(str, Enum):
|
||||
soralized = "solarized"
|
||||
blood = "blood"
|
||||
moon = "moon"
|
||||
black_contrast = "black-contrast"
|
||||
white_contrast = "white-contrast"
|
||||
dracula = "dracula"
|
||||
|
||||
|
||||
class RevealJS(Converter):
|
||||
@ -316,20 +316,22 @@ class RevealJS(Converter):
|
||||
auto_animate_easing: AutoAnimateEasing = AutoAnimateEasing.ease
|
||||
auto_animate_duration: float = 1.0
|
||||
auto_animate_unmatched: JsBool = JsBool.true
|
||||
auto_animate_styles: List[str] = [
|
||||
"opacity",
|
||||
"color",
|
||||
"background-color",
|
||||
"padding",
|
||||
"font-size",
|
||||
"line-height",
|
||||
"letter-spacing",
|
||||
"border-width",
|
||||
"border-color",
|
||||
"border-radius",
|
||||
"outline",
|
||||
"outline-offset",
|
||||
]
|
||||
auto_animate_styles: List[str] = Field(
|
||||
default_factory=lambda: [
|
||||
"opacity",
|
||||
"color",
|
||||
"background-color",
|
||||
"padding",
|
||||
"font-size",
|
||||
"line-height",
|
||||
"letter-spacing",
|
||||
"border-width",
|
||||
"border-color",
|
||||
"border-radius",
|
||||
"outline",
|
||||
"outline-offset",
|
||||
]
|
||||
)
|
||||
auto_slide: AutoSlide = 0
|
||||
auto_slide_stoppable: JsBool = JsBool.true
|
||||
auto_slide_method: Union[AutoSlideMethod, Function] = AutoSlideMethod.null
|
||||
@ -351,57 +353,34 @@ class RevealJS(Converter):
|
||||
display: Display = Display.block
|
||||
hide_inactive_cursor: JsBool = JsBool.true
|
||||
hide_cursor_time: int = 5000
|
||||
# Add. options
|
||||
background_color: str = "black" # TODO: use pydantic.color.Color
|
||||
reveal_version: str = "4.4.0"
|
||||
# Appearance options from RevealJS
|
||||
background_color: Color = "black"
|
||||
reveal_version: str = "4.6.1"
|
||||
reveal_theme: RevealTheme = RevealTheme.black
|
||||
title: str = "Manim Slides"
|
||||
# Pydantic options
|
||||
model_config = ConfigDict(use_enum_values=True, extra="forbid")
|
||||
|
||||
def get_sections_iter(self, assets_dir: Path) -> Generator[str, None, None]:
|
||||
"""Generates a sequence of sections, one per slide, that will be included into the html template."""
|
||||
for presentation_config in self.presentation_configs:
|
||||
for slide_config in presentation_config.slides:
|
||||
file = presentation_config.files[slide_config.start_animation]
|
||||
|
||||
logger.debug(f"Writing video section with file {file}")
|
||||
|
||||
if self.data_uri:
|
||||
file = data_uri(file)
|
||||
else:
|
||||
file = assets_dir / file.name
|
||||
|
||||
# TODO: document this
|
||||
# Videos are muted because, otherwise, the first slide never plays correctly.
|
||||
# This is due to a restriction in playing audio without the user doing anything.
|
||||
# Later, this might be useful to only mute the first video, or to make it optional.
|
||||
# Read more about this:
|
||||
# https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide#autoplay_and_autoplay_blocking
|
||||
if slide_config.is_loop():
|
||||
yield f'<section data-background-size={self.background_size.value} data-background-color="{presentation_config.background_color}" data-background-video="{file}" data-background-video-muted data-background-video-loop></section>'
|
||||
else:
|
||||
yield f'<section data-background-size={self.background_size.value} data-background-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."""
|
||||
"""Return the RevealJS HTML template as a string."""
|
||||
if isinstance(self.template, Path):
|
||||
return self.template.read_text()
|
||||
|
||||
if sys.version_info < (3, 9):
|
||||
return resources.read_text(data, "revealjs_template.html")
|
||||
return resources.read_text(templates, "revealjs.html")
|
||||
|
||||
return resources.files(data).joinpath("revealjs_template.html").read_text()
|
||||
return resources.files(templates).joinpath("revealjs.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."""
|
||||
"""
|
||||
Convert this configuration into a RevealJS HTML presentation, saved to
|
||||
DEST.
|
||||
"""
|
||||
if self.data_uri:
|
||||
assets_dir = Path("") # Actually we won't care.
|
||||
|
||||
for presentation_config in self.presentation_configs:
|
||||
presentation_config.concat_animations()
|
||||
else:
|
||||
dirname = dest.parent
|
||||
basename = dest.stem
|
||||
@ -417,20 +396,27 @@ class RevealJS(Converter):
|
||||
full_assets_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for presentation_config in self.presentation_configs:
|
||||
presentation_config.concat_animations().copy_to(full_assets_dir)
|
||||
presentation_config.copy_to(full_assets_dir, include_reversed=False)
|
||||
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(dest, "w") as f:
|
||||
sections = "".join(self.get_sections_iter(assets_dir))
|
||||
revealjs_template = Template(self.load_template())
|
||||
|
||||
revealjs_template = self.load_template()
|
||||
options = self.dict()
|
||||
options["assets_dir"] = assets_dir
|
||||
|
||||
if self.data_uri:
|
||||
data_uri_fix = DATA_URI_FIX
|
||||
else:
|
||||
data_uri_fix = ""
|
||||
has_notes = any(
|
||||
slide_config.notes != ""
|
||||
for presentation_config in self.presentation_configs
|
||||
for slide_config in presentation_config.slides
|
||||
)
|
||||
|
||||
content = revealjs_template.format(
|
||||
sections=sections, data_uri_fix=data_uri_fix, **self.dict()
|
||||
content = revealjs_template.render(
|
||||
file_to_data_uri=file_to_data_uri,
|
||||
get_duration_ms=get_duration_ms,
|
||||
has_notes=has_notes,
|
||||
**options,
|
||||
)
|
||||
|
||||
f.write(content)
|
||||
@ -450,7 +436,7 @@ class PDF(Converter):
|
||||
return open_with_default(file)
|
||||
|
||||
def convert_to(self, dest: Path) -> None:
|
||||
"""Converts this configuration into a PDF presentation, saved to DEST."""
|
||||
"""Convert this configuration into a PDF presentation, saved to DEST."""
|
||||
|
||||
def read_image_from_video_file(file: Path, frame_index: FrameIndex) -> Image:
|
||||
cap = cv2.VideoCapture(str(file))
|
||||
@ -460,6 +446,7 @@ class PDF(Converter):
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, index - 1)
|
||||
|
||||
ret, frame = cap.read()
|
||||
cap.release()
|
||||
|
||||
if ret:
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
@ -470,15 +457,16 @@ class PDF(Converter):
|
||||
images = []
|
||||
|
||||
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]
|
||||
images.append(
|
||||
read_image_from_video_file(slide_config.file, self.frame_index)
|
||||
)
|
||||
|
||||
images.append(read_image_from_video_file(file, self.frame_index))
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
images[0].save(
|
||||
dest,
|
||||
@ -501,8 +489,8 @@ class PowerPoint(Converter):
|
||||
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."""
|
||||
def convert_to(self, dest: Path) -> None: # noqa: C901
|
||||
"""Convert this configuration into a PowerPoint presentation, saved to DEST."""
|
||||
prs = pptx.Presentation()
|
||||
prs.slide_width = self.width * 9525
|
||||
prs.slide_height = self.height * 9525
|
||||
@ -531,25 +519,26 @@ class PowerPoint(Converter):
|
||||
return etree.ElementBase.xpath(el, query, namespaces=nsmap)
|
||||
|
||||
def save_first_image_from_video_file(file: Path) -> Optional[str]:
|
||||
cap = cv2.VideoCapture(str(file))
|
||||
cap = cv2.VideoCapture(file.as_posix())
|
||||
ret, frame = cap.read()
|
||||
cap.release()
|
||||
|
||||
if ret:
|
||||
f = tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".png")
|
||||
cv2.imwrite(f.name, frame)
|
||||
f.close()
|
||||
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]
|
||||
file = slide_config.file
|
||||
|
||||
mime_type = mimetypes.guess_type(file)[0]
|
||||
|
||||
@ -568,14 +557,18 @@ class PowerPoint(Converter):
|
||||
poster_frame_image=poster_frame_image,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
if self.auto_play_media:
|
||||
auto_play_media(movie, loop=slide_config.is_loop())
|
||||
if slide_config.notes != "":
|
||||
slide.notes_slide.notes_text_frame.text = slide_config.notes
|
||||
|
||||
if self.auto_play_media:
|
||||
auto_play_media(movie, loop=slide_config.loop)
|
||||
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
prs.save(dest)
|
||||
|
||||
|
||||
def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Wraps a function to add a `--show-config` option."""
|
||||
"""Wrap a function to add a `--show-config` option."""
|
||||
|
||||
def callback(ctx: Context, param: Parameter, value: bool) -> None:
|
||||
if not value or ctx.resilient_parsing:
|
||||
@ -583,9 +576,11 @@ def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
|
||||
to = ctx.params.get("to", "html")
|
||||
|
||||
converter = Converter.from_string(to)(presentation_configs=[])
|
||||
converter = Converter.from_string(to)(
|
||||
presentation_configs=[PresentationConfig()]
|
||||
)
|
||||
for key, value in converter.dict().items():
|
||||
click.echo(f"{key}: {repr(value)}")
|
||||
click.echo(f"{key}: {value!r}")
|
||||
|
||||
ctx.exit()
|
||||
|
||||
@ -601,7 +596,7 @@ def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
|
||||
|
||||
def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Wraps a function to add a `--show-template` option."""
|
||||
"""Wrap a function to add a `--show-template` option."""
|
||||
|
||||
def callback(ctx: Context, param: Parameter, value: bool) -> None:
|
||||
if not value or ctx.resilient_parsing:
|
||||
@ -611,7 +606,7 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
template = ctx.params.get("template", None)
|
||||
|
||||
converter = Converter.from_string(to)(
|
||||
presentation_configs=[], template=template
|
||||
presentation_configs=[PresentationConfig()], template=template
|
||||
)
|
||||
click.echo(converter.load_template())
|
||||
|
||||
@ -634,10 +629,11 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@click.argument("dest", type=click.Path(dir_okay=False, path_type=Path))
|
||||
@click.option(
|
||||
"--to",
|
||||
type=click.Choice(["html", "pdf", "pptx"], case_sensitive=False),
|
||||
default="html",
|
||||
type=click.Choice(["auto", "html", "pdf", "pptx"], case_sensitive=False),
|
||||
metavar="FORMAT",
|
||||
default="auto",
|
||||
show_default=True,
|
||||
help="Set the conversion format to use.",
|
||||
help="Set the conversion format to use. Use 'auto' to detect format from DEST.",
|
||||
)
|
||||
@click.option(
|
||||
"--open",
|
||||
@ -674,14 +670,23 @@ def convert(
|
||||
config_options: Dict[str, str],
|
||||
template: Optional[Path],
|
||||
) -> None:
|
||||
"""
|
||||
Convert SCENE(s) into a given format and writes the result in DEST.
|
||||
"""
|
||||
|
||||
"""Convert SCENE(s) into a given format and writes the result in DEST."""
|
||||
presentation_configs = get_scenes_presentation_config(scenes, folder)
|
||||
|
||||
try:
|
||||
converter = Converter.from_string(to)(
|
||||
if to == "auto":
|
||||
fmt = dest.suffix[1:].lower()
|
||||
try:
|
||||
cls = Converter.from_string(fmt)
|
||||
except KeyError:
|
||||
logger.warn(
|
||||
f"Could not guess conversion format from {dest!s}, defaulting to HTML."
|
||||
)
|
||||
cls = RevealJS
|
||||
else:
|
||||
cls = Converter.from_string(to)
|
||||
|
||||
converter = cls(
|
||||
presentation_configs=presentation_configs,
|
||||
template=template,
|
||||
**config_options,
|
||||
@ -704,4 +709,4 @@ def convert(
|
||||
_msg = error["msg"]
|
||||
msg.append(f"Option '{option}': {_msg}")
|
||||
|
||||
raise click.UsageError("\n".join(msg))
|
||||
raise click.UsageError("\n".join(msg)) from None
|
||||
|
@ -1,290 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
|
||||
<title>{title}</title>
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{reveal_version}/reveal.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{reveal_version}/theme/{reveal_theme}.min.css">
|
||||
|
||||
<!-- Theme used for syntax highlighting of code -->
|
||||
<!-- <link rel="stylesheet" href="lib/css/zenburn.css"> -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.13.1/styles/zenburn.min.css">
|
||||
|
||||
<!-- <link rel="stylesheet" href="index.css"> -->
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="reveal">
|
||||
<div class="slides">
|
||||
{sections}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{reveal_version}/reveal.min.js"></script>
|
||||
|
||||
<!-- To include plugins, see: https://revealjs.com/plugins/ -->
|
||||
|
||||
<!-- <script src="index.js"></script> -->
|
||||
<script>
|
||||
Reveal.initialize({{
|
||||
// The "normal" size of the presentation, aspect ratio will
|
||||
// be preserved when the presentation is scaled to fit different
|
||||
// resolutions. Can be specified using percentage units.
|
||||
width: {width},
|
||||
height: {height},
|
||||
|
||||
// Factor of the display size that should remain empty around
|
||||
// the content
|
||||
margin: {margin},
|
||||
|
||||
// Bounds for smallest/largest possible scale to apply to content
|
||||
minScale: {min_scale},
|
||||
maxScale: {max_scale},
|
||||
|
||||
// Display presentation control arrows
|
||||
controls: {controls},
|
||||
|
||||
// Help the user learn the controls by providing hints, for example by
|
||||
// bouncing the down arrow when they first encounter a vertical slide
|
||||
controlsTutorial: {controls_tutorial},
|
||||
|
||||
// Determines where controls appear, "edges" or "bottom-right"
|
||||
controlsLayout: {controls_layout},
|
||||
|
||||
// Visibility rule for backwards navigation arrows; "faded", "hidden"
|
||||
// or "visible"
|
||||
controlsBackArrows: {controls_back_arrows},
|
||||
|
||||
// Display a presentation progress bar
|
||||
progress: {progress},
|
||||
|
||||
// Display the page number of the current slide
|
||||
// - true: Show slide number
|
||||
// - false: Hide slide number
|
||||
//
|
||||
// Can optionally be set as a string that specifies the number formatting:
|
||||
// - "h.v": Horizontal . vertical slide number (default)
|
||||
// - "h/v": Horizontal / vertical slide number
|
||||
// - "c": Flattened slide number
|
||||
// - "c/t": Flattened slide number / total slides
|
||||
//
|
||||
// Alternatively, you can provide a function that returns the slide
|
||||
// number for the current slide. The function should take in a slide
|
||||
// object and return an array with one string [slideNumber] or
|
||||
// three strings [n1,delimiter,n2]. See #formatSlideNumber().
|
||||
slideNumber: {slide_number},
|
||||
|
||||
// Can be used to limit the contexts in which the slide number appears
|
||||
// - "all": Always show the slide number
|
||||
// - "print": Only when printing to PDF
|
||||
// - "speaker": Only in the speaker view
|
||||
showSlideNumber: {show_slide_number},
|
||||
|
||||
// Use 1 based indexing for # links to match slide number (default is zero
|
||||
// based)
|
||||
hashOneBasedIndex: {hash_one_based_index},
|
||||
|
||||
// Add the current slide number to the URL hash so that reloading the
|
||||
// page/copying the URL will return you to the same slide
|
||||
hash: {hash},
|
||||
|
||||
// Flags if we should monitor the hash and change slides accordingly
|
||||
respondToHashChanges: {respond_to_hash_changes},
|
||||
|
||||
// Push each slide change to the browser history. Implies `hash: true`
|
||||
history: {history},
|
||||
|
||||
// Enable keyboard shortcuts for navigation
|
||||
keyboard: {keyboard},
|
||||
|
||||
// Optional function that blocks keyboard events when retuning false
|
||||
//
|
||||
// If you set this to 'focused', we will only capture keyboard events
|
||||
// for embedded decks when they are in focus
|
||||
keyboardCondition: {keyboard_condition},
|
||||
|
||||
// Disables the default reveal.js slide layout (scaling and centering)
|
||||
// so that you can use custom CSS layout
|
||||
disableLayout: {disable_layout},
|
||||
|
||||
// Enable the slide overview mode
|
||||
overview: {overview},
|
||||
|
||||
// Vertical centering of slides
|
||||
center: {center},
|
||||
|
||||
// Enables touch navigation on devices with touch input
|
||||
touch: {touch},
|
||||
|
||||
// Loop the presentation
|
||||
loop: {loop},
|
||||
|
||||
// Change the presentation direction to be RTL
|
||||
rtl: {rtl},
|
||||
|
||||
// Changes the behavior of our navigation directions.
|
||||
//
|
||||
// "default"
|
||||
// Left/right arrow keys step between horizontal slides, up/down
|
||||
// arrow keys step between vertical slides. Space key steps through
|
||||
// all slides (both horizontal and vertical).
|
||||
//
|
||||
// "linear"
|
||||
// Removes the up/down arrows. Left/right arrows step through all
|
||||
// slides (both horizontal and vertical).
|
||||
//
|
||||
// "grid"
|
||||
// When this is enabled, stepping left/right from a vertical stack
|
||||
// to an adjacent vertical stack will land you at the same vertical
|
||||
// index.
|
||||
//
|
||||
// Consider a deck with six slides ordered in two vertical stacks:
|
||||
// 1.1 2.1
|
||||
// 1.2 2.2
|
||||
// 1.3 2.3
|
||||
//
|
||||
// If you're on slide 1.3 and navigate right, you will normally move
|
||||
// from 1.3 -> 2.1. If "grid" is used, the same navigation takes you
|
||||
// from 1.3 -> 2.3.
|
||||
navigationMode: {navigation_mode},
|
||||
|
||||
// Randomizes the order of slides each time the presentation loads
|
||||
shuffle: {shuffle},
|
||||
|
||||
// Turns fragments on and off globally
|
||||
fragments: {fragments},
|
||||
|
||||
// Flags whether to include the current fragment in the URL,
|
||||
// so that reloading brings you to the same fragment position
|
||||
fragmentInURL: {fragment_in_url},
|
||||
|
||||
// Flags if the presentation is running in an embedded mode,
|
||||
// i.e. contained within a limited portion of the screen
|
||||
embedded: {embedded},
|
||||
|
||||
// Flags if we should show a help overlay when the question-mark
|
||||
// key is pressed
|
||||
help: {help},
|
||||
|
||||
// Flags if it should be possible to pause the presentation (blackout)
|
||||
pause: {pause},
|
||||
|
||||
// Flags if speaker notes should be visible to all viewers
|
||||
showNotes: {show_notes},
|
||||
|
||||
// Global override for autolaying embedded media (video/audio/iframe)
|
||||
// - null: Media will only autoplay if data-autoplay is present
|
||||
// - true: All media will autoplay, regardless of individual setting
|
||||
// - false: No media will autoplay, regardless of individual setting
|
||||
autoPlayMedia: {auto_play_media},
|
||||
|
||||
// Global override for preloading lazy-loaded iframes
|
||||
// - null: Iframes with data-src AND data-preload will be loaded when within
|
||||
// the viewDistance, iframes with only data-src will be loaded when visible
|
||||
// - true: All iframes with data-src will be loaded when within the viewDistance
|
||||
// - false: All iframes with data-src will be loaded only when visible
|
||||
preloadIframes: {preload_iframes},
|
||||
|
||||
// Can be used to globally disable auto-animation
|
||||
autoAnimate: {auto_animate},
|
||||
|
||||
// Optionally provide a custom element matcher that will be
|
||||
// used to dictate which elements we can animate between.
|
||||
autoAnimateMatcher: {auto_animate_matcher},
|
||||
|
||||
// Default settings for our auto-animate transitions, can be
|
||||
// overridden per-slide or per-element via data arguments
|
||||
autoAnimateEasing: {auto_animate_easing},
|
||||
autoAnimateDuration: {auto_animate_duration},
|
||||
autoAnimateUnmatched: {auto_animate_unmatched},
|
||||
|
||||
// CSS properties that can be auto-animated. Position & scale
|
||||
// is matched separately so there's no need to include styles
|
||||
// like top/right/bottom/left, width/height or margin.
|
||||
autoAnimateStyles: {auto_animate_styles},
|
||||
|
||||
// Controls automatic progression to the next slide
|
||||
// - 0: Auto-sliding only happens if the data-autoslide HTML attribute
|
||||
// is present on the current slide or fragment
|
||||
// - 1+: All slides will progress automatically at the given interval
|
||||
// - false: No auto-sliding, even if data-autoslide is present
|
||||
autoSlide: {auto_slide},
|
||||
|
||||
// Stop auto-sliding after user input
|
||||
autoSlideStoppable: {auto_slide_stoppable},
|
||||
|
||||
// Use this method for navigation when auto-sliding (defaults to navigateNext)
|
||||
autoSlideMethod: {auto_slide_method},
|
||||
|
||||
// Specify the average time in seconds that you think you will spend
|
||||
// presenting each slide. This is used to show a pacing timer in the
|
||||
// speaker view
|
||||
defaultTiming: {default_timing},
|
||||
|
||||
// Enable slide navigation via mouse wheel
|
||||
mouseWheel: {mouse_wheel},
|
||||
|
||||
// Opens links in an iframe preview overlay
|
||||
// Add `data-preview-link` and `data-preview-link="false"` to customise each link
|
||||
// individually
|
||||
previewLinks: {preview_links},
|
||||
|
||||
// Exposes the reveal.js API through window.postMessage
|
||||
postMessage: {post_message},
|
||||
|
||||
// Dispatches all reveal.js events to the parent window through postMessage
|
||||
postMessageEvents: {post_message_events},
|
||||
|
||||
// Focuses body when page changes visibility to ensure keyboard shortcuts work
|
||||
focusBodyOnPageVisibilityChange: {focus_body_on_page_visibility_change},
|
||||
|
||||
// Transition style
|
||||
transition: {transition}, // none/fade/slide/convex/concave/zoom
|
||||
|
||||
// Transition speed
|
||||
transitionSpeed: {transition_speed}, // default/fast/slow
|
||||
|
||||
// Transition style for full page slide backgrounds
|
||||
backgroundTransition: {background_transition}, // none/fade/slide/convex/concave/zoom
|
||||
|
||||
// The maximum number of pages a single slide can expand onto when printing
|
||||
// to PDF, unlimited by default
|
||||
pdfMaxPagesPerSlide: {pdf_max_pages_per_slide},
|
||||
|
||||
// Prints each fragment on a separate slide
|
||||
pdfSeparateFragments: {pdf_separate_fragments},
|
||||
|
||||
// Offset used to reduce the height of content within exported PDF pages.
|
||||
// This exists to account for environment differences based on how you
|
||||
// print to PDF. CLI printing options, like phantomjs and wkpdf, can end
|
||||
// on precisely the total height of the document whereas in-browser
|
||||
// printing has to end one pixel before.
|
||||
pdfPageHeightOffset: {pdf_page_height_offset},
|
||||
|
||||
// Number of slides away from the current that are visible
|
||||
viewDistance: {view_distance},
|
||||
|
||||
// Number of slides away from the current that are visible on mobile
|
||||
// devices. It is advisable to set this to a lower number than
|
||||
// viewDistance in order to save resources.
|
||||
mobileViewDistance: {mobile_view_distance},
|
||||
|
||||
// The display mode that will be used to show slides
|
||||
display: {display},
|
||||
|
||||
// Hide cursor if inactive
|
||||
hideInactiveCursor: {hide_inactive_cursor},
|
||||
|
||||
// Time before the cursor is hidden (in ms)
|
||||
hideCursorTime: {hide_cursor_time}
|
||||
}});
|
||||
|
||||
{data_uri_fix}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
@ -2,4 +2,3 @@ from pathlib import Path
|
||||
|
||||
FOLDER_PATH: Path = Path("./slides")
|
||||
CONFIG_PATH: Path = Path(".manim-slides.toml")
|
||||
FFMPEG_BIN: Path = Path("ffmpeg")
|
||||
|
@ -1,6 +1,6 @@
|
||||
# type: ignore
|
||||
r"""
|
||||
A directive for including Manim slides in a Sphinx document
|
||||
A directive for including Manim Slides in a Sphinx document
|
||||
===========================================================
|
||||
|
||||
.. warning::
|
||||
@ -21,7 +21,9 @@ This directive requires three additional dependencies:
|
||||
with Sphinx.
|
||||
You can install them manually, or with the extra keyword:
|
||||
|
||||
pip install manim-slides[sphinx-directive]
|
||||
.. code-block:: bash
|
||||
|
||||
pip install "manim-slides[sphinx-directive]"
|
||||
|
||||
Note that you will still need to install Manim's platform-specific dependencies,
|
||||
see
|
||||
@ -69,13 +71,27 @@ render scenes that are defined within doctests, for example::
|
||||
>>> class DirectiveDoctestExample(Slide):
|
||||
... def construct(self):
|
||||
... self.play(Create(dot))
|
||||
...
|
||||
|
||||
A third application is to render scenes from another specific file::
|
||||
|
||||
.. manim-slides:: file.py:FileExample
|
||||
:hide_source:
|
||||
:quality: high
|
||||
|
||||
.. warning::
|
||||
|
||||
The code will be executed with the current working directory
|
||||
being the same as the one containing the source file. This being said,
|
||||
you should probably not include examples that rely on external files, since
|
||||
relative paths risk to be broken.
|
||||
|
||||
Options
|
||||
-------
|
||||
|
||||
Options can be passed as follows::
|
||||
|
||||
.. manim-slides:: <Class name>
|
||||
.. manim-slides:: <file>:<Class name>
|
||||
:<option name>: <value>
|
||||
|
||||
The following configuration options are supported by the
|
||||
@ -101,7 +117,7 @@ directive:
|
||||
A list of methods, separated by spaces,
|
||||
that is rendered in a reference block after the source code.
|
||||
|
||||
"""
|
||||
""" # noqa: D400, D415
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
@ -124,8 +140,9 @@ classnamedict = {}
|
||||
|
||||
|
||||
class SkipManimNode(nodes.Admonition, nodes.Element):
|
||||
"""Auxiliary node class that is used when the ``skip-manim-slides`` tag is
|
||||
present or ``.pot`` files are being built.
|
||||
"""
|
||||
Auxiliary node class that is used when the ``skip-manim-slides`` tag is present or
|
||||
``.pot`` files are being built.
|
||||
|
||||
Skips rendering the manim-slides directive and outputs a placeholder instead.
|
||||
"""
|
||||
@ -144,8 +161,9 @@ def depart(self, node):
|
||||
|
||||
|
||||
def process_name_list(option_input: str, reference_type: str) -> list[str]:
|
||||
r"""Reformats a string of space separated class names
|
||||
as a list of strings containing valid Sphinx references.
|
||||
r"""
|
||||
Reformats a string of space separated class names as a list of strings containing
|
||||
valid Sphinx references.
|
||||
|
||||
Tests
|
||||
-----
|
||||
@ -161,15 +179,16 @@ def process_name_list(option_input: str, reference_type: str) -> list[str]:
|
||||
|
||||
|
||||
class ManimSlidesDirective(Directive):
|
||||
r"""The manim-slides directive, rendering videos while building
|
||||
the documentation.
|
||||
r"""
|
||||
The manim-slides directive, rendering videos while building the documentation.
|
||||
|
||||
See the module docstring for documentation.
|
||||
"""
|
||||
|
||||
has_content = True
|
||||
required_arguments = 1
|
||||
optional_arguments = 0
|
||||
option_spec = {
|
||||
option_spec = { # noqa: RUF012
|
||||
"hide_source": bool,
|
||||
"quality": lambda arg: directives.choice(
|
||||
arg,
|
||||
@ -182,7 +201,7 @@ class ManimSlidesDirective(Directive):
|
||||
}
|
||||
final_argument_whitespace = True
|
||||
|
||||
def run(self):
|
||||
def run(self): # noqa: C901
|
||||
# Rendering is skipped if the tag skip-manim is present,
|
||||
# or if we are making the pot-files
|
||||
should_skip = (
|
||||
@ -211,7 +230,17 @@ class ManimSlidesDirective(Directive):
|
||||
|
||||
global classnamedict
|
||||
|
||||
clsname = self.arguments[0]
|
||||
def split_file_cls(arg: str) -> tuple[Path, str]:
|
||||
if ":" in arg:
|
||||
file, cls = arg.split(":", maxsplit=1)
|
||||
_, file = self.state.document.settings.env.relfn2path(file)
|
||||
return Path(file), cls
|
||||
else:
|
||||
return None, arg
|
||||
|
||||
arguments = [split_file_cls(arg) for arg in self.arguments]
|
||||
|
||||
clsname = arguments[0][1]
|
||||
if clsname not in classnamedict:
|
||||
classnamedict[clsname] = 1
|
||||
else:
|
||||
@ -271,20 +300,24 @@ class ManimSlidesDirective(Directive):
|
||||
"output_file": output_file,
|
||||
}
|
||||
|
||||
user_code = self.content
|
||||
if file := arguments[0][0]:
|
||||
user_code = file.absolute().read_text().splitlines()
|
||||
else:
|
||||
user_code = self.content
|
||||
|
||||
if user_code[0].startswith(">>> "): # check whether block comes from doctest
|
||||
user_code = [
|
||||
line[4:] for line in user_code if line.startswith((">>> ", "... "))
|
||||
]
|
||||
|
||||
code = [
|
||||
"from manim import *",
|
||||
*user_code,
|
||||
f"{clsname}().render()",
|
||||
]
|
||||
|
||||
try:
|
||||
with tempconfig(example_config):
|
||||
print(f"Rendering {clsname}...") # noqa: T201
|
||||
run_time = timeit(lambda: exec("\n".join(code), globals()), number=1)
|
||||
video_dir = config.get_dir("video_dir")
|
||||
except Exception as e:
|
||||
@ -306,9 +339,6 @@ class ManimSlidesDirective(Directive):
|
||||
RevealJS(presentation_configs=presentation_configs, controls="true").convert_to(
|
||||
destfile
|
||||
)
|
||||
# shutil.copyfile(filesrc, destfile)
|
||||
|
||||
print("CLASS NAME:", clsname)
|
||||
|
||||
rendered_template = jinja2.Template(TEMPLATE).render(
|
||||
clsname=clsname,
|
||||
@ -348,7 +378,7 @@ def _log_rendering_times(*args):
|
||||
if len(data) == 0:
|
||||
sys.exit()
|
||||
|
||||
print("\nRendering Summary\n-----------------\n")
|
||||
print("\nRendering Summary\n-----------------\n") # noqa: T201
|
||||
|
||||
max_file_length = max(len(row[0]) for row in data)
|
||||
for key, group in it.groupby(data, key=lambda row: row[0]):
|
||||
@ -356,15 +386,17 @@ def _log_rendering_times(*args):
|
||||
group = list(group)
|
||||
if len(group) == 1:
|
||||
row = group[0]
|
||||
print(f"{key}{row[2].rjust(7, '.')}s {row[1]}")
|
||||
print(f"{key}{row[2].rjust(7, '.')}s {row[1]}") # noqa: T201
|
||||
continue
|
||||
time_sum = sum(float(row[2]) for row in group)
|
||||
print(
|
||||
print( # noqa: T201
|
||||
f"{key}{f'{time_sum:.3f}'.rjust(7, '.')}s => {len(group)} EXAMPLES",
|
||||
)
|
||||
for row in group:
|
||||
print(f"{' '*(max_file_length)} {row[2].rjust(7)}s {row[1]}")
|
||||
print("")
|
||||
print( # noqa: T201
|
||||
f"{' '*(max_file_length)} {row[2].rjust(7)}s {row[1]}"
|
||||
)
|
||||
print("") # noqa: T201
|
||||
|
||||
|
||||
def _delete_rendering_times(*args):
|
||||
@ -400,6 +432,7 @@ TEMPLATE = r"""
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<!-- From: https://faq.dailymotion.com/hc/en-us/articles/360022841393-How-to-preserve-the-player-aspect-ratio-on-a-responsive-page -->
|
||||
|
||||
<div style="position:relative;padding-bottom:56.25%;">
|
||||
<iframe
|
||||
|
@ -16,7 +16,9 @@ Utilities for using Manim Slides with IPython (in particular: Jupyter notebooks)
|
||||
This magic requires two additional dependencies: ``manim`` and ``IPython``.
|
||||
You can install them manually, or with the extra keyword:
|
||||
|
||||
pip install manim-slides[magic]
|
||||
.. code-block:: bash
|
||||
|
||||
pip install "manim-slides[magic]"
|
||||
|
||||
Note that you will still need to install Manim's platform-specific dependencies,
|
||||
see
|
||||
@ -30,7 +32,7 @@ import mimetypes
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any
|
||||
|
||||
from IPython import get_ipython
|
||||
from IPython.core.interactiveshell import InteractiveShell
|
||||
@ -49,18 +51,18 @@ from ..present import get_scenes_presentation_config
|
||||
class ManimSlidesMagic(Magics): # type: ignore
|
||||
def __init__(self, shell: InteractiveShell) -> None:
|
||||
super().__init__(shell)
|
||||
self.rendered_files: Dict[Path, Path] = {}
|
||||
self.rendered_files: dict[Path, Path] = {}
|
||||
|
||||
@needs_local_scope
|
||||
@line_cell_magic
|
||||
def manim_slides(
|
||||
def manim_slides( # noqa: C901
|
||||
self,
|
||||
line: str,
|
||||
cell: Optional[str] = None,
|
||||
local_ns: Dict[str, Any] = {},
|
||||
cell: str | None = None,
|
||||
local_ns: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
r"""Render Manim Slides contained in IPython cells.
|
||||
Works as a line or cell magic.
|
||||
r"""
|
||||
Render Manim Slides contained in IPython cells. Works as a line or cell magic.
|
||||
|
||||
.. note::
|
||||
|
||||
@ -118,7 +120,6 @@ class ManimSlidesMagic(Magics): # type: ignore
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
First make sure to put ``from manim_slides import ManimSlidesMagic``,
|
||||
or even ``from manim_slides import *``
|
||||
in a cell and evaluate it. Then, a typical Jupyter notebook cell for Manim Slides
|
||||
@ -143,8 +144,9 @@ class ManimSlidesMagic(Magics): # type: ignore
|
||||
In case you want to hide the red box containing the output progress bar, the ``progress_bar`` config
|
||||
option should be set to ``None``. This can also be done by passing ``--progress_bar None`` as a
|
||||
CLI flag.
|
||||
|
||||
"""
|
||||
if local_ns is None:
|
||||
local_ns = {}
|
||||
if cell:
|
||||
exec(cell, local_ns)
|
||||
|
||||
@ -174,8 +176,8 @@ class ManimSlidesMagic(Magics): # type: ignore
|
||||
renderer = OpenGLRenderer()
|
||||
|
||||
try:
|
||||
SceneClass = local_ns[config["scene_names"][0]]
|
||||
scene = SceneClass(renderer=renderer)
|
||||
scene_cls = local_ns[config["scene_names"][0]]
|
||||
scene = scene_cls(renderer=renderer)
|
||||
scene.render()
|
||||
finally:
|
||||
# Shader cache becomes invalid as the context is destroyed
|
||||
|
@ -1,5 +1,7 @@
|
||||
"""
|
||||
Logger utils, mostly copied from Manim Community:
|
||||
Logger utils, mostly copied from Manim Community.
|
||||
|
||||
Source code:
|
||||
https://github.com/ManimCommunity/manim/blob/d5b65b844b8ce8ff5151a2f56f9dc98cebbc1db4/manim/_config/logger_utils.py#L29-L101
|
||||
"""
|
||||
|
||||
@ -7,9 +9,8 @@ import logging
|
||||
|
||||
from rich.console import Console
|
||||
from rich.logging import RichHandler
|
||||
from rich.theme import Theme
|
||||
|
||||
__all__ = ["logger", "make_logger"]
|
||||
__all__ = ["logger"]
|
||||
|
||||
HIGHLIGHTED_KEYWORDS = [ # these keywords are highlighted specially
|
||||
"Played",
|
||||
@ -30,20 +31,22 @@ HIGHLIGHTED_KEYWORDS = [ # these keywords are highlighted specially
|
||||
|
||||
|
||||
def make_logger() -> logging.Logger:
|
||||
"""
|
||||
Make a logger similar to the one used by Manim.
|
||||
"""
|
||||
"""Make a logger similar to the one used by Manim."""
|
||||
RichHandler.KEYWORDS = HIGHLIGHTED_KEYWORDS
|
||||
rich_handler = RichHandler(
|
||||
show_time=True,
|
||||
console=Console(theme=Theme({"logging.level.perf": "magenta"})),
|
||||
console=Console(),
|
||||
)
|
||||
logging.addLevelName(5, "PERF")
|
||||
logger = logging.getLogger("manim-slides")
|
||||
logger.setLevel(logging.getLogger("manim").level)
|
||||
logger.addHandler(rich_handler)
|
||||
|
||||
if not (libav_logger := logging.getLogger("libav")).hasHandlers():
|
||||
libav_logger.addHandler(rich_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
make_logger()
|
||||
|
||||
logger = logging.getLogger("manim-slides")
|
||||
|
@ -1,93 +0,0 @@
|
||||
import sys
|
||||
from importlib.util import find_spec
|
||||
|
||||
__all__ = [
|
||||
# Constants
|
||||
"FFMPEG_BIN",
|
||||
"LEFT",
|
||||
"MANIM",
|
||||
"MANIM_PACKAGE_NAME",
|
||||
"MANIM_AVAILABLE",
|
||||
"MANIM_IMPORTED",
|
||||
"MANIMGL",
|
||||
"MANIMGL_PACKAGE_NAME",
|
||||
"MANIMGL_AVAILABLE",
|
||||
"MANIMGL_IMPORTED",
|
||||
# Classes
|
||||
"AnimationGroup",
|
||||
"FadeIn",
|
||||
"FadeOut",
|
||||
"Mobject",
|
||||
"Scene",
|
||||
"ThreeDScene",
|
||||
# Objects
|
||||
"logger",
|
||||
"config",
|
||||
]
|
||||
|
||||
|
||||
MANIM_PACKAGE_NAME = "manim"
|
||||
MANIM_AVAILABLE = find_spec(MANIM_PACKAGE_NAME) is not None
|
||||
MANIM_IMPORTED = MANIM_PACKAGE_NAME in sys.modules
|
||||
|
||||
MANIMGL_PACKAGE_NAME = "manimlib"
|
||||
MANIMGL_AVAILABLE = find_spec(MANIMGL_PACKAGE_NAME) is not None
|
||||
MANIMGL_IMPORTED = MANIMGL_PACKAGE_NAME in sys.modules
|
||||
|
||||
if MANIM_IMPORTED and MANIMGL_IMPORTED:
|
||||
from manim import logger
|
||||
|
||||
logger.warning(
|
||||
"Both manim and manimgl are imported, therefore `manim-slide` needs to know which one to use. Please only import one of the two modules so that `manim-slide` knows which one to use. Here, manim is used by default"
|
||||
)
|
||||
MANIM = True
|
||||
MANIMGL = False
|
||||
elif MANIM_IMPORTED:
|
||||
MANIM = True
|
||||
MANIMGL = False
|
||||
elif MANIMGL_IMPORTED:
|
||||
MANIM = False
|
||||
MANIMGL = True
|
||||
elif MANIM_AVAILABLE:
|
||||
MANIM = True
|
||||
MANIMGL = False
|
||||
elif MANIMGL_AVAILABLE:
|
||||
MANIM = False
|
||||
MANIMGL = True
|
||||
else:
|
||||
raise ModuleNotFoundError(
|
||||
"Either manim (community) or manimgl (3b1b) package must be installed"
|
||||
)
|
||||
|
||||
|
||||
if MANIMGL:
|
||||
from manimlib import (
|
||||
LEFT,
|
||||
AnimationGroup,
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
Mobject,
|
||||
Scene,
|
||||
ThreeDScene,
|
||||
config,
|
||||
)
|
||||
from manimlib.constants import FFMPEG_BIN
|
||||
from manimlib.logger import log as logger
|
||||
|
||||
else:
|
||||
from manim import (
|
||||
LEFT,
|
||||
AnimationGroup,
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
Mobject,
|
||||
Scene,
|
||||
ThreeDScene,
|
||||
config,
|
||||
logger,
|
||||
)
|
||||
|
||||
try: # For manim<v0.16.0.post0
|
||||
from manim.constants import FFMPEG_BIN
|
||||
except ImportError:
|
||||
FFMPEG_BIN = config.ffmpeg_executable
|
321
manim_slides/present/__init__.py
Normal file
@ -0,0 +1,321 @@
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import click
|
||||
from click import Context, Parameter
|
||||
from pydantic import ValidationError
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from ..commons import config_path_option, folder_path_option, verbosity_option
|
||||
from ..config import Config, PresentationConfig
|
||||
from ..logger import logger
|
||||
from ..qt_utils import qapp
|
||||
from .player import Player
|
||||
|
||||
ASPECT_RATIO_MODES = {
|
||||
"keep": Qt.KeepAspectRatio,
|
||||
"ignore": Qt.IgnoreAspectRatio,
|
||||
}
|
||||
|
||||
|
||||
@click.command()
|
||||
@folder_path_option
|
||||
@click.help_option("-h", "--help")
|
||||
@verbosity_option
|
||||
def list_scenes(folder: Path) -> None:
|
||||
"""List available scenes."""
|
||||
for i, scene in enumerate(_list_scenes(folder), start=1):
|
||||
click.secho(f"{i}: {scene}", fg="green")
|
||||
|
||||
|
||||
def _list_scenes(folder: Path) -> List[str]:
|
||||
"""List available scenes in given directory."""
|
||||
scenes = []
|
||||
|
||||
for filepath in folder.glob("*.json"):
|
||||
try:
|
||||
_ = PresentationConfig.from_file(filepath)
|
||||
scenes.append(filepath.stem)
|
||||
except (
|
||||
Exception
|
||||
) as e: # Could not parse this file as a proper presentation config
|
||||
logger.warn(
|
||||
f"Something went wrong with parsing presentation config `{filepath}`: {e}"
|
||||
)
|
||||
|
||||
logger.debug(f"Found {len(scenes)} valid scene configuration files in `{folder}`.")
|
||||
|
||||
return scenes
|
||||
|
||||
|
||||
def prompt_for_scenes(folder: Path) -> List[str]:
|
||||
"""Prompt the user to select scenes within a given folder."""
|
||||
scene_choices = dict(enumerate(_list_scenes(folder), start=1))
|
||||
|
||||
for i, scene in scene_choices.items():
|
||||
click.secho(f"{i}: {scene}", fg="green")
|
||||
|
||||
click.echo()
|
||||
|
||||
click.echo("Choose number corresponding to desired scene/arguments.")
|
||||
click.echo("(Use comma separated list for multiple entries)")
|
||||
|
||||
def value_proc(value: Optional[str]) -> List[str]:
|
||||
indices = list(map(int, (value or "").strip().replace(" ", "").split(",")))
|
||||
|
||||
if not all(0 < i <= len(scene_choices) for i in indices):
|
||||
raise click.UsageError("Please only enter numbers displayed on the screen.")
|
||||
|
||||
return [scene_choices[i] for i in indices]
|
||||
|
||||
if len(scene_choices) == 0:
|
||||
raise click.UsageError(
|
||||
"No scenes were found, are you in the correct directory?"
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
scenes = click.prompt("Choice(s)", value_proc=value_proc)
|
||||
return scenes # type: ignore
|
||||
except ValueError as e:
|
||||
raise click.UsageError(str(e)) from None
|
||||
|
||||
|
||||
def get_scenes_presentation_config(
|
||||
scenes: List[str], folder: Path
|
||||
) -> List[PresentationConfig]:
|
||||
"""Return a list of presentation configurations based on the user input."""
|
||||
if len(scenes) == 0:
|
||||
scenes = prompt_for_scenes(folder)
|
||||
|
||||
presentation_configs = []
|
||||
for scene in scenes:
|
||||
config_file = folder / f"{scene}.json"
|
||||
if not config_file.exists():
|
||||
raise click.UsageError(
|
||||
f"File {config_file} does not exist, check the scene name and make sure to use Slide as your scene base class"
|
||||
)
|
||||
try:
|
||||
presentation_configs.append(PresentationConfig.from_file(config_file))
|
||||
except ValidationError as e:
|
||||
raise click.UsageError(str(e)) from None
|
||||
|
||||
return presentation_configs
|
||||
|
||||
|
||||
def start_at_callback(
|
||||
ctx: Context, param: Parameter, values: str
|
||||
) -> Tuple[Optional[int], ...]:
|
||||
if values == "(None, None)":
|
||||
return (None, None)
|
||||
|
||||
def str_to_int_or_none(value: str) -> Optional[int]:
|
||||
if value.lower().strip() == "":
|
||||
return None
|
||||
else:
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
raise click.BadParameter(
|
||||
f"start index can only be an integer or an empty string, not `{value}`",
|
||||
ctx=ctx,
|
||||
param=param,
|
||||
) from None
|
||||
|
||||
values_tuple = values.split(",")
|
||||
n_values = len(values_tuple)
|
||||
if n_values == 2:
|
||||
return tuple(map(str_to_int_or_none, values_tuple))
|
||||
|
||||
raise click.BadParameter(
|
||||
f"exactly 2 arguments are expected but you gave {n_values}, please use commas to separate them",
|
||||
ctx=ctx,
|
||||
param=param,
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("scenes", nargs=-1)
|
||||
@config_path_option
|
||||
@folder_path_option
|
||||
@click.option("--start-paused", is_flag=True, help="Start paused.")
|
||||
@click.option(
|
||||
"-F",
|
||||
"--full-screen",
|
||||
"--fullscreen",
|
||||
"full_screen",
|
||||
is_flag=True,
|
||||
help="Toggle full screen mode.",
|
||||
)
|
||||
@click.option(
|
||||
"-s",
|
||||
"--skip-all",
|
||||
is_flag=True,
|
||||
help="Skip all slides, useful the test if slides are working. "
|
||||
"Automatically sets ``--exit-after-last-slide`` to True.",
|
||||
)
|
||||
@click.option(
|
||||
"--exit-after-last-slide",
|
||||
is_flag=True,
|
||||
help="At the end of last slide, the application will be exited.",
|
||||
)
|
||||
@click.option(
|
||||
"-H",
|
||||
"--hide-mouse",
|
||||
is_flag=True,
|
||||
help="Hide mouse cursor.",
|
||||
)
|
||||
@click.option(
|
||||
"--aspect-ratio",
|
||||
type=click.Choice(["keep", "ignore"], case_sensitive=False),
|
||||
default="keep",
|
||||
help="Set the aspect ratio mode to be used when rescaling the video.",
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
"--sa",
|
||||
"--start-at",
|
||||
"start_at",
|
||||
metavar="<SCENE,SLIDE>",
|
||||
type=str,
|
||||
callback=start_at_callback,
|
||||
default=(None, None),
|
||||
help="Start presenting at (x, y), equivalent to ``--sacn x --sasn y``, "
|
||||
"and overrides values if not None.",
|
||||
)
|
||||
@click.option(
|
||||
"--sacn",
|
||||
"--start-at-scene-number",
|
||||
"start_at_scene_number",
|
||||
metavar="INDEX",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Start presenting at a given scene number (0 is first, -1 is last).",
|
||||
)
|
||||
@click.option(
|
||||
"--sasn",
|
||||
"--start-at-slide-number",
|
||||
"start_at_slide_number",
|
||||
metavar="INDEX",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Start presenting at a given slide number (0 is first, -1 is last).",
|
||||
)
|
||||
@click.option(
|
||||
"-S",
|
||||
"--screen",
|
||||
"screen_number",
|
||||
metavar="NUMBER",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Present content on the given screen (a.k.a. display).",
|
||||
)
|
||||
@click.option(
|
||||
"--playback-rate",
|
||||
metavar="RATE",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="Playback rate of the video slides, see PySide6 docs for details. "
|
||||
" The playback rate of each slide is defined as the product of its default "
|
||||
" playback rate and the provided value.",
|
||||
)
|
||||
@click.option(
|
||||
"--next-terminates-loop",
|
||||
"next_terminates_loop",
|
||||
is_flag=True,
|
||||
help="If set, pressing next will turn any looping slide into a play slide.",
|
||||
)
|
||||
@click.option(
|
||||
"--hide-info-window",
|
||||
is_flag=True,
|
||||
help="Hide info window.",
|
||||
)
|
||||
@click.help_option("-h", "--help")
|
||||
@verbosity_option
|
||||
def present(
|
||||
scenes: List[str],
|
||||
config_path: Path,
|
||||
folder: Path,
|
||||
start_paused: bool,
|
||||
full_screen: bool,
|
||||
skip_all: bool,
|
||||
exit_after_last_slide: bool,
|
||||
hide_mouse: bool,
|
||||
aspect_ratio: str,
|
||||
start_at: Tuple[Optional[int], Optional[int], Optional[int]],
|
||||
start_at_scene_number: int,
|
||||
start_at_slide_number: int,
|
||||
screen_number: Optional[int],
|
||||
playback_rate: float,
|
||||
next_terminates_loop: bool,
|
||||
hide_info_window: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Present SCENE(s), one at a time, in order.
|
||||
|
||||
Each SCENE parameter must be the name of a Manim scene,
|
||||
with existing SCENE.json config file.
|
||||
|
||||
You can present the same SCENE multiple times by repeating the parameter.
|
||||
|
||||
Use ``manim-slide list-scenes`` to list all available
|
||||
scenes in a given folder.
|
||||
"""
|
||||
if skip_all:
|
||||
exit_after_last_slide = True
|
||||
|
||||
presentation_configs = get_scenes_presentation_config(scenes, folder)
|
||||
|
||||
if config_path.exists():
|
||||
try:
|
||||
config = Config.from_file(config_path)
|
||||
except ValidationError as e:
|
||||
raise click.UsageError(str(e)) from None
|
||||
else:
|
||||
logger.debug("No configuration file found, using default configuration.")
|
||||
config = Config()
|
||||
|
||||
if start_at[0]:
|
||||
start_at_scene_number = start_at[0]
|
||||
|
||||
if start_at[1]:
|
||||
start_at_slide_number = start_at[1]
|
||||
|
||||
app = qapp()
|
||||
app.setApplicationName("Manim Slides")
|
||||
|
||||
if screen_number is not None:
|
||||
try:
|
||||
screen = app.screens()[screen_number]
|
||||
except IndexError:
|
||||
logger.error(
|
||||
f"Invalid screen number {screen_number}, "
|
||||
f"allowed values are from 0 to {len(app.screens())-1} (incl.)"
|
||||
)
|
||||
screen = None
|
||||
else:
|
||||
screen = None
|
||||
|
||||
player = Player(
|
||||
config,
|
||||
presentation_configs,
|
||||
start_paused=start_paused,
|
||||
full_screen=full_screen,
|
||||
skip_all=skip_all,
|
||||
exit_after_last_slide=exit_after_last_slide,
|
||||
hide_mouse=hide_mouse,
|
||||
aspect_ratio_mode=ASPECT_RATIO_MODES[aspect_ratio],
|
||||
presentation_index=start_at_scene_number,
|
||||
slide_index=start_at_slide_number,
|
||||
screen=screen,
|
||||
playback_rate=playback_rate,
|
||||
next_terminates_loop=next_terminates_loop,
|
||||
hide_info_window=hide_info_window,
|
||||
)
|
||||
|
||||
player.show()
|
||||
|
||||
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
||||
sys.exit(app.exec())
|
551
manim_slides/present/player.py
Normal file
@ -0,0 +1,551 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer, QUrl, Signal, Slot
|
||||
from PySide6.QtGui import QCloseEvent, QIcon, QKeyEvent, QScreen
|
||||
from PySide6.QtMultimedia import QMediaPlayer
|
||||
from PySide6.QtMultimediaWidgets import QVideoWidget
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..config import Config, PresentationConfig, SlideConfig
|
||||
from ..logger import logger
|
||||
from ..resources import * # noqa: F403
|
||||
|
||||
WINDOW_NAME = "Manim Slides"
|
||||
|
||||
|
||||
class Info(QWidget): # type: ignore[misc]
|
||||
key_press_event: Signal = Signal(QKeyEvent)
|
||||
close_event: Signal = Signal(QCloseEvent)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
full_screen: bool,
|
||||
aspect_ratio_mode: Qt.AspectRatioMode,
|
||||
screen: Optional[QScreen],
|
||||
) -> None:
|
||||
super().__init__()
|
||||
|
||||
if screen:
|
||||
self.setScreen(screen)
|
||||
self.move(screen.geometry().topLeft())
|
||||
|
||||
if full_screen:
|
||||
self.setWindowState(Qt.WindowFullScreen)
|
||||
|
||||
layout = QHBoxLayout()
|
||||
|
||||
# Current slide view
|
||||
|
||||
left_layout = QVBoxLayout()
|
||||
left_layout.addWidget(
|
||||
QLabel("Current slide"),
|
||||
alignment=Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter,
|
||||
)
|
||||
main_video_widget = QVideoWidget()
|
||||
main_video_widget.setAspectRatioMode(aspect_ratio_mode)
|
||||
main_video_widget.setFixedSize(720, 480)
|
||||
self.video_sink = main_video_widget.videoSink()
|
||||
left_layout.addWidget(main_video_widget)
|
||||
|
||||
# Current slide informations
|
||||
|
||||
self.scene_label = QLabel()
|
||||
self.slide_label = QLabel()
|
||||
self.start_time = datetime.now()
|
||||
self.time_label = QLabel()
|
||||
self.elapsed_label = QLabel("00h00m00s")
|
||||
self.timer = QTimer()
|
||||
self.timer.start(1000) # every second
|
||||
self.timer.timeout.connect(self.update_time)
|
||||
|
||||
bottom_left_layout = QHBoxLayout()
|
||||
bottom_left_layout.addWidget(
|
||||
QLabel("Scene:"),
|
||||
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight,
|
||||
)
|
||||
bottom_left_layout.addWidget(
|
||||
self.scene_label,
|
||||
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
|
||||
)
|
||||
bottom_left_layout.addWidget(
|
||||
QLabel("Slide:"),
|
||||
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight,
|
||||
)
|
||||
bottom_left_layout.addWidget(
|
||||
self.slide_label,
|
||||
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
|
||||
)
|
||||
bottom_left_layout.addWidget(
|
||||
QLabel("Time:"),
|
||||
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight,
|
||||
)
|
||||
bottom_left_layout.addWidget(
|
||||
self.time_label,
|
||||
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
|
||||
)
|
||||
bottom_left_layout.addWidget(
|
||||
QLabel("Elapsed:"),
|
||||
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight,
|
||||
)
|
||||
bottom_left_layout.addWidget(
|
||||
self.elapsed_label,
|
||||
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
|
||||
)
|
||||
left_layout.addLayout(bottom_left_layout)
|
||||
layout.addLayout(left_layout)
|
||||
|
||||
layout.addSpacing(20)
|
||||
|
||||
# Next slide preview
|
||||
|
||||
right_layout = QVBoxLayout()
|
||||
right_layout.addWidget(
|
||||
QLabel("Next slide"),
|
||||
alignment=Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter,
|
||||
)
|
||||
next_video_widget = QVideoWidget()
|
||||
next_video_widget.setAspectRatioMode(aspect_ratio_mode)
|
||||
next_video_widget.setFixedSize(360, 240)
|
||||
self.next_media_player = QMediaPlayer()
|
||||
self.next_media_player.setVideoOutput(next_video_widget)
|
||||
self.next_media_player.setLoops(-1)
|
||||
|
||||
right_layout.addWidget(next_video_widget)
|
||||
|
||||
# Notes
|
||||
|
||||
self.slide_notes = QLabel()
|
||||
self.slide_notes.setWordWrap(True)
|
||||
self.slide_notes.setTextFormat(Qt.TextFormat.MarkdownText)
|
||||
self.slide_notes.setFixedWidth(360)
|
||||
right_layout.addWidget(
|
||||
self.slide_notes,
|
||||
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
|
||||
)
|
||||
layout.addLayout(right_layout)
|
||||
|
||||
widget = QWidget()
|
||||
|
||||
widget.setLayout(layout)
|
||||
|
||||
main_layout = QVBoxLayout()
|
||||
main_layout.addWidget(widget, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.setLayout(main_layout)
|
||||
|
||||
@Slot()
|
||||
def update_time(self) -> None:
|
||||
now = datetime.now()
|
||||
seconds = (now - self.start_time).total_seconds()
|
||||
hours, seconds = divmod(seconds, 3600)
|
||||
minutes, seconds = divmod(seconds, 60)
|
||||
self.time_label.setText(now.strftime("%Y/%m/%d %H:%M:%S"))
|
||||
self.elapsed_label.setText(
|
||||
f"{int(hours):02d}h{int(minutes):02d}m{int(seconds):02d}s"
|
||||
)
|
||||
|
||||
@Slot()
|
||||
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
|
||||
self.close_event.emit(event)
|
||||
|
||||
@Slot()
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
|
||||
self.key_press_event.emit(event)
|
||||
|
||||
|
||||
class Player(QMainWindow): # type: ignore[misc]
|
||||
presentation_changed: Signal = Signal()
|
||||
slide_changed: Signal = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Config,
|
||||
presentation_configs: List[PresentationConfig],
|
||||
*,
|
||||
start_paused: bool = False,
|
||||
full_screen: bool = False,
|
||||
skip_all: bool = False,
|
||||
exit_after_last_slide: bool = False,
|
||||
hide_mouse: bool = False,
|
||||
aspect_ratio_mode: Qt.AspectRatioMode = Qt.KeepAspectRatio,
|
||||
presentation_index: int = 0,
|
||||
slide_index: int = 0,
|
||||
screen: Optional[QScreen] = None,
|
||||
playback_rate: float = 1.0,
|
||||
next_terminates_loop: bool = False,
|
||||
hide_info_window: bool = False,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
# Wizard's config
|
||||
|
||||
self.config = config
|
||||
|
||||
# Presentation configs
|
||||
|
||||
self.presentation_configs = presentation_configs
|
||||
self.__current_presentation_index = 0
|
||||
self.__current_slide_index = 0
|
||||
|
||||
self.current_presentation_index = presentation_index
|
||||
self.current_slide_index = slide_index
|
||||
|
||||
self.__current_file: Path = self.current_slide_config.file
|
||||
|
||||
self.__playing_reversed_slide = False
|
||||
|
||||
# Widgets
|
||||
|
||||
if screen:
|
||||
self.setScreen(screen)
|
||||
self.move(screen.geometry().topLeft())
|
||||
|
||||
if full_screen:
|
||||
self.setWindowState(Qt.WindowFullScreen)
|
||||
else:
|
||||
w, h = self.current_presentation_config.resolution
|
||||
geometry = self.geometry()
|
||||
geometry.setWidth(w)
|
||||
geometry.setHeight(h)
|
||||
self.setGeometry(geometry)
|
||||
|
||||
if hide_mouse:
|
||||
self.setCursor(Qt.BlankCursor)
|
||||
|
||||
self.setWindowTitle(WINDOW_NAME)
|
||||
self.icon = QIcon(":/icon.png")
|
||||
self.setWindowIcon(self.icon)
|
||||
|
||||
self.video_widget = QVideoWidget()
|
||||
self.video_sink = self.video_widget.videoSink()
|
||||
self.video_widget.setAspectRatioMode(aspect_ratio_mode)
|
||||
self.setCentralWidget(self.video_widget)
|
||||
|
||||
self.media_player = QMediaPlayer(self)
|
||||
self.media_player.setVideoOutput(self.video_widget)
|
||||
self.playback_rate = playback_rate
|
||||
|
||||
self.presentation_changed.connect(self.presentation_changed_callback)
|
||||
self.slide_changed.connect(self.slide_changed_callback)
|
||||
|
||||
self.info = Info(
|
||||
full_screen=full_screen, aspect_ratio_mode=aspect_ratio_mode, screen=screen
|
||||
)
|
||||
self.info.close_event.connect(self.closeEvent)
|
||||
self.info.key_press_event.connect(self.keyPressEvent)
|
||||
self.video_sink.videoFrameChanged.connect(
|
||||
lambda frame: self.info.video_sink.setVideoFrame(frame)
|
||||
)
|
||||
self.hide_info_window = hide_info_window
|
||||
|
||||
# Connecting key callbacks
|
||||
|
||||
self.config.keys.QUIT.connect(self.close)
|
||||
self.config.keys.PLAY_PAUSE.connect(self.play_pause)
|
||||
self.config.keys.NEXT.connect(self.next)
|
||||
self.config.keys.PREVIOUS.connect(self.previous)
|
||||
self.config.keys.REVERSE.connect(self.reverse)
|
||||
self.config.keys.REPLAY.connect(self.replay)
|
||||
self.config.keys.FULL_SCREEN.connect(self.full_screen)
|
||||
self.config.keys.HIDE_MOUSE.connect(self.hide_mouse)
|
||||
|
||||
self.dispatch = self.config.keys.dispatch_key_function()
|
||||
|
||||
# Misc
|
||||
|
||||
self.exit_after_last_slide = exit_after_last_slide
|
||||
self.next_terminates_loop = next_terminates_loop
|
||||
|
||||
# Setting-up everything
|
||||
|
||||
if skip_all:
|
||||
|
||||
def media_status_changed(status: QMediaPlayer.MediaStatus) -> None:
|
||||
self.media_player.setLoops(1) # Otherwise looping slides never end
|
||||
if status == QMediaPlayer.EndOfMedia:
|
||||
self.load_next_slide()
|
||||
|
||||
self.media_player.mediaStatusChanged.connect(media_status_changed)
|
||||
|
||||
else:
|
||||
|
||||
def media_status_changed(status: QMediaPlayer.MediaStatus) -> None:
|
||||
if (
|
||||
status == QMediaPlayer.EndOfMedia
|
||||
and self.current_slide_config.auto_next
|
||||
):
|
||||
self.load_next_slide()
|
||||
|
||||
self.media_player.mediaStatusChanged.connect(media_status_changed)
|
||||
|
||||
if self.current_slide_config.loop:
|
||||
self.media_player.setLoops(-1)
|
||||
|
||||
self.load_current_media(start_paused=start_paused)
|
||||
|
||||
self.presentation_changed.emit()
|
||||
self.slide_changed.emit()
|
||||
|
||||
"""
|
||||
Properties
|
||||
"""
|
||||
|
||||
@property
|
||||
def presentations_count(self) -> int:
|
||||
return len(self.presentation_configs)
|
||||
|
||||
@property
|
||||
def current_presentation_index(self) -> int:
|
||||
return self.__current_presentation_index
|
||||
|
||||
@current_presentation_index.setter
|
||||
def current_presentation_index(self, index: int) -> None:
|
||||
if 0 <= index < self.presentations_count:
|
||||
self.__current_presentation_index = index
|
||||
elif -self.presentations_count <= index < 0:
|
||||
self.__current_presentation_index = index + self.presentations_count
|
||||
else:
|
||||
logger.warn(f"Could not set presentation index to {index}.")
|
||||
return
|
||||
|
||||
self.presentation_changed.emit()
|
||||
|
||||
@property
|
||||
def current_presentation_config(self) -> PresentationConfig:
|
||||
return self.presentation_configs[self.current_presentation_index]
|
||||
|
||||
@property
|
||||
def current_slides_count(self) -> int:
|
||||
return len(self.current_presentation_config.slides)
|
||||
|
||||
@property
|
||||
def current_slide_index(self) -> int:
|
||||
return self.__current_slide_index
|
||||
|
||||
@current_slide_index.setter
|
||||
def current_slide_index(self, index: int) -> None:
|
||||
if 0 <= index < self.current_slides_count:
|
||||
self.__current_slide_index = index
|
||||
elif -self.current_slides_count <= index < 0:
|
||||
self.__current_slide_index = index + self.current_slides_count
|
||||
else:
|
||||
logger.warn(f"Could not set slide index to {index}.")
|
||||
return
|
||||
|
||||
self.slide_changed.emit()
|
||||
|
||||
@property
|
||||
def current_slide_config(self) -> SlideConfig:
|
||||
return self.current_presentation_config.slides[self.current_slide_index]
|
||||
|
||||
@property
|
||||
def current_file(self) -> Path:
|
||||
return self.__current_file
|
||||
|
||||
@current_file.setter
|
||||
def current_file(self, file: Path) -> None:
|
||||
self.__current_file = file
|
||||
|
||||
@property
|
||||
def next_slide_config(self) -> Optional[SlideConfig]:
|
||||
if self.playing_reversed_slide:
|
||||
return self.current_slide_config
|
||||
elif self.current_slide_index < self.current_slides_count - 1:
|
||||
return self.presentation_configs[self.current_presentation_index].slides[
|
||||
self.current_slide_index + 1
|
||||
]
|
||||
elif self.current_presentation_index < self.presentations_count - 1:
|
||||
return self.presentation_configs[
|
||||
self.current_presentation_index + 1
|
||||
].slides[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def next_file(self) -> Optional[Path]:
|
||||
if slide_config := self.next_slide_config:
|
||||
return slide_config.file # type: ignore[no-any-return]
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def playing_reversed_slide(self) -> bool:
|
||||
return self.__playing_reversed_slide
|
||||
|
||||
@playing_reversed_slide.setter
|
||||
def playing_reversed_slide(self, playing_reversed_slide: bool) -> None:
|
||||
self.__playing_reversed_slide = playing_reversed_slide
|
||||
|
||||
"""
|
||||
Loading slides
|
||||
"""
|
||||
|
||||
def load_current_media(self, start_paused: bool = False) -> None:
|
||||
url = QUrl.fromLocalFile(self.current_file)
|
||||
self.media_player.setSource(url)
|
||||
|
||||
if self.playing_reversed_slide:
|
||||
self.media_player.setPlaybackRate(
|
||||
self.current_slide_config.reversed_playback_rate * self.playback_rate
|
||||
)
|
||||
else:
|
||||
self.media_player.setPlaybackRate(
|
||||
self.current_slide_config.playback_rate * self.playback_rate
|
||||
)
|
||||
|
||||
if start_paused:
|
||||
self.media_player.pause()
|
||||
else:
|
||||
self.media_player.play()
|
||||
|
||||
def load_current_slide(self) -> None:
|
||||
slide_config = self.current_slide_config
|
||||
self.current_file = slide_config.file
|
||||
|
||||
if slide_config.loop:
|
||||
self.media_player.setLoops(-1)
|
||||
else:
|
||||
self.media_player.setLoops(1)
|
||||
|
||||
self.load_current_media()
|
||||
|
||||
def load_previous_slide(self) -> None:
|
||||
self.playing_reversed_slide = False
|
||||
|
||||
if self.current_slide_index > 0:
|
||||
self.current_slide_index -= 1
|
||||
elif self.current_presentation_index > 0:
|
||||
self.current_presentation_index -= 1
|
||||
self.current_slide_index = self.current_slides_count - 1
|
||||
else:
|
||||
logger.info("No previous slide.")
|
||||
return
|
||||
|
||||
self.load_current_slide()
|
||||
|
||||
def load_next_slide(self) -> None:
|
||||
if self.playing_reversed_slide:
|
||||
self.playing_reversed_slide = False
|
||||
self.preview_next_slide() # Slide number did not change, but next did
|
||||
elif self.current_slide_index < self.current_slides_count - 1:
|
||||
self.current_slide_index += 1
|
||||
elif self.current_presentation_index < self.presentations_count - 1:
|
||||
self.current_presentation_index += 1
|
||||
self.current_slide_index = 0
|
||||
elif self.exit_after_last_slide:
|
||||
self.close()
|
||||
return
|
||||
else:
|
||||
logger.info("No more slide to play.")
|
||||
return
|
||||
|
||||
self.load_current_slide()
|
||||
|
||||
def load_reversed_slide(self) -> None:
|
||||
self.playing_reversed_slide = True
|
||||
self.current_file = self.current_slide_config.rev_file
|
||||
self.load_current_media()
|
||||
|
||||
"""
|
||||
Key callbacks and slots
|
||||
"""
|
||||
|
||||
@Slot()
|
||||
def presentation_changed_callback(self) -> None:
|
||||
index = self.current_presentation_index
|
||||
count = self.presentations_count
|
||||
self.info.scene_label.setText(f"{index+1:4d}/{count:4<d}")
|
||||
|
||||
@Slot()
|
||||
def slide_changed_callback(self) -> None:
|
||||
index = self.current_slide_index
|
||||
count = self.current_slides_count
|
||||
self.info.slide_label.setText(f"{index+1:4d}/{count:4<d}")
|
||||
self.info.slide_notes.setText(self.current_slide_config.notes)
|
||||
self.preview_next_slide()
|
||||
|
||||
def preview_next_slide(self) -> None:
|
||||
if slide_config := self.next_slide_config:
|
||||
url = QUrl.fromLocalFile(slide_config.file)
|
||||
self.info.next_media_player.setSource(url)
|
||||
self.info.next_media_player.play()
|
||||
|
||||
def show(self) -> None:
|
||||
super().show()
|
||||
|
||||
if not self.hide_info_window:
|
||||
self.info.show()
|
||||
|
||||
@Slot()
|
||||
def close(self) -> None:
|
||||
logger.info("Closing gracefully...")
|
||||
self.info.close()
|
||||
super().close()
|
||||
|
||||
@Slot()
|
||||
def next(self) -> None:
|
||||
if self.media_player.playbackState() == QMediaPlayer.PausedState:
|
||||
self.media_player.play()
|
||||
elif self.next_terminates_loop and self.media_player.loops() != 1:
|
||||
position = self.media_player.position()
|
||||
self.media_player.setLoops(1)
|
||||
self.media_player.stop()
|
||||
self.media_player.setPosition(position)
|
||||
self.media_player.play()
|
||||
else:
|
||||
self.load_next_slide()
|
||||
|
||||
@Slot()
|
||||
def previous(self) -> None:
|
||||
self.load_previous_slide()
|
||||
|
||||
@Slot()
|
||||
def reverse(self) -> None:
|
||||
self.load_reversed_slide()
|
||||
self.preview_next_slide()
|
||||
|
||||
@Slot()
|
||||
def replay(self) -> None:
|
||||
self.media_player.setPosition(0)
|
||||
self.media_player.play()
|
||||
|
||||
@Slot()
|
||||
def play_pause(self) -> None:
|
||||
state = self.media_player.playbackState()
|
||||
if state == QMediaPlayer.PausedState:
|
||||
self.media_player.play()
|
||||
elif state == QMediaPlayer.PlayingState:
|
||||
self.media_player.pause()
|
||||
|
||||
@Slot()
|
||||
def full_screen(self) -> None:
|
||||
if self.windowState() == Qt.WindowFullScreen:
|
||||
self.setWindowState(Qt.WindowNoState)
|
||||
else:
|
||||
self.setWindowState(Qt.WindowFullScreen)
|
||||
|
||||
@Slot()
|
||||
def hide_mouse(self) -> None:
|
||||
if self.cursor().shape() == Qt.BlankCursor:
|
||||
self.setCursor(Qt.ArrowCursor)
|
||||
else:
|
||||
self.setCursor(Qt.BlankCursor)
|
||||
|
||||
@Slot()
|
||||
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
|
||||
self.close()
|
||||
|
||||
@Slot()
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
|
||||
key = event.key()
|
||||
self.dispatch(key)
|
||||
event.accept()
|
14
manim_slides/qt_utils.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""Qt utils."""
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
|
||||
def qapp() -> QApplication:
|
||||
"""
|
||||
Return a QApplication instance, creating one
|
||||
if needed.
|
||||
"""
|
||||
if app := QApplication.instance():
|
||||
return app
|
||||
|
||||
return QApplication([])
|
54
manim_slides/render.py
Normal file
@ -0,0 +1,54 @@
|
||||
"""
|
||||
Alias command to either
|
||||
``manim render [OPTIONS] [ARGS]...`` or
|
||||
``manimgl [OPTIONS] [ARGS]...``.
|
||||
|
||||
This is especially useful for two reasons:
|
||||
|
||||
1. You can are sure to execute the rendering command with the same Python environment
|
||||
as for ``manim-slides``.
|
||||
2. You can pass options to the config.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Tuple
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command(
|
||||
context_settings=dict(
|
||||
ignore_unknown_options=True, allow_extra_args=True, help_option_names=("-h",)
|
||||
),
|
||||
options_metavar="[-h] [--CE|--GL]",
|
||||
)
|
||||
@click.option(
|
||||
"--CE",
|
||||
is_flag=True,
|
||||
envvar="MANIM_RENDERER",
|
||||
show_envvar=True,
|
||||
help="If set, use Manim Community Edition (CE) renderer. "
|
||||
"If this or ``--GL`` is not set, defaults to CE renderer.",
|
||||
)
|
||||
@click.option(
|
||||
"--GL",
|
||||
is_flag=True,
|
||||
envvar="MANIMGL_RENDERER",
|
||||
show_envvar=True,
|
||||
help="If set, use ManimGL renderer.",
|
||||
)
|
||||
@click.argument("args", metavar="[RENDERER_ARGS]...", nargs=-1, type=click.UNPROCESSED)
|
||||
def render(ce: bool, gl: bool, args: Tuple[str, ...]) -> None:
|
||||
"""
|
||||
Render SCENE(s) from the input FILE, using the specified renderer.
|
||||
|
||||
Use ``manim-slides render --help`` to see help information for
|
||||
a specific renderer.
|
||||
"""
|
||||
if ce and gl:
|
||||
raise click.UsageError("You cannot specify both --CE and --GL renderers.")
|
||||
if gl:
|
||||
subprocess.run([sys.executable, "-m", "manimlib", *args])
|
||||
else:
|
||||
subprocess.run([sys.executable, "-m", "manim", "render", *args])
|
@ -1,738 +0,0 @@
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
Any,
|
||||
List,
|
||||
Mapping,
|
||||
MutableMapping,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
ValuesView,
|
||||
)
|
||||
from warnings import warn
|
||||
|
||||
import numpy as np
|
||||
from tqdm import tqdm
|
||||
|
||||
from .config import PresentationConfig, SlideConfig, SlideType
|
||||
from .defaults import FOLDER_PATH
|
||||
from .manim import (
|
||||
FFMPEG_BIN,
|
||||
LEFT,
|
||||
MANIMGL,
|
||||
AnimationGroup,
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
Mobject,
|
||||
Scene,
|
||||
ThreeDScene,
|
||||
config,
|
||||
logger,
|
||||
)
|
||||
|
||||
|
||||
def reverse_video_file(src: Path, dst: Path) -> None:
|
||||
"""Reverses a video file, writting the result to `dst`."""
|
||||
command = [str(FFMPEG_BIN), "-y", "-i", str(src), "-vf", "reverse", str(dst)]
|
||||
logger.debug(" ".join(command))
|
||||
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
output, error = process.communicate()
|
||||
|
||||
if output:
|
||||
logger.debug(output.decode())
|
||||
|
||||
if error:
|
||||
logger.debug(error.decode())
|
||||
|
||||
|
||||
class Slide(Scene): # type:ignore
|
||||
"""
|
||||
Inherits from :class:`Scene<manim.scene.scene.Scene>` and provide necessary tools for slides rendering.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, *args: Any, output_folder: Path = FOLDER_PATH, **kwargs: Any
|
||||
) -> None:
|
||||
if MANIMGL:
|
||||
Path("videos").mkdir(exist_ok=True)
|
||||
kwargs["file_writer_config"] = {
|
||||
"break_into_partial_movies": True,
|
||||
"output_directory": "",
|
||||
"write_to_movie": True,
|
||||
}
|
||||
|
||||
kwargs["preview"] = False
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.__output_folder: Path = output_folder
|
||||
self.__slides: List[SlideConfig] = []
|
||||
self.__current_slide = 1
|
||||
self.__current_animation = 0
|
||||
self.__loop_start_animation: Optional[int] = None
|
||||
self.__pause_start_animation = 0
|
||||
self.__canvas: MutableMapping[str, Mobject] = {}
|
||||
self.__wait_time_between_slides = 0.0
|
||||
|
||||
@property
|
||||
def __frame_height(self) -> float:
|
||||
"""Returns the scene's frame height."""
|
||||
if MANIMGL:
|
||||
return self.frame_height # type: ignore
|
||||
else:
|
||||
return config["frame_height"] # type: ignore
|
||||
|
||||
@property
|
||||
def __frame_width(self) -> float:
|
||||
"""Returns the scene's frame width."""
|
||||
if MANIMGL:
|
||||
return self.frame_width # type: ignore
|
||||
else:
|
||||
return config["frame_width"] # type: ignore
|
||||
|
||||
@property
|
||||
def __background_color(self) -> str:
|
||||
"""Returns the scene's background color."""
|
||||
if MANIMGL:
|
||||
return self.camera_config["background_color"].hex # type: ignore
|
||||
else:
|
||||
return config["background_color"].hex # type: ignore
|
||||
|
||||
@property
|
||||
def __resolution(self) -> Tuple[int, int]:
|
||||
"""Returns the scene's resolution used during rendering."""
|
||||
if MANIMGL:
|
||||
return self.camera_config["pixel_width"], self.camera_config["pixel_height"]
|
||||
else:
|
||||
return config["pixel_width"], config["pixel_height"]
|
||||
|
||||
@property
|
||||
def __partial_movie_files(self) -> List[Path]:
|
||||
"""Returns a list of partial movie files, a.k.a animations."""
|
||||
if MANIMGL:
|
||||
from manimlib.utils.file_ops import get_sorted_integer_files
|
||||
|
||||
kwargs = {
|
||||
"remove_non_integer_files": True,
|
||||
"extension": self.file_writer.movie_file_extension,
|
||||
}
|
||||
files = get_sorted_integer_files(
|
||||
self.file_writer.partial_movie_directory, **kwargs
|
||||
)
|
||||
else:
|
||||
files = self.renderer.file_writer.partial_movie_files
|
||||
|
||||
return [Path(file) for file in files]
|
||||
|
||||
@property
|
||||
def __show_progress_bar(self) -> bool:
|
||||
"""Returns True if progress bar should be displayed."""
|
||||
if MANIMGL:
|
||||
return getattr(self, "show_progress_bar", True)
|
||||
else:
|
||||
return config["progress_bar"] != "none" # type: ignore
|
||||
|
||||
@property
|
||||
def __leave_progress_bar(self) -> bool:
|
||||
"""Returns True if progress bar should be left after completed."""
|
||||
if MANIMGL:
|
||||
return getattr(self, "leave_progress_bars", False)
|
||||
else:
|
||||
return config["progress_bar"] == "leave" # type: ignore
|
||||
|
||||
@property
|
||||
def __start_at_animation_number(self) -> Optional[int]:
|
||||
if MANIMGL:
|
||||
return getattr(self, "start_at_animation_number", None)
|
||||
else:
|
||||
return config["from_animation_number"] # type: ignore
|
||||
|
||||
@property
|
||||
def canvas(self) -> MutableMapping[str, Mobject]:
|
||||
"""
|
||||
Returns the canvas associated to the current slide.
|
||||
|
||||
The canvas is a mapping between names and Mobjects,
|
||||
for objects that are assumed to stay in multiple slides.
|
||||
|
||||
For example, a section title or a slide number.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
.. manim-slides:: CanvasExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class CanvasExample(Slide):
|
||||
def update_canvas(self):
|
||||
self.counter += 1
|
||||
old_slide_number = self.canvas["slide_number"]
|
||||
new_slide_number = Text(f"{self.counter}").move_to(old_slide_number)
|
||||
self.play(Transform(old_slide_number, new_slide_number))
|
||||
|
||||
def construct(self):
|
||||
title = Text("My Title").to_corner(UL)
|
||||
|
||||
self.counter = 1
|
||||
slide_number = Text("1").to_corner(DL)
|
||||
|
||||
self.add_to_canvas(title=title, slide_number=slide_number)
|
||||
|
||||
self.play(FadeIn(title), FadeIn(slide_number))
|
||||
self.next_slide()
|
||||
|
||||
circle = Circle(radius=2)
|
||||
dot = Dot()
|
||||
|
||||
self.update_canvas()
|
||||
self.play(Create(circle))
|
||||
self.play(MoveAlongPath(dot, circle))
|
||||
|
||||
self.next_slide()
|
||||
self.update_canvas()
|
||||
|
||||
square = Square()
|
||||
|
||||
self.play(self.wipe(self.mobjects_without_canvas, square))
|
||||
self.next_slide()
|
||||
|
||||
self.update_canvas()
|
||||
self.play(
|
||||
Transform(
|
||||
self.canvas["title"],
|
||||
Text("New Title").to_corner(UL)
|
||||
)
|
||||
)
|
||||
self.next_slide()
|
||||
|
||||
self.remove_from_canvas("title", "slide_number")
|
||||
self.play(self.wipe(self.mobjects_without_canvas, []))
|
||||
|
||||
"""
|
||||
return self.__canvas
|
||||
|
||||
def add_to_canvas(self, **objects: Mobject) -> Mobject:
|
||||
"""
|
||||
Adds objects to the canvas, using key values as names.
|
||||
|
||||
:param objects: A mapping between names and Mobjects.
|
||||
|
||||
.. note::
|
||||
|
||||
This method does not actually do anything in terms of
|
||||
animations. You must still call :code:`self.add` or
|
||||
play some animation that introduces each Mobject for
|
||||
it to appear. The same applies when removing objects.
|
||||
"""
|
||||
self.__canvas.update(objects)
|
||||
|
||||
def remove_from_canvas(self, *names: str) -> None:
|
||||
"""
|
||||
Removes objects from the canvas.
|
||||
"""
|
||||
for name in names:
|
||||
self.__canvas.pop(name)
|
||||
|
||||
@property
|
||||
def canvas_mobjects(self) -> ValuesView[Mobject]:
|
||||
"""
|
||||
Returns Mobjects contained in the canvas.
|
||||
"""
|
||||
return self.canvas.values()
|
||||
|
||||
@property
|
||||
def mobjects_without_canvas(self) -> Sequence[Mobject]:
|
||||
"""
|
||||
Returns the list of objects contained in the scene,
|
||||
minus those present in the canvas.
|
||||
"""
|
||||
return [
|
||||
mobject for mobject in self.mobjects if mobject not in self.canvas_mobjects
|
||||
]
|
||||
|
||||
@property
|
||||
def wait_time_between_slides(self) -> float:
|
||||
r"""
|
||||
Returns the wait duration (in seconds) added between two slides.
|
||||
|
||||
By default, this value is set to 0.
|
||||
|
||||
Setting this value to something bigger than 0 will result in a
|
||||
:code:`self.wait` animation called at the end of every slide.
|
||||
|
||||
.. note::
|
||||
This is useful because animations are usually only terminated
|
||||
when a new animation is played. You can observe the small difference
|
||||
in the examples below: the circle is not fully complete in the first
|
||||
slide of the first example, but well in the second example.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
.. manim-slides:: WithoutWaitExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class WithoutWaitExample(Slide):
|
||||
def construct(self):
|
||||
circle = Circle(radius=2)
|
||||
arrow = Arrow().next_to(circle, RIGHT).scale(-1)
|
||||
text = Text("Small\ngap").next_to(arrow, RIGHT)
|
||||
|
||||
self.play(Create(arrow), FadeIn(text))
|
||||
self.play(Create(circle))
|
||||
self.next_slide()
|
||||
|
||||
self.play(FadeOut(circle))
|
||||
|
||||
.. manim-slides:: WithWaitExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class WithWaitExample(Slide):
|
||||
def construct(self):
|
||||
self.wait_time_between_slides = 0.1 # A small value > 1 / FPS
|
||||
circle = Circle(radius=2)
|
||||
arrow = Arrow().next_to(circle, RIGHT).scale(-1)
|
||||
text = Text("No more\ngap").next_to(arrow, RIGHT)
|
||||
|
||||
self.play(Create(arrow), FadeIn(text))
|
||||
self.play(Create(circle))
|
||||
self.next_slide()
|
||||
|
||||
self.play(FadeOut(circle))
|
||||
|
||||
"""
|
||||
return self.__wait_time_between_slides
|
||||
|
||||
@wait_time_between_slides.setter
|
||||
def wait_time_between_slides(self, wait_time: float) -> None:
|
||||
self.__wait_time_between_slides = max(wait_time, 0.0)
|
||||
|
||||
def play(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Overloads `self.play` and increment animation count."""
|
||||
super().play(*args, **kwargs)
|
||||
self.__current_animation += 1
|
||||
|
||||
def next_slide(self) -> None:
|
||||
"""
|
||||
Creates a new slide with previous animations.
|
||||
|
||||
This usually means that the user will need to press some key before the
|
||||
next slide is played. By default, this is the right arrow key.
|
||||
|
||||
|
||||
.. note::
|
||||
|
||||
Calls to :func:`next_slide` at the very beginning or at the end are
|
||||
not needed, since they are automatically added.
|
||||
|
||||
.. warning::
|
||||
|
||||
This is not allowed to call :func:`next_slide` inside a loop.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
The following contains 3 slides:
|
||||
|
||||
#. the first with nothing on it;
|
||||
#. the second with "Hello World!" fading in;
|
||||
#. and the last with the text fading out;
|
||||
|
||||
.. manim-slides:: NextSlideExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class NextSlideExample(Slide):
|
||||
def construct(self):
|
||||
text = Text("Hello World!")
|
||||
|
||||
self.play(FadeIn(text))
|
||||
|
||||
self.next_slide()
|
||||
self.play(FadeOut(text))
|
||||
"""
|
||||
assert (
|
||||
self.__loop_start_animation is None
|
||||
), "You cannot call `self.next_slide()` inside a loop"
|
||||
|
||||
if self.wait_time_between_slides > 0.0:
|
||||
self.wait(self.wait_time_between_slides)
|
||||
|
||||
self.__slides.append(
|
||||
SlideConfig(
|
||||
type=SlideType.slide,
|
||||
start_animation=self.__pause_start_animation,
|
||||
end_animation=self.__current_animation,
|
||||
number=self.__current_slide,
|
||||
)
|
||||
)
|
||||
self.__current_slide += 1
|
||||
self.__pause_start_animation = self.__current_animation
|
||||
|
||||
def pause(self) -> None:
|
||||
"""
|
||||
Creates a new slide with previous animations.
|
||||
|
||||
.. deprecated:: 4.10.0
|
||||
Use :func:`next_slide` instead.
|
||||
"""
|
||||
warn(
|
||||
"`self.pause()` is deprecated. Use `self.next_slide()` instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
Slide.next_slide(self)
|
||||
|
||||
def __add_last_slide(self) -> None:
|
||||
"""Adds a 'last' slide to the end of slides."""
|
||||
|
||||
if (
|
||||
len(self.__slides) > 0
|
||||
and self.__current_animation == self.__slides[-1].end_animation
|
||||
):
|
||||
self.__slides[-1].type = SlideType.last
|
||||
return
|
||||
|
||||
self.__slides.append(
|
||||
SlideConfig(
|
||||
type=SlideType.last,
|
||||
start_animation=self.__pause_start_animation,
|
||||
end_animation=self.__current_animation,
|
||||
number=self.__current_slide,
|
||||
)
|
||||
)
|
||||
|
||||
def start_loop(self) -> None:
|
||||
"""
|
||||
Starts a loop. End it with :func:`end_loop`.
|
||||
|
||||
A loop will automatically replay the slide, i.e., everything between
|
||||
:func:`start_loop` and :func:`end_loop`, upon reaching end.
|
||||
|
||||
.. warning::
|
||||
|
||||
When rendered with RevealJS, loops cannot be in the first nor
|
||||
the last slide.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
The following contains one slide that will loop endlessly.
|
||||
|
||||
.. manim-slides:: LoopExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class LoopExample(Slide):
|
||||
def construct(self):
|
||||
dot = Dot(color=BLUE, radius=1)
|
||||
|
||||
self.play(FadeIn(dot))
|
||||
self.next_slide()
|
||||
|
||||
self.start_loop()
|
||||
|
||||
self.play(Indicate(dot, scale_factor=2))
|
||||
|
||||
self.end_loop()
|
||||
|
||||
self.play(FadeOut(dot))
|
||||
"""
|
||||
assert self.__loop_start_animation is None, "You cannot nest loops"
|
||||
self.__loop_start_animation = self.__current_animation
|
||||
|
||||
def end_loop(self) -> None:
|
||||
"""Ends an existing loop. See :func:`start_loop` for more details."""
|
||||
assert (
|
||||
self.__loop_start_animation is not None
|
||||
), "You have to start a loop before ending it"
|
||||
self.__slides.append(
|
||||
SlideConfig(
|
||||
type=SlideType.loop,
|
||||
start_animation=self.__loop_start_animation,
|
||||
end_animation=self.__current_animation,
|
||||
number=self.__current_slide,
|
||||
)
|
||||
)
|
||||
self.__current_slide += 1
|
||||
self.__loop_start_animation = None
|
||||
self.__pause_start_animation = self.__current_animation
|
||||
|
||||
def __save_slides(self, use_cache: bool = True) -> None:
|
||||
"""
|
||||
Saves slides, optionally using cached files.
|
||||
|
||||
Note that cached files only work with Manim.
|
||||
"""
|
||||
self.__add_last_slide()
|
||||
|
||||
files_folder = self.__output_folder / "files"
|
||||
|
||||
scene_name = str(self)
|
||||
scene_files_folder = files_folder / scene_name
|
||||
|
||||
scene_files_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
files = []
|
||||
for src_file in tqdm(
|
||||
self.__partial_movie_files,
|
||||
desc=f"Copying animation files to '{scene_files_folder}' and generating reversed animations",
|
||||
leave=self.__leave_progress_bar,
|
||||
ascii=True if platform.system() == "Windows" else None,
|
||||
disable=not self.__show_progress_bar,
|
||||
):
|
||||
if src_file is None and not MANIMGL:
|
||||
# This happens if rendering with -na,b (manim only)
|
||||
# where animations not in [a,b] will be skipped
|
||||
# but animations before a will have a None src_file
|
||||
continue
|
||||
|
||||
dst_file = scene_files_folder / src_file.name
|
||||
rev_file = scene_files_folder / f"{src_file.stem}_reversed{src_file.suffix}"
|
||||
|
||||
# We only copy animation if it was not present
|
||||
if not use_cache or not dst_file.exists():
|
||||
shutil.copyfile(src_file, dst_file)
|
||||
|
||||
# We only reverse video if it was not present
|
||||
if not use_cache or not rev_file.exists():
|
||||
reverse_video_file(src_file, rev_file)
|
||||
|
||||
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 '{scene_files_folder.absolute()}' and generated reversed animations"
|
||||
)
|
||||
|
||||
slide_path = self.__output_folder / f"{scene_name}.json"
|
||||
|
||||
PresentationConfig(
|
||||
slides=self.__slides,
|
||||
files=files,
|
||||
resolution=self.__resolution,
|
||||
background_color=self.__background_color,
|
||||
).to_file(slide_path)
|
||||
|
||||
logger.info(
|
||||
f"Slide '{scene_name}' configuration written in '{slide_path.absolute()}'"
|
||||
)
|
||||
|
||||
def run(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""MANIMGL renderer"""
|
||||
super().run(*args, **kwargs)
|
||||
self.__save_slides(use_cache=False)
|
||||
|
||||
def render(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""MANIM render"""
|
||||
# We need to disable the caching limit since we rely on intermediate files
|
||||
max_files_cached = config["max_files_cached"]
|
||||
config["max_files_cached"] = float("inf")
|
||||
|
||||
super().render(*args, **kwargs)
|
||||
|
||||
config["max_files_cached"] = max_files_cached
|
||||
|
||||
self.__save_slides()
|
||||
|
||||
def wipe(
|
||||
self,
|
||||
current: Sequence[Mobject] = [],
|
||||
future: Sequence[Mobject] = [],
|
||||
direction: np.ndarray = LEFT,
|
||||
fade_in_kwargs: Mapping[str, Any] = {},
|
||||
fade_out_kwargs: Mapping[str, Any] = {},
|
||||
**kwargs: Any,
|
||||
) -> AnimationGroup:
|
||||
"""
|
||||
Returns a wipe animation that will shift all the current objects outside
|
||||
of the current scene's scope, and all the future objects inside.
|
||||
|
||||
:param current: A sequence of mobjects to remove from the scene.
|
||||
:param future: A sequence of mobjects to add to the scene.
|
||||
:param direction: The wipe direction.
|
||||
:param fade_in_kwargs: Keyword arguments passed to
|
||||
:class:`FadeIn<manim.animation.fading.FadeIn>`.
|
||||
:param fade_out_kwargs: Keyword arguments passed to
|
||||
:class:`FadeOut<manim.animation.fading.FadeOut>`.
|
||||
:param kwargs: Keyword arguments passed to
|
||||
:class:`AnimationGroup<manim.animation.composition.AnimationGroup>`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
.. manim-slides:: WipeExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class WipeExample(Slide):
|
||||
def construct(self):
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
square = Square()
|
||||
text = Text("This is a wipe example").next_to(square, DOWN)
|
||||
beautiful = Text("Beautiful, no?")
|
||||
|
||||
self.play(FadeIn(circle))
|
||||
self.next_slide()
|
||||
|
||||
self.play(self.wipe(circle, Group(square, text)))
|
||||
self.next_slide()
|
||||
|
||||
self.play(self.wipe(Group(square, text), beautiful, direction=UP))
|
||||
self.next_slide()
|
||||
|
||||
self.play(self.wipe(beautiful, circle, direction=DOWN + RIGHT))
|
||||
"""
|
||||
shift_amount = np.asarray(direction) * np.array(
|
||||
[self.__frame_width, self.__frame_height, 0.0]
|
||||
)
|
||||
|
||||
animations = []
|
||||
|
||||
for mobject in future:
|
||||
animations.append(FadeIn(mobject, shift=shift_amount, **fade_in_kwargs))
|
||||
|
||||
for mobject in current:
|
||||
animations.append(FadeOut(mobject, shift=shift_amount, **fade_out_kwargs))
|
||||
|
||||
return AnimationGroup(*animations, **kwargs)
|
||||
|
||||
def zoom(
|
||||
self,
|
||||
current: Sequence[Mobject] = [],
|
||||
future: Sequence[Mobject] = [],
|
||||
scale: float = 4.0,
|
||||
out: bool = False,
|
||||
fade_in_kwargs: Mapping[str, Any] = {},
|
||||
fade_out_kwargs: Mapping[str, Any] = {},
|
||||
**kwargs: Any,
|
||||
) -> AnimationGroup:
|
||||
"""
|
||||
Returns a zoom animation that will fade out all the current objects,
|
||||
and fade in all the future objects. Objects are faded in a direction
|
||||
that goes towards the camera.
|
||||
|
||||
:param current: A sequence of mobjects to remove from the scene.
|
||||
:param future: A sequence of mobjects to add to the scene.
|
||||
:param scale: How much the objects are scaled (up or down).
|
||||
:param out: If set, the objects fade in the opposite direction.
|
||||
:param fade_in_kwargs: Keyword arguments passed to
|
||||
:class:`FadeIn<manim.animation.fading.FadeIn>`.
|
||||
:param fade_out_kwargs: Keyword arguments passed to
|
||||
:class:`FadeOut<manim.animation.fading.FadeOut>`.
|
||||
:param kwargs: Keyword arguments passed to
|
||||
:class:`AnimationGroup<manim.animation.composition.AnimationGroup>`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
.. manim-slides:: ZoomExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class ZoomExample(Slide):
|
||||
def construct(self):
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
square = Square()
|
||||
|
||||
self.play(FadeIn(circle))
|
||||
self.next_slide()
|
||||
|
||||
self.play(self.zoom(circle, square))
|
||||
self.next_slide()
|
||||
|
||||
self.play(self.zoom(square, circle, out=True, scale=10.))
|
||||
"""
|
||||
scale_in = 1.0 / scale
|
||||
scale_out = scale
|
||||
|
||||
if out:
|
||||
scale_in, scale_out = scale_out, scale_in
|
||||
|
||||
animations = []
|
||||
|
||||
for mobject in future:
|
||||
animations.append(FadeIn(mobject, scale=scale_in, **fade_in_kwargs))
|
||||
|
||||
for mobject in current:
|
||||
animations.append(FadeOut(mobject, scale=scale_out, **fade_out_kwargs))
|
||||
|
||||
return AnimationGroup(*animations, **kwargs)
|
||||
|
||||
|
||||
class ThreeDSlide(Slide, ThreeDScene): # type: ignore
|
||||
"""
|
||||
Inherits from :class:`Slide` and :class:`ThreeDScene<manim.scene.three_d_scene.ThreeDScene>` and provide necessary tools for slides rendering.
|
||||
|
||||
.. note:: ManimGL does not need ThreeDScene for 3D rendering in recent versions, see `example.py`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
.. manim-slides:: ThreeDExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import ThreeDSlide
|
||||
|
||||
class ThreeDExample(ThreeDSlide):
|
||||
def construct(self):
|
||||
title = Text("A 2D Text")
|
||||
|
||||
self.play(FadeIn(title))
|
||||
self.next_slide()
|
||||
|
||||
sphere = Sphere([0, 0, -3])
|
||||
|
||||
self.move_camera(phi=PI/3, theta=-PI/4, distance=7)
|
||||
self.play(
|
||||
GrowFromCenter(sphere),
|
||||
Transform(title, Text("A 3D Text"))
|
||||
)
|
||||
self.next_slide()
|
||||
|
||||
bye = Text("Bye!")
|
||||
|
||||
self.start_loop()
|
||||
self.play(
|
||||
self.wipe(
|
||||
self.mobjects_without_canvas,
|
||||
[bye],
|
||||
direction=UP
|
||||
)
|
||||
)
|
||||
self.wait(.5)
|
||||
self.play(
|
||||
self.wipe(
|
||||
self.mobjects_without_canvas,
|
||||
[title, sphere],
|
||||
direction=DOWN
|
||||
)
|
||||
)
|
||||
self.wait(.5)
|
||||
self.end_loop()
|
||||
|
||||
self.play(*[FadeOut(mobject) for mobject in self.mobjects])
|
||||
"""
|
||||
|
||||
pass
|
63
manim_slides/slide/__init__.py
Normal file
@ -0,0 +1,63 @@
|
||||
__all__ = [
|
||||
"MANIM",
|
||||
"MANIMGL",
|
||||
"API_NAME",
|
||||
"Slide",
|
||||
"ThreeDSlide",
|
||||
]
|
||||
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
class ManimApiNotFoundError(ImportError):
|
||||
"""Error raised if specified manim API could be imported."""
|
||||
|
||||
_msg = "Could not import the specified manim API"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(self._msg)
|
||||
|
||||
|
||||
API_NAMES = {
|
||||
"manim": "manim",
|
||||
"manimce": "manim",
|
||||
"manimlib": "manimlib",
|
||||
"manimgl": "manimlib",
|
||||
}
|
||||
|
||||
MANIM_API: str = "MANIM_API"
|
||||
FORCE_MANIM_API: str = "FORCE_" + MANIM_API
|
||||
|
||||
API: str = os.environ.get(MANIM_API, "manim").lower()
|
||||
|
||||
|
||||
if API not in API_NAMES:
|
||||
raise ImportError(
|
||||
f"Specified MANIM_API={API!r} is not in valid options: " f"{API_NAMES}",
|
||||
)
|
||||
|
||||
API_NAME = API_NAMES[API]
|
||||
|
||||
if not os.environ.get(FORCE_MANIM_API):
|
||||
if "manim" in sys.modules:
|
||||
API_NAME = "manim"
|
||||
elif "manimlib" in sys.modules:
|
||||
API_NAME = "manimlib"
|
||||
|
||||
MANIM: bool = API_NAME == "manim"
|
||||
MANIMGL: bool = API_NAME == "manimlib"
|
||||
|
||||
if MANIM:
|
||||
try:
|
||||
from .manim import Slide, ThreeDSlide
|
||||
except ImportError as e:
|
||||
raise ManimApiNotFoundError from e
|
||||
elif MANIMGL:
|
||||
try:
|
||||
from .manimlib import Slide, ThreeDSlide
|
||||
except ImportError as e:
|
||||
raise ManimApiNotFoundError from e
|
||||
else:
|
||||
raise ManimApiNotFoundError
|
159
manim_slides/slide/animation.py
Normal file
@ -0,0 +1,159 @@
|
||||
"""
|
||||
Additional animations for Manim objects.
|
||||
|
||||
Like with Manim, animations are classes that must be put inside a
|
||||
:meth:`Scene.play<manim.scene.scene.Scene.play>` call.
|
||||
|
||||
For each of the provided classes, there exists a method variant
|
||||
that directly calls ``self.play(Animation(...))``, see
|
||||
:class:`Slide<manim_slides.slide.Slide>`.
|
||||
"""
|
||||
|
||||
__all__ = ["Wipe", "Zoom"]
|
||||
|
||||
from typing import Any, Mapping, Optional, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from . import MANIM
|
||||
|
||||
if MANIM:
|
||||
from manim import LEFT, AnimationGroup, FadeIn, FadeOut
|
||||
from manim.mobject.mobject import Mobject
|
||||
else:
|
||||
from manimlib import LEFT, AnimationGroup, FadeIn, FadeOut
|
||||
|
||||
Mobject = Any
|
||||
|
||||
|
||||
class Wipe(AnimationGroup): # type: ignore[misc]
|
||||
"""
|
||||
Creates a wipe animation that will shift all the current objects and future objects
|
||||
by a given value.
|
||||
|
||||
:param current: A sequence of mobjects to remove from the scene.
|
||||
:param future: A sequence of mobjects to add to the scene.
|
||||
:param shift: The shift vector, used for both fading in and out.
|
||||
:param fade_in_kwargs: Keyword arguments passed to
|
||||
:class:`FadeIn<manim.animation.fading.FadeIn>`.
|
||||
:param fade_out_kwargs: Keyword arguments passed to
|
||||
:class:`FadeOut<manim.animation.fading.FadeOut>`.
|
||||
:param kwargs: Keyword arguments passed to
|
||||
:class:`AnimationGroup<manim.animation.composition.AnimationGroup>`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
.. manim-slides:: WipeClassExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
from manim_slides.slide.animation import Wipe
|
||||
|
||||
class WipeClassExample(Slide):
|
||||
def construct(self):
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
square = Square()
|
||||
|
||||
self.play(FadeIn(circle))
|
||||
self.next_slide()
|
||||
|
||||
self.play(Wipe(circle, square, shift=3 * LEFT))
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
current: Optional[Sequence[Mobject]] = None,
|
||||
future: Optional[Sequence[Mobject]] = None,
|
||||
shift: np.ndarray = LEFT,
|
||||
fade_in_kwargs: Optional[Mapping[str, Any]] = None,
|
||||
fade_out_kwargs: Optional[Mapping[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
animations = []
|
||||
|
||||
if future:
|
||||
if fade_in_kwargs is None:
|
||||
fade_in_kwargs = {}
|
||||
|
||||
for mobject in future:
|
||||
animations.append(FadeIn(mobject, shift=shift, **fade_in_kwargs))
|
||||
|
||||
if current:
|
||||
if fade_out_kwargs is None:
|
||||
fade_out_kwargs = {}
|
||||
|
||||
for mobject in current:
|
||||
animations.append(FadeOut(mobject, shift=shift, **fade_out_kwargs))
|
||||
|
||||
super().__init__(*animations, **kwargs)
|
||||
|
||||
|
||||
class Zoom(AnimationGroup): # type: ignore[misc]
|
||||
"""
|
||||
Creates a zoom animation that will fade out all the current objects, and fade in all
|
||||
the future objects. Objects are faded in a direction that goes towards the camera.
|
||||
|
||||
:param current: A sequence of mobjects to remove from the scene.
|
||||
:param future: A sequence of mobjects to add to the scene.
|
||||
:param scale: How much the objects are scaled (up or down).
|
||||
:param out: If set, the objects fade in the opposite direction.
|
||||
:param fade_in_kwargs: Keyword arguments passed to
|
||||
:class:`FadeIn<manim.animation.fading.FadeIn>`.
|
||||
:param fade_out_kwargs: Keyword arguments passed to
|
||||
:class:`FadeOut<manim.animation.fading.FadeOut>`.
|
||||
:param kwargs: Keyword arguments passed to
|
||||
:class:`AnimationGroup<manim.animation.composition.AnimationGroup>`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
.. manim-slides:: ZoomClassExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
from manim_slides.slide.animation import Zoom
|
||||
|
||||
class ZoomClassExample(Slide):
|
||||
def construct(self):
|
||||
circles = [Circle(radius=i) for i in range(1, 4)]
|
||||
|
||||
self.play(FadeIn(circles[0]))
|
||||
self.next_slide()
|
||||
|
||||
for i in range(2):
|
||||
self.play(Zoom(circles[i], circles[i+1]))
|
||||
self.next_slide()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
current: Optional[Sequence[Mobject]] = None,
|
||||
future: Optional[Sequence[Mobject]] = None,
|
||||
scale: float = 4.0,
|
||||
out: bool = False,
|
||||
fade_in_kwargs: Optional[Mapping[str, Any]] = None,
|
||||
fade_out_kwargs: Optional[Mapping[str, Any]] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
scale_in = 1.0 / scale
|
||||
scale_out = scale
|
||||
|
||||
if out:
|
||||
scale_in, scale_out = scale_out, scale_in
|
||||
|
||||
animations = []
|
||||
|
||||
if future:
|
||||
if fade_in_kwargs is None:
|
||||
fade_in_kwargs = {}
|
||||
|
||||
for mobject in future:
|
||||
animations.append(FadeIn(mobject, scale=scale_in, **fade_in_kwargs))
|
||||
|
||||
if current:
|
||||
if fade_out_kwargs is None:
|
||||
fade_out_kwargs = {}
|
||||
|
||||
for mobject in current:
|
||||
animations.append(FadeOut(mobject, scale=scale_out, **fade_out_kwargs))
|
||||
|
||||
super().__init__(*animations, **kwargs)
|
640
manim_slides/slide/base.py
Normal file
@ -0,0 +1,640 @@
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["BaseSlide"]
|
||||
|
||||
import platform
|
||||
from abc import abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
MutableMapping,
|
||||
Sequence,
|
||||
ValuesView,
|
||||
)
|
||||
|
||||
import numpy as np
|
||||
from tqdm import tqdm
|
||||
|
||||
from ..config import BaseSlideConfig, PresentationConfig, PreSlideConfig, SlideConfig
|
||||
from ..defaults import FOLDER_PATH
|
||||
from ..logger import logger
|
||||
from ..utils import concatenate_video_files, merge_basenames, reverse_video_file
|
||||
from . import MANIM
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .animation import Wipe, Zoom
|
||||
|
||||
if MANIM:
|
||||
from manim.mobject.mobject import Mobject
|
||||
else:
|
||||
Mobject = Any
|
||||
|
||||
LEFT: np.ndarray = np.array([-1.0, 0.0, 0.0])
|
||||
|
||||
|
||||
class BaseSlide:
|
||||
def __init__(
|
||||
self, *args: Any, output_folder: Path = FOLDER_PATH, **kwargs: Any
|
||||
) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._output_folder: Path = output_folder
|
||||
self._slides: list[PreSlideConfig] = []
|
||||
self._base_slide_config: BaseSlideConfig = BaseSlideConfig()
|
||||
self._current_slide = 1
|
||||
self._current_animation = 0
|
||||
self._start_animation = 0
|
||||
self._canvas: MutableMapping[str, Mobject] = {}
|
||||
self._wait_time_between_slides = 0.0
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _frame_height(self) -> float:
|
||||
"""Return the scene's frame height."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _frame_width(self) -> float:
|
||||
"""Return the scene's frame width."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _background_color(self) -> str:
|
||||
"""Return the scene's background color."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _resolution(self) -> tuple[int, int]:
|
||||
"""Return the scene's resolution used during rendering."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _partial_movie_files(self) -> list[Path]:
|
||||
"""Return a list of partial movie files, a.k.a animations."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _show_progress_bar(self) -> bool:
|
||||
"""Return True if progress bar should be displayed."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _leave_progress_bar(self) -> bool:
|
||||
"""Return True if progress bar should be left after completed."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _start_at_animation_number(self) -> int | None:
|
||||
"""If set, return the animation number at which rendering start."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def canvas(self) -> MutableMapping[str, Mobject]:
|
||||
"""
|
||||
Return the canvas associated to the current slide.
|
||||
|
||||
The canvas is a mapping between names and Mobjects,
|
||||
for objects that are assumed to stay in multiple slides.
|
||||
|
||||
For example, a section title or a slide number.
|
||||
|
||||
Examples
|
||||
--------
|
||||
.. manim-slides:: CanvasExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class CanvasExample(Slide):
|
||||
def update_canvas(self):
|
||||
self.counter += 1
|
||||
old_slide_number = self.canvas["slide_number"]
|
||||
new_slide_number = Text(f"{self.counter}").move_to(old_slide_number)
|
||||
self.play(Transform(old_slide_number, new_slide_number))
|
||||
|
||||
def construct(self):
|
||||
title = Text("My Title").to_corner(UL)
|
||||
|
||||
self.counter = 1
|
||||
slide_number = Text("1").to_corner(DL)
|
||||
|
||||
self.add_to_canvas(title=title, slide_number=slide_number)
|
||||
|
||||
self.play(FadeIn(title), FadeIn(slide_number))
|
||||
self.next_slide()
|
||||
|
||||
circle = Circle(radius=2)
|
||||
dot = Dot()
|
||||
|
||||
self.update_canvas()
|
||||
self.play(Create(circle))
|
||||
self.play(MoveAlongPath(dot, circle))
|
||||
|
||||
self.next_slide()
|
||||
self.update_canvas()
|
||||
|
||||
square = Square()
|
||||
|
||||
self.wipe(self.mobjects_without_canvas, square)
|
||||
self.next_slide()
|
||||
|
||||
self.update_canvas()
|
||||
self.play(
|
||||
Transform(
|
||||
self.canvas["title"],
|
||||
Text("New Title").to_corner(UL)
|
||||
)
|
||||
)
|
||||
self.next_slide()
|
||||
|
||||
self.remove_from_canvas("title", "slide_number")
|
||||
self.wipe(self.mobjects_without_canvas, [])
|
||||
"""
|
||||
return self._canvas
|
||||
|
||||
def add_to_canvas(self, **objects: Mobject) -> None:
|
||||
"""
|
||||
Add objects to the canvas, using key values as names.
|
||||
|
||||
:param objects: A mapping between names and Mobjects.
|
||||
|
||||
.. note::
|
||||
|
||||
This method does not actually do anything in terms of
|
||||
animations. You must still call :code:`self.add` or
|
||||
play some animation that introduces each Mobject for
|
||||
it to appear. The same applies when removing objects.
|
||||
"""
|
||||
self._canvas.update(objects)
|
||||
|
||||
def remove_from_canvas(self, *names: str) -> None:
|
||||
"""Remove objects from the canvas."""
|
||||
for name in names:
|
||||
self._canvas.pop(name)
|
||||
|
||||
@property
|
||||
def canvas_mobjects(self) -> ValuesView[Mobject]:
|
||||
"""Return Mobjects contained in the canvas."""
|
||||
return self.canvas.values()
|
||||
|
||||
@property
|
||||
def mobjects_without_canvas(self) -> Sequence[Mobject]:
|
||||
"""
|
||||
Return the list of objects contained in the scene, minus those present in
|
||||
the canvas.
|
||||
"""
|
||||
return [
|
||||
mobject
|
||||
for mobject in self.mobjects # type: ignore[attr-defined]
|
||||
if mobject not in self.canvas_mobjects
|
||||
]
|
||||
|
||||
@property
|
||||
def wait_time_between_slides(self) -> float:
|
||||
r"""
|
||||
Return the wait duration (in seconds) added between two slides.
|
||||
|
||||
By default, this value is set to 0.
|
||||
|
||||
Setting this value to something bigger than 0 will result in a
|
||||
:code:`self.wait` animation called at the end of every slide.
|
||||
|
||||
.. note::
|
||||
This is useful because animations are usually only terminated
|
||||
when a new animation is played. You can observe the small difference
|
||||
in the examples below: the circle is not fully complete in the first
|
||||
slide of the first example, but well in the second example.
|
||||
|
||||
Examples
|
||||
--------
|
||||
.. manim-slides:: WithoutWaitExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class WithoutWaitExample(Slide):
|
||||
def construct(self):
|
||||
circle = Circle(radius=2)
|
||||
arrow = Arrow().next_to(circle, RIGHT).scale(-1)
|
||||
text = Text("Small\ngap").next_to(arrow, RIGHT)
|
||||
|
||||
self.play(Create(arrow), FadeIn(text))
|
||||
self.play(Create(circle))
|
||||
self.next_slide()
|
||||
|
||||
self.play(FadeOut(circle))
|
||||
|
||||
.. manim-slides:: WithWaitExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class WithWaitExample(Slide):
|
||||
def construct(self):
|
||||
self.wait_time_between_slides = 0.1 # A small value > 1 / FPS
|
||||
circle = Circle(radius=2)
|
||||
arrow = Arrow().next_to(circle, RIGHT).scale(-1)
|
||||
text = Text("No more\ngap").next_to(arrow, RIGHT)
|
||||
|
||||
self.play(Create(arrow), FadeIn(text))
|
||||
self.play(Create(circle))
|
||||
self.next_slide()
|
||||
|
||||
self.play(FadeOut(circle))
|
||||
"""
|
||||
return self._wait_time_between_slides
|
||||
|
||||
@wait_time_between_slides.setter
|
||||
def wait_time_between_slides(self, wait_time: float) -> None:
|
||||
self._wait_time_between_slides = max(wait_time, 0.0)
|
||||
|
||||
def play(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Overload `self.play` and increment animation count."""
|
||||
super().play(*args, **kwargs) # type: ignore[misc]
|
||||
self._current_animation += 1
|
||||
|
||||
@BaseSlideConfig.wrapper("base_slide_config")
|
||||
def next_slide(
|
||||
self,
|
||||
*,
|
||||
base_slide_config: BaseSlideConfig,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""
|
||||
Create a new slide with previous animations, and setup options
|
||||
for the next slide.
|
||||
|
||||
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.
|
||||
|
||||
:param args:
|
||||
Positional arguments to be passed to
|
||||
:meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`,
|
||||
or ignored if `manimlib` API is used.
|
||||
:param loop:
|
||||
If set, next slide will be looping.
|
||||
:param auto_next:
|
||||
If set, next slide will play immediately play the next slide
|
||||
upon terminating.
|
||||
|
||||
Note that this is only supported by ``manim-slides present``
|
||||
and ``manim-slides convert --to=html``.
|
||||
:param playback_rate:
|
||||
Playback rate at which the video is played.
|
||||
|
||||
Note that this is only supported by ``manim-slides present``.
|
||||
:param reversed_playback_rate:
|
||||
Playback rate at which the reversed video is played.
|
||||
|
||||
Note that this is only supported by ``manim-slides present``.
|
||||
:param notes:
|
||||
Presenter notes, in Markdown format.
|
||||
|
||||
Note that PowerPoint does not support Markdown.
|
||||
|
||||
Note that this is only supported by ``manim-slides present``
|
||||
and ``manim-slides convert --to=html/pptx``.
|
||||
:param dedent_notes:
|
||||
If set, apply :func:`textwrap.dedent` to notes.
|
||||
:param kwargs:
|
||||
Keyword arguments to be passed to
|
||||
:meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`,
|
||||
or ignored if `manimlib` API is used.
|
||||
|
||||
.. note::
|
||||
|
||||
Calls to :func:`next_slide` at the very beginning or at the end are
|
||||
not needed, since they are automatically added.
|
||||
|
||||
.. warning::
|
||||
|
||||
When rendered with RevealJS, loops cannot be in the first nor
|
||||
the last slide.
|
||||
|
||||
.. seealso::
|
||||
|
||||
When using ``manim`` API, this method will also call
|
||||
:meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
The following contains 3 slides:
|
||||
|
||||
#. the first with nothing on it;
|
||||
#. the second with "Hello World!" fading in;
|
||||
#. and the last with the text fading out;
|
||||
|
||||
.. manim-slides:: NextSlideExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class NextSlideExample(Slide):
|
||||
def construct(self):
|
||||
text = Text("Hello World!")
|
||||
|
||||
self.play(FadeIn(text))
|
||||
|
||||
self.next_slide()
|
||||
self.play(FadeOut(text))
|
||||
|
||||
The following contains one slide that will loop endlessly.
|
||||
|
||||
.. manim-slides:: LoopExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class LoopExample(Slide):
|
||||
def construct(self):
|
||||
dot = Dot(color=BLUE, radius=1)
|
||||
|
||||
self.play(FadeIn(dot))
|
||||
|
||||
self.next_slide(loop=True)
|
||||
|
||||
self.play(Indicate(dot, scale_factor=2))
|
||||
|
||||
self.next_slide()
|
||||
|
||||
self.play(FadeOut(dot))
|
||||
|
||||
The following contains one slide that triggers the next slide
|
||||
upon terminating.
|
||||
|
||||
.. manim-slides:: AutoNextExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class AutoNextExample(Slide):
|
||||
def construct(self):
|
||||
square = Square(color=RED, side_length=2)
|
||||
|
||||
self.play(GrowFromCenter(square))
|
||||
|
||||
self.next_slide(auto_next=True)
|
||||
|
||||
self.play(Wiggle(square))
|
||||
|
||||
self.next_slide()
|
||||
|
||||
self.wipe(square)
|
||||
|
||||
The following contains speaker notes. On the webbrowser,
|
||||
the speaker view can be triggered by pressing :kbd:`S`.
|
||||
|
||||
.. manim-slides:: SpeakerNotesExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class SpeakerNotesExample(Slide):
|
||||
def construct(self):
|
||||
self.next_slide(notes="Some introduction")
|
||||
square = Square(color=GREEN, side_length=2)
|
||||
|
||||
self.play(GrowFromCenter(square))
|
||||
|
||||
self.next_slide(notes="We now rotate the slide")
|
||||
|
||||
self.play(Rotate(square, PI / 2))
|
||||
|
||||
self.next_slide(notes="Bye bye")
|
||||
|
||||
self.zoom(square)
|
||||
"""
|
||||
if self._current_animation > self._start_animation:
|
||||
if self.wait_time_between_slides > 0.0:
|
||||
self.wait(self.wait_time_between_slides) # type: ignore[attr-defined]
|
||||
|
||||
self._slides.append(
|
||||
PreSlideConfig.from_base_slide_config_and_animation_indices(
|
||||
self._base_slide_config,
|
||||
self._start_animation,
|
||||
self._current_animation,
|
||||
)
|
||||
)
|
||||
|
||||
self._current_slide += 1
|
||||
|
||||
self._base_slide_config = base_slide_config
|
||||
self._start_animation = self._current_animation
|
||||
|
||||
def _add_last_slide(self) -> None:
|
||||
"""Add a 'last' slide to the end of slides."""
|
||||
if (
|
||||
len(self._slides) > 0
|
||||
and self._current_animation == self._slides[-1].end_animation
|
||||
):
|
||||
return
|
||||
|
||||
self._slides.append(
|
||||
PreSlideConfig.from_base_slide_config_and_animation_indices(
|
||||
self._base_slide_config,
|
||||
self._start_animation,
|
||||
self._current_animation,
|
||||
)
|
||||
)
|
||||
|
||||
def _save_slides(self, use_cache: bool = True) -> None:
|
||||
"""
|
||||
Save slides, optionally using cached files.
|
||||
|
||||
Note that cached files only work with Manim.
|
||||
"""
|
||||
self._add_last_slide()
|
||||
|
||||
files_folder = self._output_folder / "files"
|
||||
|
||||
scene_name = str(self)
|
||||
scene_files_folder = files_folder / scene_name
|
||||
|
||||
scene_files_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
files: list[Path] = self._partial_movie_files
|
||||
|
||||
# We must filter slides that end before the animation offset
|
||||
if offset := self._start_at_animation_number:
|
||||
self._slides = [
|
||||
slide for slide in self._slides if slide.end_animation > offset
|
||||
]
|
||||
for slide in self._slides:
|
||||
slide.start_animation = max(0, slide.start_animation - offset)
|
||||
slide.end_animation -= offset
|
||||
|
||||
slides: list[SlideConfig] = []
|
||||
|
||||
for pre_slide_config in tqdm(
|
||||
self._slides,
|
||||
desc=f"Concatenating animation files to '{scene_files_folder}' and generating reversed animations",
|
||||
leave=self._leave_progress_bar,
|
||||
ascii=True if platform.system() == "Windows" else None,
|
||||
disable=not self._show_progress_bar,
|
||||
):
|
||||
slide_files = files[pre_slide_config.slides_slice]
|
||||
|
||||
file = merge_basenames(slide_files)
|
||||
dst_file = scene_files_folder / file.name
|
||||
rev_file = scene_files_folder / f"{file.stem}_reversed{file.suffix}"
|
||||
|
||||
# We only concat animations if it was not present
|
||||
if not use_cache or not dst_file.exists():
|
||||
concatenate_video_files(slide_files, dst_file)
|
||||
|
||||
# We only reverse video if it was not present
|
||||
if not use_cache or not rev_file.exists():
|
||||
reverse_video_file(dst_file, rev_file)
|
||||
|
||||
slides.append(
|
||||
SlideConfig.from_pre_slide_config_and_files(
|
||||
pre_slide_config, dst_file, rev_file
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Generated {len(slides)} slides to '{scene_files_folder.absolute()}'"
|
||||
)
|
||||
|
||||
slide_path = self._output_folder / f"{scene_name}.json"
|
||||
|
||||
PresentationConfig(
|
||||
slides=slides,
|
||||
resolution=self._resolution,
|
||||
background_color=self._background_color,
|
||||
).to_file(slide_path)
|
||||
|
||||
logger.info(
|
||||
f"Slide '{scene_name}' configuration written in '{slide_path.absolute()}'"
|
||||
)
|
||||
|
||||
def wipe(
|
||||
self,
|
||||
*args: Any,
|
||||
direction: np.ndarray = LEFT,
|
||||
return_animation: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> Wipe | None:
|
||||
"""
|
||||
Play a wipe animation that will shift all the current objects outside of the
|
||||
current scene's scope, and all the future objects inside.
|
||||
|
||||
:param args: Positional arguments passed to
|
||||
:class:`Wipe<manim_slides.slide.animation.Wipe>`.
|
||||
:param direction: The wipe direction, that will be scaled by the scene size.
|
||||
:param return_animation: If set, return the animation instead of
|
||||
playing it. This is useful to combine multiple animations with this one.
|
||||
:param kwargs: Keyword arguments passed to
|
||||
:class:`Wipe<manim_slides.slide.animation.Wipe>`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
.. manim-slides:: WipeExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class WipeExample(Slide):
|
||||
def construct(self):
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
square = Square()
|
||||
text = Text("This is a wipe example").next_to(square, DOWN)
|
||||
beautiful = Text("Beautiful, no?")
|
||||
|
||||
self.play(FadeIn(circle))
|
||||
self.next_slide()
|
||||
|
||||
self.wipe(circle, Group(square, text))
|
||||
self.next_slide()
|
||||
|
||||
self.wipe(Group(square, text), beautiful, direction=UP)
|
||||
self.next_slide()
|
||||
|
||||
anim = self.wipe(
|
||||
beautiful,
|
||||
circle,
|
||||
direction=DOWN + RIGHT,
|
||||
return_animation=True
|
||||
)
|
||||
self.play(anim)
|
||||
"""
|
||||
from .animation import Wipe
|
||||
|
||||
shift_amount = np.asarray(direction) * np.array(
|
||||
[self._frame_width, self._frame_height, 0.0]
|
||||
)
|
||||
|
||||
kwargs.setdefault("shift", shift_amount)
|
||||
|
||||
animation = Wipe(
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
if return_animation:
|
||||
return animation
|
||||
|
||||
self.play(animation)
|
||||
return None
|
||||
|
||||
def zoom(
|
||||
self,
|
||||
*args: Any,
|
||||
return_animation: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> Zoom | None:
|
||||
"""
|
||||
Play a zoom animation that will fade out all the current objects, and fade in
|
||||
all the future objects. Objects are faded in a direction that goes towards the
|
||||
camera.
|
||||
|
||||
:param args: Positional arguments passed to
|
||||
:class:`Zoom<manim_slides.slide.animation.Zoom>`.
|
||||
:param return_animation: If set, return the animation instead of
|
||||
playing it. This is useful to combine multiple animations with this one.
|
||||
:param kwargs: Keyword arguments passed to
|
||||
:class:`Zoom<manim_slides.slide.animation.Zoom>`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
.. manim-slides:: ZoomExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class ZoomExample(Slide):
|
||||
def construct(self):
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
square = Square()
|
||||
|
||||
self.play(FadeIn(circle))
|
||||
self.next_slide()
|
||||
|
||||
self.zoom(circle, square)
|
||||
self.next_slide()
|
||||
|
||||
anim = self.zoom(
|
||||
square,
|
||||
circle,
|
||||
out=True,
|
||||
scale=10.0,
|
||||
return_animation=True
|
||||
)
|
||||
self.play(anim)
|
||||
"""
|
||||
from .animation import Zoom
|
||||
|
||||
animation = Zoom(*args, **kwargs)
|
||||
|
||||
if return_animation:
|
||||
return animation
|
||||
|
||||
self.play(animation)
|
||||
return None
|
149
manim_slides/slide/manim.py
Normal file
@ -0,0 +1,149 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from manim import Scene, ThreeDScene, config
|
||||
|
||||
from ..config import BaseSlideConfig
|
||||
from .base import BaseSlide
|
||||
|
||||
|
||||
class Slide(BaseSlide, Scene): # type: ignore[misc]
|
||||
"""
|
||||
Inherits from :class:`Scene<manim.scene.scene.Scene>` and provide necessary tools
|
||||
for slides rendering.
|
||||
"""
|
||||
|
||||
@property
|
||||
def _frame_height(self) -> float:
|
||||
return config["frame_height"] # type: ignore
|
||||
|
||||
@property
|
||||
def _frame_width(self) -> float:
|
||||
return config["frame_width"] # type: ignore
|
||||
|
||||
@property
|
||||
def _background_color(self) -> str:
|
||||
color = config["background_color"]
|
||||
if hex_color := getattr(color, "hex", None):
|
||||
return hex_color # type: ignore
|
||||
else: # manim>=0.18, see https://github.com/ManimCommunity/manim/pull/3020
|
||||
return color.to_hex() # type: ignore
|
||||
|
||||
@property
|
||||
def _resolution(self) -> Tuple[int, int]:
|
||||
return config["pixel_width"], config["pixel_height"]
|
||||
|
||||
@property
|
||||
def _partial_movie_files(self) -> List[Path]:
|
||||
# When rendering with -na,b (manim only)
|
||||
# the animations not in [a,b] will be skipped,
|
||||
# but animation before a will have a None source file.
|
||||
return [
|
||||
Path(file)
|
||||
for file in self.renderer.file_writer.partial_movie_files
|
||||
if file is not None
|
||||
]
|
||||
|
||||
@property
|
||||
def _show_progress_bar(self) -> bool:
|
||||
return config["progress_bar"] != "none" # type: ignore
|
||||
|
||||
@property
|
||||
def _leave_progress_bar(self) -> bool:
|
||||
return config["progress_bar"] == "leave" # type: ignore
|
||||
|
||||
@property
|
||||
def _start_at_animation_number(self) -> Optional[int]:
|
||||
return config["from_animation_number"] # type: ignore
|
||||
|
||||
def next_section(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""
|
||||
Alias to :meth:`next_slide`.
|
||||
|
||||
:param args:
|
||||
Positional arguments to be passed to :meth:`next_slide`.
|
||||
:param kwargs:
|
||||
Keyword arguments to be passed to :meth:`next_slide`.
|
||||
|
||||
.. attention::
|
||||
|
||||
This method is only available when using ``manim`` API.
|
||||
"""
|
||||
self.next_slide(*args, **kwargs)
|
||||
|
||||
@BaseSlideConfig.wrapper("base_slide_config")
|
||||
def next_slide(
|
||||
self,
|
||||
*args: Any,
|
||||
base_slide_config: BaseSlideConfig,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
Scene.next_section(self, *args, **kwargs)
|
||||
BaseSlide.next_slide.__wrapped__(
|
||||
self,
|
||||
base_slide_config=base_slide_config,
|
||||
)
|
||||
|
||||
def render(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""MANIM render."""
|
||||
# We need to disable the caching limit since we rely on intermediate files
|
||||
max_files_cached = config["max_files_cached"]
|
||||
config["max_files_cached"] = float("inf")
|
||||
|
||||
super().render(*args, **kwargs)
|
||||
|
||||
config["max_files_cached"] = max_files_cached
|
||||
|
||||
self._save_slides()
|
||||
|
||||
|
||||
class ThreeDSlide(Slide, ThreeDScene): # type: ignore[misc]
|
||||
"""
|
||||
Inherits from :class:`Slide` and
|
||||
:class:`ThreeDScene<manim.scene.three_d_scene.ThreeDScene>` and provide necessary
|
||||
tools for slides rendering.
|
||||
|
||||
Examples
|
||||
--------
|
||||
.. manim-slides:: ThreeDExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import ThreeDSlide
|
||||
|
||||
class ThreeDExample(ThreeDSlide):
|
||||
def construct(self):
|
||||
title = Text("A 2D Text")
|
||||
|
||||
self.play(FadeIn(title))
|
||||
self.next_slide()
|
||||
|
||||
sphere = Sphere([0, 0, -3])
|
||||
|
||||
self.move_camera(phi=PI/3, theta=-PI/4, distance=7)
|
||||
self.play(
|
||||
GrowFromCenter(sphere),
|
||||
Transform(title, Text("A 3D Text"))
|
||||
)
|
||||
self.next_slide()
|
||||
|
||||
bye = Text("Bye!")
|
||||
|
||||
self.next_slide(loop=True)
|
||||
self.wipe(
|
||||
self.mobjects_without_canvas,
|
||||
[bye],
|
||||
direction=UP
|
||||
)
|
||||
self.wait(.5)
|
||||
self.wipe(
|
||||
self.mobjects_without_canvas,
|
||||
[title, sphere],
|
||||
direction=DOWN
|
||||
)
|
||||
self.wait(.5)
|
||||
self.next_slide()
|
||||
|
||||
self.play(*[FadeOut(mobject) for mobject in self.mobjects])
|
||||
"""
|
||||
|
||||
pass
|
72
manim_slides/slide/manimlib.py
Normal file
@ -0,0 +1,72 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar, Dict, List, Optional, Tuple
|
||||
|
||||
from manimlib import Scene, ThreeDCamera
|
||||
from manimlib.utils.file_ops import get_sorted_integer_files
|
||||
|
||||
from .base import BaseSlide
|
||||
|
||||
|
||||
class Slide(BaseSlide, Scene): # type: ignore[misc]
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
kwargs.setdefault("file_writer_config", {}).update(
|
||||
skip_animations=True,
|
||||
break_into_partial_movies=True,
|
||||
write_to_movie=True,
|
||||
)
|
||||
|
||||
kwargs["preview"] = False # Avoid opening a preview window
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def _frame_height(self) -> float:
|
||||
return self.camera.frame.get_height() # type: ignore
|
||||
|
||||
@property
|
||||
def _frame_width(self) -> float:
|
||||
return self.camera.frame.get_width() # type: ignore
|
||||
|
||||
@property
|
||||
def _background_color(self) -> str:
|
||||
return self.camera_config["background_color"].hex # type: ignore
|
||||
|
||||
@property
|
||||
def _resolution(self) -> Tuple[int, int]:
|
||||
return self.camera_config["pixel_width"], self.camera_config["pixel_height"]
|
||||
|
||||
@property
|
||||
def _partial_movie_files(self) -> List[Path]:
|
||||
kwargs = {
|
||||
"remove_non_integer_files": True,
|
||||
"extension": self.file_writer.movie_file_extension,
|
||||
}
|
||||
return [
|
||||
Path(file)
|
||||
for file in get_sorted_integer_files(
|
||||
self.file_writer.partial_movie_directory, **kwargs
|
||||
)
|
||||
]
|
||||
|
||||
@property
|
||||
def _show_progress_bar(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def _leave_progress_bar(self) -> bool:
|
||||
return self.leave_progress_bars # type: ignore
|
||||
|
||||
@property
|
||||
def _start_at_animation_number(self) -> Optional[int]:
|
||||
return self.start_at_animation_number # type: ignore
|
||||
|
||||
def run(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""MANIMGL renderer."""
|
||||
super().run(*args, **kwargs)
|
||||
self._save_slides(use_cache=False)
|
||||
|
||||
|
||||
class ThreeDSlide(Slide):
|
||||
CONFIG: ClassVar[Dict[str, Any]] = {
|
||||
"camera_class": ThreeDCamera,
|
||||
}
|
||||
pass
|
343
manim_slides/templates/revealjs.html
Normal file
@ -0,0 +1,343 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
|
||||
<title>{{ title }}</title>
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/reveal.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/theme/{{ reveal_theme }}.min.css">
|
||||
|
||||
<!-- Theme used for syntax highlighting of code -->
|
||||
<!-- <link rel="stylesheet" href="lib/css/zenburn.css"> -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.13.1/styles/zenburn.min.css">
|
||||
|
||||
<!-- <link rel="stylesheet" href="index.css"> -->
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="reveal">
|
||||
<div class="slides">
|
||||
{%- for presentation_config in presentation_configs -%}
|
||||
{% set outer_loop = loop %}
|
||||
{%- for slide_config in presentation_config.slides -%}
|
||||
{%- if data_uri -%}
|
||||
{% set file = file_to_data_uri(slide_config.file) %}
|
||||
{%- else -%}
|
||||
{% set file = assets_dir / slide_config.file.name %}
|
||||
{%- endif -%}
|
||||
<section
|
||||
data-background-size={{ background_size }}
|
||||
data-background-color="{{ presentation_config.background_color }}"
|
||||
data-background-video="{{ file }}"
|
||||
{% if loop.index == 1 and outer_loop.index == 1 -%}
|
||||
data-background-video-muted
|
||||
{%- endif -%}
|
||||
{% if slide_config.loop -%}
|
||||
data-background-video-loop
|
||||
{%- endif -%}
|
||||
{% if slide_config.auto_next -%}
|
||||
data-autoslide="{{ get_duration_ms(slide_config.file) }}"
|
||||
{%- endif -%}>
|
||||
{% if slide_config.notes != "" -%}
|
||||
<aside class="notes" data-markdown>{{ slide_config.notes }}</aside>
|
||||
{%- endif %}
|
||||
</section>
|
||||
{%- endfor -%}
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/reveal.min.js"></script>
|
||||
|
||||
<!-- To include plugins, see: https://revealjs.com/plugins/ -->
|
||||
|
||||
{% if has_notes -%}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/plugin/markdown/markdown.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/plugin/notes/notes.min.js"></script>
|
||||
{%- endif -%}
|
||||
|
||||
<!-- <script src="index.js"></script> -->
|
||||
<script>
|
||||
Reveal.initialize({
|
||||
{% if has_notes -%}
|
||||
plugins: [ RevealMarkdown, RevealNotes ],
|
||||
{%- endif %}
|
||||
// The "normal" size of the presentation, aspect ratio will
|
||||
// be preserved when the presentation is scaled to fit different
|
||||
// resolutions. Can be specified using percentage units.
|
||||
width: {{ width }},
|
||||
height: {{ height }},
|
||||
|
||||
// Factor of the display size that should remain empty around
|
||||
// the content
|
||||
margin: {{ margin }},
|
||||
|
||||
// Bounds for smallest/largest possible scale to apply to content
|
||||
minScale: {{ min_scale }},
|
||||
maxScale: {{ max_scale }},
|
||||
|
||||
// Display presentation control arrows
|
||||
controls: {{ controls }},
|
||||
|
||||
// Help the user learn the controls by providing hints, for example by
|
||||
// bouncing the down arrow when they first encounter a vertical slide
|
||||
controlsTutorial: {{ controls_tutorial }},
|
||||
|
||||
// Determines where controls appear, "edges" or "bottom-right"
|
||||
controlsLayout: {{ controls_layout }},
|
||||
|
||||
// Visibility rule for backwards navigation arrows; "faded", "hidden"
|
||||
// or "visible"
|
||||
controlsBackArrows: {{ controls_back_arrows }},
|
||||
|
||||
// Display a presentation progress bar
|
||||
progress: {{ progress }},
|
||||
|
||||
// Display the page number of the current slide
|
||||
// - true: Show slide number
|
||||
// - false: Hide slide number
|
||||
//
|
||||
// Can optionally be set as a string that specifies the number formatting:
|
||||
// - "h.v": Horizontal . vertical slide number (default)
|
||||
// - "h/v": Horizontal / vertical slide number
|
||||
// - "c": Flattened slide number
|
||||
// - "c/t": Flattened slide number / total slides
|
||||
//
|
||||
// Alternatively, you can provide a function that returns the slide
|
||||
// number for the current slide. The function should take in a slide
|
||||
// object and return an array with one string [slideNumber] or
|
||||
// three strings [n1,delimiter,n2]. See #formatSlideNumber().
|
||||
slideNumber: {{ slide_number }},
|
||||
|
||||
// Can be used to limit the contexts in which the slide number appears
|
||||
// - "all": Always show the slide number
|
||||
// - "print": Only when printing to PDF
|
||||
// - "speaker": Only in the speaker view
|
||||
showSlideNumber: {{ show_slide_number }},
|
||||
|
||||
// Use 1 based indexing for # links to match slide number (default is zero
|
||||
// based)
|
||||
hashOneBasedIndex: {{ hash_one_based_index }},
|
||||
|
||||
// Add the current slide number to the URL hash so that reloading the
|
||||
// page/copying the URL will return you to the same slide
|
||||
hash: {{ hash }},
|
||||
|
||||
// Flags if we should monitor the hash and change slides accordingly
|
||||
respondToHashChanges: {{ respond_to_hash_changes }},
|
||||
|
||||
// Push each slide change to the browser history. Implies `hash: true`
|
||||
history: {{ history }},
|
||||
|
||||
// Enable keyboard shortcuts for navigation
|
||||
keyboard: {{ keyboard }},
|
||||
|
||||
// Optional function that blocks keyboard events when retuning false
|
||||
//
|
||||
// If you set this to 'focused', we will only capture keyboard events
|
||||
// for embedded decks when they are in focus
|
||||
keyboardCondition: {{ keyboard_condition }},
|
||||
|
||||
// Disables the default reveal.js slide layout (scaling and centering)
|
||||
// so that you can use custom CSS layout
|
||||
disableLayout: {{ disable_layout }},
|
||||
|
||||
// Enable the slide overview mode
|
||||
overview: {{ overview }},
|
||||
|
||||
// Vertical centering of slides
|
||||
center: {{ center }},
|
||||
|
||||
// Enables touch navigation on devices with touch input
|
||||
touch: {{ touch }},
|
||||
|
||||
// Loop the presentation
|
||||
loop: {{ loop }},
|
||||
|
||||
// Change the presentation direction to be RTL
|
||||
rtl: {{ rtl }},
|
||||
|
||||
// Changes the behavior of our navigation directions.
|
||||
//
|
||||
// "default"
|
||||
// Left/right arrow keys step between horizontal slides, up/down
|
||||
// arrow keys step between vertical slides. Space key steps through
|
||||
// all slides (both horizontal and vertical).
|
||||
//
|
||||
// "linear"
|
||||
// Removes the up/down arrows. Left/right arrows step through all
|
||||
// slides (both horizontal and vertical).
|
||||
//
|
||||
// "grid"
|
||||
// When this is enabled, stepping left/right from a vertical stack
|
||||
// to an adjacent vertical stack will land you at the same vertical
|
||||
// index.
|
||||
//
|
||||
// Consider a deck with six slides ordered in two vertical stacks:
|
||||
// 1.1 2.1
|
||||
// 1.2 2.2
|
||||
// 1.3 2.3
|
||||
//
|
||||
// If you're on slide 1.3 and navigate right, you will normally move
|
||||
// from 1.3 -> 2.1. If "grid" is used, the same navigation takes you
|
||||
// from 1.3 -> 2.3.
|
||||
navigationMode: {{ navigation_mode }},
|
||||
|
||||
// Randomizes the order of slides each time the presentation loads
|
||||
shuffle: {{ shuffle }},
|
||||
|
||||
// Turns fragments on and off globally
|
||||
fragments: {{ fragments }},
|
||||
|
||||
// Flags whether to include the current fragment in the URL,
|
||||
// so that reloading brings you to the same fragment position
|
||||
fragmentInURL: {{ fragment_in_url }},
|
||||
|
||||
// Flags if the presentation is running in an embedded mode,
|
||||
// i.e. contained within a limited portion of the screen
|
||||
embedded: {{ embedded }},
|
||||
|
||||
// Flags if we should show a help overlay when the question-mark
|
||||
// key is pressed
|
||||
help: {{ help }},
|
||||
|
||||
// Flags if it should be possible to pause the presentation (blackout)
|
||||
pause: {{ pause }},
|
||||
|
||||
// Flags if speaker notes should be visible to all viewers
|
||||
showNotes: {{ show_notes }},
|
||||
|
||||
// Global override for autolaying embedded media (video/audio/iframe)
|
||||
// - null: Media will only autoplay if data-autoplay is present
|
||||
// - true: All media will autoplay, regardless of individual setting
|
||||
// - false: No media will autoplay, regardless of individual setting
|
||||
autoPlayMedia: {{ auto_play_media }},
|
||||
|
||||
// Global override for preloading lazy-loaded iframes
|
||||
// - null: Iframes with data-src AND data-preload will be loaded when within
|
||||
// the viewDistance, iframes with only data-src will be loaded when visible
|
||||
// - true: All iframes with data-src will be loaded when within the viewDistance
|
||||
// - false: All iframes with data-src will be loaded only when visible
|
||||
preloadIframes: {{ preload_iframes }},
|
||||
|
||||
// Can be used to globally disable auto-animation
|
||||
autoAnimate: {{ auto_animate }},
|
||||
|
||||
// Optionally provide a custom element matcher that will be
|
||||
// used to dictate which elements we can animate between.
|
||||
autoAnimateMatcher: {{ auto_animate_matcher }},
|
||||
|
||||
// Default settings for our auto-animate transitions, can be
|
||||
// overridden per-slide or per-element via data arguments
|
||||
autoAnimateEasing: {{ auto_animate_easing }},
|
||||
autoAnimateDuration: {{ auto_animate_duration }},
|
||||
autoAnimateUnmatched: {{ auto_animate_unmatched }},
|
||||
|
||||
// CSS properties that can be auto-animated. Position & scale
|
||||
// is matched separately so there's no need to include styles
|
||||
// like top/right/bottom/left, width/height or margin.
|
||||
autoAnimateStyles: {{ auto_animate_styles }},
|
||||
|
||||
// Controls automatic progression to the next slide
|
||||
// - 0: Auto-sliding only happens if the data-autoslide HTML attribute
|
||||
// is present on the current slide or fragment
|
||||
// - 1+: All slides will progress automatically at the given interval
|
||||
// - false: No auto-sliding, even if data-autoslide is present
|
||||
autoSlide: {{ auto_slide }},
|
||||
|
||||
// Stop auto-sliding after user input
|
||||
autoSlideStoppable: {{ auto_slide_stoppable }},
|
||||
|
||||
// Use this method for navigation when auto-sliding (defaults to navigateNext)
|
||||
autoSlideMethod: {{ auto_slide_method }},
|
||||
|
||||
// Specify the average time in seconds that you think you will spend
|
||||
// presenting each slide. This is used to show a pacing timer in the
|
||||
// speaker view
|
||||
defaultTiming: {{ default_timing }},
|
||||
|
||||
// Enable slide navigation via mouse wheel
|
||||
mouseWheel: {{ mouse_wheel }},
|
||||
|
||||
// Opens links in an iframe preview overlay
|
||||
// Add `data-preview-link` and `data-preview-link="false"` to customise each link
|
||||
// individually
|
||||
previewLinks: {{ preview_links }},
|
||||
|
||||
// Exposes the reveal.js API through window.postMessage
|
||||
postMessage: {{ post_message }},
|
||||
|
||||
// Dispatches all reveal.js events to the parent window through postMessage
|
||||
postMessageEvents: {{ post_message_events }},
|
||||
|
||||
// Focuses body when page changes visibility to ensure keyboard shortcuts work
|
||||
focusBodyOnPageVisibilityChange: {{ focus_body_on_page_visibility_change }},
|
||||
|
||||
// Transition style
|
||||
transition: {{ transition }}, // none/fade/slide/convex/concave/zoom
|
||||
|
||||
// Transition speed
|
||||
transitionSpeed: {{ transition_speed }}, // default/fast/slow
|
||||
|
||||
// Transition style for full page slide backgrounds
|
||||
backgroundTransition: {{ background_transition }}, // none/fade/slide/convex/concave/zoom
|
||||
|
||||
// The maximum number of pages a single slide can expand onto when printing
|
||||
// to PDF, unlimited by default
|
||||
pdfMaxPagesPerSlide: {{ pdf_max_pages_per_slide }},
|
||||
|
||||
// Prints each fragment on a separate slide
|
||||
pdfSeparateFragments: {{ pdf_separate_fragments }},
|
||||
|
||||
// Offset used to reduce the height of content within exported PDF pages.
|
||||
// This exists to account for environment differences based on how you
|
||||
// print to PDF. CLI printing options, like phantomjs and wkpdf, can end
|
||||
// on precisely the total height of the document whereas in-browser
|
||||
// printing has to end one pixel before.
|
||||
pdfPageHeightOffset: {{ pdf_page_height_offset }},
|
||||
|
||||
// Number of slides away from the current that are visible
|
||||
viewDistance: {{ view_distance }},
|
||||
|
||||
// Number of slides away from the current that are visible on mobile
|
||||
// devices. It is advisable to set this to a lower number than
|
||||
// viewDistance in order to save resources.
|
||||
mobileViewDistance: {{ mobile_view_distance }},
|
||||
|
||||
// The display mode that will be used to show slides
|
||||
display: {{ display }},
|
||||
|
||||
// Hide cursor if inactive
|
||||
hideInactiveCursor: {{ hide_inactive_cursor }},
|
||||
|
||||
// Time before the cursor is hidden (in ms)
|
||||
hideCursorTime: {{ hide_cursor_time }}
|
||||
});
|
||||
|
||||
{% if data_uri %}
|
||||
// Fix found by @t-fritsch on GitHub
|
||||
// see: https://github.com/hakimel/reveal.js/discussions/3362#discussioncomment-6651475.
|
||||
function fixBase64VideoBackground(event) {
|
||||
// event.previousSlide, event.currentSlide, event.indexh, event.indexv
|
||||
if (event.currentSlide.getAttribute('data-background-video')) {
|
||||
const background = Reveal.getSlideBackground(event.indexh, event.indexv),
|
||||
video = background.querySelector('video'),
|
||||
sources = video.querySelectorAll('source');
|
||||
|
||||
sources.forEach((source, i) => {
|
||||
const src = source.getAttribute('src');
|
||||
if(src.match(/^data:video.*;base64$/)) {
|
||||
const nextSrc = sources[i+1]?.getAttribute('src');
|
||||
video.setAttribute('src', `${src},${nextSrc}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reveal.on( 'ready', fixBase64VideoBackground );
|
||||
Reveal.on( 'slidechanged', fixBase64VideoBackground );
|
||||
{% endif %}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
98
manim_slides/utils.py
Normal file
@ -0,0 +1,98 @@
|
||||
import hashlib
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import av
|
||||
|
||||
from .logger import logger
|
||||
|
||||
|
||||
def concatenate_video_files(files: List[Path], dest: Path) -> None:
|
||||
"""Concatenate multiple video files into one."""
|
||||
f = tempfile.NamedTemporaryFile(mode="w", delete=False)
|
||||
f.writelines(f"file '{path.absolute()}'\n" for path in files)
|
||||
f.close()
|
||||
|
||||
input_ = av.open(f.name, options={"safe": "0"}, format="concat")
|
||||
input_stream = input_.streams.video[0]
|
||||
output = av.open(str(dest), mode="w")
|
||||
output_stream = output.add_stream(
|
||||
template=input_stream,
|
||||
)
|
||||
|
||||
for packet in input_.demux(input_stream):
|
||||
# We need to skip the "flushing" packets that `demux` generates.
|
||||
if packet.dts is None:
|
||||
continue
|
||||
|
||||
# We need to assign the packet to the new stream.
|
||||
packet.stream = output_stream
|
||||
output.mux(packet)
|
||||
|
||||
input_.close()
|
||||
output.close()
|
||||
|
||||
|
||||
def merge_basenames(files: List[Path]) -> Path:
|
||||
"""Merge multiple filenames by concatenating basenames."""
|
||||
if len(files) == 0:
|
||||
raise ValueError("Cannot merge an empty list of files!")
|
||||
|
||||
dirname: Path = files[0].parent
|
||||
ext = files[0].suffix
|
||||
|
||||
basenames = list(file.stem for file in files)
|
||||
|
||||
basenames_str = ",".join(f"{len(b)}:{b}" for b in basenames)
|
||||
|
||||
# We use hashes to prevent too-long filenames, see issue #123:
|
||||
# https://github.com/jeertmans/manim-slides/issues/123
|
||||
basename = hashlib.sha256(basenames_str.encode()).hexdigest()
|
||||
|
||||
logger.debug(f"Generated a new basename for basenames: {basenames} -> '{basename}'")
|
||||
|
||||
return dirname.joinpath(basename + ext)
|
||||
|
||||
|
||||
def link_nodes(*nodes: av.filter.context.FilterContext) -> None:
|
||||
"""Code from https://github.com/PyAV-Org/PyAV/issues/239."""
|
||||
for c, n in zip(nodes, nodes[1:]):
|
||||
c.link_to(n)
|
||||
|
||||
|
||||
def reverse_video_file(src: Path, dest: Path) -> None:
|
||||
"""Reverses a video file, writting the result to `dest`."""
|
||||
input_ = av.open(str(src))
|
||||
input_stream = input_.streams.video[0]
|
||||
output = av.open(str(dest), mode="w")
|
||||
output_stream = output.add_stream(codec_name="libx264", rate=input_stream.base_rate)
|
||||
output_stream.width = input_stream.width
|
||||
output_stream.height = input_stream.height
|
||||
output_stream.pix_fmt = input_stream.pix_fmt
|
||||
|
||||
graph = av.filter.Graph()
|
||||
link_nodes(
|
||||
graph.add_buffer(template=input_stream),
|
||||
graph.add("reverse"),
|
||||
graph.add("buffersink"),
|
||||
)
|
||||
graph.configure()
|
||||
|
||||
frames_count = 0
|
||||
for frame in input_.decode(video=0):
|
||||
graph.push(frame)
|
||||
frames_count += 1
|
||||
|
||||
graph.push(None) # EOF: https://github.com/PyAV-Org/PyAV/issues/886.
|
||||
|
||||
for _ in range(frames_count):
|
||||
frame = graph.pull()
|
||||
frame.pict_type = 1 # Otherwise we get a warning saying it is changed
|
||||
output.mux(output_stream.encode(frame))
|
||||
|
||||
for packet in output_stream.encode():
|
||||
output.mux(packet)
|
||||
|
||||
input_.close()
|
||||
output.close()
|
@ -7,7 +7,6 @@ import click
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QIcon, QKeyEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QGridLayout,
|
||||
@ -22,7 +21,8 @@ from .commons import config_options, verbosity_option
|
||||
from .config import Config, Key
|
||||
from .defaults import CONFIG_PATH
|
||||
from .logger import logger
|
||||
from .resources import * # noqa: F401, F403
|
||||
from .qt_utils import qapp
|
||||
from .resources import * # noqa: F403
|
||||
|
||||
WINDOW_NAME: str = "Configuration Wizard"
|
||||
|
||||
@ -43,7 +43,7 @@ class KeyInput(QDialog): # type: ignore
|
||||
self.layout.addWidget(self.label)
|
||||
self.setLayout(self.layout)
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None:
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
|
||||
self.key = event.key()
|
||||
self.deleteLater()
|
||||
event.accept()
|
||||
@ -57,12 +57,13 @@ class Wizard(QWidget): # type: ignore
|
||||
self.config = config
|
||||
self.icon = QIcon(":/icon.png")
|
||||
self.setWindowIcon(self.icon)
|
||||
self.closed_without_saving = False
|
||||
|
||||
QBtn = QDialogButtonBox.Save | QDialogButtonBox.Cancel
|
||||
button = QDialogButtonBox.Save | QDialogButtonBox.Cancel
|
||||
|
||||
self.buttonBox = QDialogButtonBox(QBtn)
|
||||
self.buttonBox.accepted.connect(self.saveConfig)
|
||||
self.buttonBox.rejected.connect(self.closeWithoutSaving)
|
||||
self.button_box = QDialogButtonBox(button)
|
||||
self.button_box.accepted.connect(self.save_config)
|
||||
self.button_box.rejected.connect(self.close_without_saving)
|
||||
|
||||
self.buttons = []
|
||||
|
||||
@ -72,7 +73,7 @@ class Wizard(QWidget): # type: ignore
|
||||
# Create label for key name information
|
||||
label = QLabel()
|
||||
key_info = value["name"] or key
|
||||
label.setText(key_info)
|
||||
label.setText(key_info.title())
|
||||
self.layout.addWidget(label, i, 0)
|
||||
|
||||
# Create button that will pop-up a dialog and ask to input a new key
|
||||
@ -83,24 +84,24 @@ class Wizard(QWidget): # type: ignore
|
||||
)
|
||||
self.buttons.append(button)
|
||||
button.clicked.connect(
|
||||
partial(self.openDialog, i, getattr(self.config.keys, key))
|
||||
partial(self.open_dialog, i, getattr(self.config.keys, key))
|
||||
)
|
||||
self.layout.addWidget(button, i, 1)
|
||||
|
||||
self.layout.addWidget(self.buttonBox, len(self.buttons), 1)
|
||||
self.layout.addWidget(self.button_box, len(self.buttons), 1)
|
||||
|
||||
self.setLayout(self.layout)
|
||||
|
||||
def closeWithoutSaving(self) -> None:
|
||||
def close_without_saving(self) -> None:
|
||||
logger.debug("Closing configuration wizard without saving")
|
||||
self.closed_without_saving = True
|
||||
self.deleteLater()
|
||||
sys.exit(0)
|
||||
|
||||
def closeEvent(self, event: Any) -> None:
|
||||
self.closeWithoutSaving()
|
||||
def closeEvent(self, event: Any) -> None: # noqa: N802
|
||||
self.close_without_saving()
|
||||
event.accept()
|
||||
|
||||
def saveConfig(self) -> None:
|
||||
def save_config(self) -> None:
|
||||
try:
|
||||
Config.model_validate(self.config.dict())
|
||||
except ValueError:
|
||||
@ -111,15 +112,15 @@ class Wizard(QWidget): # type: ignore
|
||||
"Two or more actions share a common key: make sure actions have distinct key codes."
|
||||
)
|
||||
msg.setWindowTitle("Error: duplicated keys")
|
||||
msg.exec_()
|
||||
msg.exec()
|
||||
return
|
||||
|
||||
self.deleteLater()
|
||||
|
||||
def openDialog(self, button_number: int, key: Key) -> None:
|
||||
def open_dialog(self, button_number: int, key: Key) -> None:
|
||||
button = self.buttons[button_number]
|
||||
dialog = KeyInput()
|
||||
dialog.exec_()
|
||||
dialog.exec()
|
||||
if dialog.key is not None:
|
||||
key_name = keymap[dialog.key]
|
||||
key.set_ids(dialog.key)
|
||||
@ -149,8 +150,10 @@ def init(
|
||||
def _init(
|
||||
config_path: Path, force: bool, merge: bool, skip_interactive: bool = False
|
||||
) -> None:
|
||||
"""Actual initialization code for configuration file, with optional interactive mode."""
|
||||
|
||||
"""
|
||||
Actual initialization code for configuration file, with optional interactive
|
||||
mode.
|
||||
"""
|
||||
if config_path.exists():
|
||||
click.secho(f"The `{CONFIG_PATH}` configuration file exists")
|
||||
|
||||
@ -178,12 +181,15 @@ def _init(
|
||||
if config_path.exists():
|
||||
config = Config.from_file(config_path)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app = qapp()
|
||||
app.setApplicationName("Manim Slides Wizard")
|
||||
window = Wizard(config)
|
||||
window.show()
|
||||
app.exec()
|
||||
|
||||
if window.closed_without_saving:
|
||||
sys.exit(0)
|
||||
|
||||
config = window.config
|
||||
|
||||
if merge:
|
||||
|
BIN
paper/docs.png
Before Width: | Height: | Size: 4.3 MiB After Width: | Height: | Size: 158 KiB |
4616
poetry.lock
generated
209
pyproject.toml
@ -1,24 +1,9 @@
|
||||
[build-system]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
requires = ["setuptools", "poetry-core>=1.0.0"]
|
||||
build-backend = "pdm.backend"
|
||||
requires = ["pdm-backend", "setuptools"]
|
||||
|
||||
[tool.black]
|
||||
target-version = ["py38"]
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
py_version = 38
|
||||
|
||||
[tool.mypy]
|
||||
disallow_untyped_decorators = false
|
||||
install_types = true
|
||||
python_version = "3.8"
|
||||
strict = true
|
||||
|
||||
[tool.poetry]
|
||||
authors = [
|
||||
"Jérome Eertmans <jeertmans@icloud.com>"
|
||||
]
|
||||
[project]
|
||||
authors = [{name = "Jérome Eertmans", email = "jeertmans@icloud.com"}]
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
@ -29,104 +14,126 @@ classifiers = [
|
||||
"Operating System :: OS Independent",
|
||||
"Topic :: Multimedia :: Video",
|
||||
"Topic :: Multimedia :: Graphics",
|
||||
"Topic :: Scientific/Engineering"
|
||||
"Topic :: Scientific/Engineering",
|
||||
]
|
||||
dependencies = [
|
||||
"av>=9.0.0",
|
||||
"click>=8.1.3",
|
||||
"click-default-group>=1.2.2",
|
||||
"jinja2>=3.1.2",
|
||||
"lxml>=4.9.2",
|
||||
"numpy>=1.19",
|
||||
"opencv-python>=4.6.0.66",
|
||||
"pillow>=9.5.0",
|
||||
"pydantic>=2.0.1",
|
||||
"pydantic-extra-types>=2.0.0",
|
||||
"pyside6==6.5.2",
|
||||
"python-pptx>=0.6.21",
|
||||
"requests>=2.28.1",
|
||||
"rich>=13.3.2",
|
||||
"rtoml>=0.9.0",
|
||||
"tqdm>=4.64.1",
|
||||
]
|
||||
description = "Tool for live presentations using manim"
|
||||
documentation = "https://eertmans.be/manim-slides"
|
||||
exclude = ["docs/", "static/"]
|
||||
homepage = "https://github.com/jeertmans/manim-slides"
|
||||
dynamic = ["version"]
|
||||
keywords = ["manim", "slides", "plugin", "manimgl"]
|
||||
license = "MIT"
|
||||
license = {text = "MIT"}
|
||||
name = "manim-slides"
|
||||
packages = [
|
||||
{include = "manim_slides"}
|
||||
]
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/jeertmans/manim-slides"
|
||||
version = "4.16.0"
|
||||
requires-python = ">=3.8,<3.12"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
click = "^8.1.3"
|
||||
click-default-group = "^1.2.2"
|
||||
docutils = {version = "^0.20.1", optional = true}
|
||||
ipython = {version = ">=8.12.2", optional = true}
|
||||
jinja2 = {version = "^3.1.2", optional = true}
|
||||
lxml = "^4.9.2"
|
||||
manim = {version = "^0.17.3", optional = true}
|
||||
manimgl = {version = "^1.6.1", optional = true}
|
||||
notebook = {version = "^7.0.2", optional = true}
|
||||
numpy = "^1.19"
|
||||
opencv-python = "^4.6.0.66"
|
||||
pillow = "^9.5.0"
|
||||
pydantic = "^2.0.1"
|
||||
pydantic-extra-types = "^2.0.0"
|
||||
pyside6 = "^6.5.1.1"
|
||||
python = ">=3.8.1,<3.12"
|
||||
python-pptx = "^0.6.21"
|
||||
requests = "^2.28.1"
|
||||
rich = "^13.3.2"
|
||||
rtoml = "^0.9.0"
|
||||
tqdm = "^4.64.1"
|
||||
[project.optional-dependencies]
|
||||
all = [
|
||||
"manim-slides[magic,manim,manimgl,sphinx-directive]",
|
||||
]
|
||||
magic = ["manim-slides[manim]", "ipython>=8.12.2"]
|
||||
manim = ["manim>=0.17.3"]
|
||||
manimgl = ["manimgl>=1.6.1"]
|
||||
sphinx-directive = ["docutils>=0.20.1", "manim-slides[manim]"]
|
||||
|
||||
[tool.poetry.extras]
|
||||
magic = ["manim", "ipython"]
|
||||
manim = ["manim"]
|
||||
manimgl = ["manimgl"]
|
||||
sphinx-directive = ["docutils", "jinja2", "manim"]
|
||||
|
||||
[tool.poetry.group.dev]
|
||||
optional = true
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = "^22.10.0"
|
||||
bump2version = "^1.0.1"
|
||||
isort = "^5.12.0"
|
||||
mypy = "^0.991"
|
||||
pre-commit = "^3.0.2"
|
||||
ruff = "^0.0.219"
|
||||
|
||||
[tool.poetry.group.docs]
|
||||
optional = true
|
||||
|
||||
[tool.poetry.group.docs.dependencies]
|
||||
furo = "^2023.5.20"
|
||||
ipykernel = "^6.25.1"
|
||||
manim = "^0.17.3"
|
||||
myst-parser = "^2.0.0"
|
||||
nbsphinx = "^0.9.2"
|
||||
pandoc = "^2.3"
|
||||
sphinx = "^7.0.1"
|
||||
sphinx-click = "^4.4.0"
|
||||
sphinx-copybutton = "^0.5.1"
|
||||
sphinxext-opengraph = "^0.7.5"
|
||||
|
||||
[tool.poetry.group.test]
|
||||
optional = true
|
||||
|
||||
[tool.poetry.group.test.dependencies]
|
||||
manim = "^0.17.3"
|
||||
manimgl = "^1.6.1"
|
||||
pytest = "^7.4.0"
|
||||
pytest-cov = "^4.1.0"
|
||||
pytest-env = "^0.8.2"
|
||||
pytest-xdist = "^3.3.1"
|
||||
|
||||
[tool.poetry.plugins]
|
||||
|
||||
[tool.poetry.plugins."console_scripts"]
|
||||
[project.scripts]
|
||||
manim-slides = "manim_slides.__main__:cli"
|
||||
|
||||
[project.urls]
|
||||
Changelog = "https://github.com/jeertmans/manim-slides/releases"
|
||||
Documentation = "https://eertmans.be/manim-slides"
|
||||
Founding = "https://github.com/sponsors/jeertmans"
|
||||
Homepage = "https://github.com/jeertmans/manim-slides"
|
||||
Repository = "https://github.com/jeertmans/manim-slides"
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
'pragma: no cover',
|
||||
'raise NotImplementedError',
|
||||
'if TYPE_CHECKING:',
|
||||
'if typing.TYPE_CHECKING:',
|
||||
]
|
||||
precision = 2
|
||||
|
||||
[tool.mypy]
|
||||
disallow_untyped_decorators = false
|
||||
install_types = true
|
||||
python_version = "3.8"
|
||||
strict = true
|
||||
|
||||
[tool.pdm.dev-dependencies]
|
||||
dev = [
|
||||
"bump2version>=1.0.1",
|
||||
"pre-commit>=3.5.0",
|
||||
]
|
||||
docs = [
|
||||
"manim-slides[magic,sphinx-directive]",
|
||||
"furo>=2023.5.20",
|
||||
"ipykernel>=6.25.1",
|
||||
"myst-parser>=2.0.0",
|
||||
"nbsphinx>=0.9.2",
|
||||
"pandoc>=2.3",
|
||||
"sphinx>=7.0.1",
|
||||
"sphinx-click>=4.4.0",
|
||||
"sphinx-copybutton>=0.5.1",
|
||||
"sphinxext-opengraph>=0.7.5",
|
||||
]
|
||||
github-action = ["setuptools"]
|
||||
test = [
|
||||
"manim-slides[manim,manimgl]",
|
||||
"pytest>=7.4.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"pytest-env>=0.8.2",
|
||||
"pytest-qt>=4.2.0",
|
||||
"pytest-xdist>=3.3.1",
|
||||
]
|
||||
|
||||
[tool.pdm.version]
|
||||
path = "manim_slides/__version__.py"
|
||||
source = "file"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
env = [
|
||||
"QT_QPA_PLATFORM=offscreen"
|
||||
"QT_QPA_PLATFORM=offscreen",
|
||||
]
|
||||
filterwarnings = [
|
||||
"error",
|
||||
"ignore::DeprecationWarning"
|
||||
"ignore::DeprecationWarning",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
ignore = [
|
||||
"E501"
|
||||
extend-exclude = ["manim_slides/resources.py"]
|
||||
extend-ignore = [
|
||||
"D100",
|
||||
"D101",
|
||||
"D102",
|
||||
"D103",
|
||||
"D104",
|
||||
"D105",
|
||||
"D106",
|
||||
"D107",
|
||||
"D203",
|
||||
"D205",
|
||||
"D212",
|
||||
"E501",
|
||||
]
|
||||
extend-include = ["*.ipynb"]
|
||||
extend-select = ["B", "C90", "D", "I", "N", "RUF", "UP", "T"]
|
||||
isort = {known-first-party = ['manim_slides', 'tests']}
|
||||
line-length = 88
|
||||
target-version = "py38"
|
||||
|
BIN
static/docs.png
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 178 KiB |
Before Width: | Height: | Size: 670 KiB After Width: | Height: | Size: 485 KiB |
BIN
static/icon.png
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.8 KiB |
BIN
static/logo.png
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 90 KiB |
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 86 KiB |
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 86 KiB |
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 110 KiB |
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 102 KiB |
@ -1,21 +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
|
||||
MANIM_SLIDES_THEME=light pdm run manim-slides 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
|
||||
MANIM_SLIDES_THEME=dark_docs pdm run manim-slides 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
|
||||
MANIM_SLIDES_THEME=dark_github pdm run manim-slides 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
|
||||
MANIM_SLIDES_THEME=light pdm run manim-slides 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
|
||||
MANIM_SLIDES_THEME=dark_docs pdm run manim-slides 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
|
||||
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 29 KiB |
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 24 KiB |
@ -1,13 +1,78 @@
|
||||
import random
|
||||
import string
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
from typing import Generator, Iterator, List
|
||||
|
||||
import pytest
|
||||
|
||||
from manim_slides.logger import make_logger
|
||||
from manim_slides.config import PresentationConfig
|
||||
|
||||
_ = make_logger() # This is run so that "PERF" level is created
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def tests_folder() -> Iterator[Path]:
|
||||
yield Path(__file__).parent.resolve(strict=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def project_folder(tests_folder: Path) -> Iterator[Path]:
|
||||
yield tests_folder.parent.resolve(strict=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def data_folder(tests_folder: Path) -> Iterator[Path]:
|
||||
yield (tests_folder / "data").resolve(strict=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def slides_folder(data_folder: Path) -> Iterator[Path]:
|
||||
yield (data_folder / "slides").resolve(strict=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def slides_file(data_folder: Path) -> Iterator[Path]:
|
||||
yield (data_folder / "slides.py").resolve(strict=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def manimgl_config(project_folder: Path) -> Iterator[Path]:
|
||||
yield (project_folder / "custom_config.yml").resolve(strict=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def video_file(data_folder: Path) -> Iterator[Path]:
|
||||
yield (data_folder / "video.mp4").resolve(strict=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def video_data_uri_file(data_folder: Path) -> Iterator[Path]:
|
||||
yield (data_folder / "video_data_uri.txt").resolve(strict=True)
|
||||
|
||||
|
||||
def random_path(
|
||||
length: int = 20,
|
||||
dirname: Path = Path("./media/videos/example"),
|
||||
suffix: str = ".mp4",
|
||||
touch: bool = False,
|
||||
) -> Path:
|
||||
basename = "".join(random.choices(string.ascii_letters, k=length))
|
||||
|
||||
filepath = dirname.joinpath(basename + suffix)
|
||||
|
||||
if touch:
|
||||
filepath.touch()
|
||||
|
||||
return filepath.resolve(strict=touch)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def folder_path() -> Iterator[Path]:
|
||||
yield (Path(__file__).parent / "slides").resolve()
|
||||
def paths() -> Generator[List[Path], None, None]:
|
||||
random.seed(1234)
|
||||
|
||||
yield [random_path() for _ in range(20)]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def presentation_config(
|
||||
slides_folder: Path,
|
||||
) -> Generator[PresentationConfig, None, None]:
|
||||
yield PresentationConfig.from_file(slides_folder / "BasicSlide.json")
|
||||
|
40
tests/data/slides.py
Normal file
@ -0,0 +1,40 @@
|
||||
# flake8: noqa: F403, F405
|
||||
# type: ignore
|
||||
|
||||
from manim_slides import Slide
|
||||
from manim_slides.slide import MANIM, MANIMGL
|
||||
|
||||
if MANIM:
|
||||
from manim import *
|
||||
elif MANIMGL:
|
||||
from manimlib import *
|
||||
|
||||
|
||||
class BasicSlide(Slide):
|
||||
def construct(self):
|
||||
text = Text("This is some text")
|
||||
|
||||
self.play(Write(text))
|
||||
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
|
||||
self.play(Transform(text, circle))
|
||||
|
||||
circle = text # this is to avoid name confusion
|
||||
|
||||
square = Square()
|
||||
|
||||
self.play(FadeIn(square))
|
||||
|
||||
self.next_slide(loop=True)
|
||||
|
||||
self.play(Rotate(square, +PI / 2))
|
||||
self.play(Rotate(square, -PI / 2))
|
||||
|
||||
self.next_slide()
|
||||
|
||||
other_text = Text("Other text")
|
||||
self.wipe([square, circle], [other_text])
|
||||
|
||||
self.next_slide()
|
||||
self.zoom(other_text, [])
|
29
tests/data/slides/BasicSlide.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"slides": [
|
||||
{
|
||||
"file": "slides/files/BasicSlide/28bf32c4df2711b07b765a647667059683133b3c45291f34692be0c845f75511.mp4",
|
||||
"rev_file": "slides/files/BasicSlide/28bf32c4df2711b07b765a647667059683133b3c45291f34692be0c845f75511_reversed.mp4",
|
||||
"loop": false
|
||||
},
|
||||
{
|
||||
"file": "slides/files/BasicSlide/c7d0d9ccbf764d32bf316451f2d00607b8f12893e64afe215041a8aedceeb368.mp4",
|
||||
"rev_file": "slides/files/BasicSlide/c7d0d9ccbf764d32bf316451f2d00607b8f12893e64afe215041a8aedceeb368_reversed.mp4",
|
||||
"loop": true
|
||||
},
|
||||
{
|
||||
"file": "slides/files/BasicSlide/5060f74bee3cb2e40a399a023e0120b3f91d348a9867c7f401db54ea337de97c.mp4",
|
||||
"rev_file": "slides/files/BasicSlide/5060f74bee3cb2e40a399a023e0120b3f91d348a9867c7f401db54ea337de97c_reversed.mp4",
|
||||
"loop": false
|
||||
},
|
||||
{
|
||||
"file": "slides/files/BasicSlide/7a5de547a0b5de2230ff3451dd680425cf0a7ea065b31e8f92b5e93527694077.mp4",
|
||||
"rev_file": "slides/files/BasicSlide/7a5de547a0b5de2230ff3451dd680425cf0a7ea065b31e8f92b5e93527694077_reversed.mp4",
|
||||
"loop": false
|
||||
}
|
||||
],
|
||||
"resolution": [
|
||||
854,
|
||||
480
|
||||
],
|
||||
"background_color": "black"
|
||||
}
|
BIN
tests/data/video.mp4
Normal file
1
tests/data/video_data_uri.txt
Normal file
@ -1,32 +0,0 @@
|
||||
{
|
||||
"slides": [
|
||||
{
|
||||
"type": "slide",
|
||||
"start_animation": 0,
|
||||
"end_animation": 1,
|
||||
"number": 1
|
||||
},
|
||||
{
|
||||
"type": "loop",
|
||||
"start_animation": 1,
|
||||
"end_animation": 2,
|
||||
"number": 2
|
||||
},
|
||||
{
|
||||
"type": "last",
|
||||
"start_animation": 2,
|
||||
"end_animation": 3,
|
||||
"number": 3
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"slides/files/BasicExample/1413466013_3346521118_223132457.mp4",
|
||||
"slides/files/BasicExample/1672018281_3136302242_2191168284.mp4",
|
||||
"slides/files/BasicExample/1672018281_1369283980_3942561600.mp4"
|
||||
],
|
||||
"resolution": [
|
||||
1920,
|
||||
1080
|
||||
],
|
||||
"background_color": "black"
|
||||
}
|
105
tests/test_base_slide.py
Normal file
@ -0,0 +1,105 @@
|
||||
from typing import MutableMapping
|
||||
|
||||
import pytest
|
||||
|
||||
from manim_slides.slide.base import BaseSlide
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_slide() -> BaseSlide:
|
||||
return BaseSlide() # type: ignore[abstract]
|
||||
|
||||
|
||||
class TestBaseSlide:
|
||||
def test_frame_height(self, base_slide: BaseSlide) -> None:
|
||||
with pytest.raises(NotImplementedError):
|
||||
_ = base_slide._frame_height
|
||||
|
||||
def test_frame_width(self, base_slide: BaseSlide) -> None:
|
||||
with pytest.raises(NotImplementedError):
|
||||
_ = base_slide._frame_width
|
||||
|
||||
def test_background_color(self, base_slide: BaseSlide) -> None:
|
||||
with pytest.raises(NotImplementedError):
|
||||
_ = base_slide._background_color
|
||||
|
||||
def test_resolution(self, base_slide: BaseSlide) -> None:
|
||||
with pytest.raises(NotImplementedError):
|
||||
_ = base_slide._resolution
|
||||
|
||||
def test_partial_movie_files(self, base_slide: BaseSlide) -> None:
|
||||
with pytest.raises(NotImplementedError):
|
||||
_ = base_slide._partial_movie_files
|
||||
|
||||
def test_show_progress_bar(self, base_slide: BaseSlide) -> None:
|
||||
with pytest.raises(NotImplementedError):
|
||||
_ = base_slide._show_progress_bar
|
||||
|
||||
def test_leave_progress_bar(self, base_slide: BaseSlide) -> None:
|
||||
with pytest.raises(NotImplementedError):
|
||||
_ = base_slide._leave_progress_bar
|
||||
|
||||
def test_start_at_animation_number(self, base_slide: BaseSlide) -> None:
|
||||
with pytest.raises(NotImplementedError):
|
||||
_ = base_slide._start_at_animation_number
|
||||
|
||||
def test_canvas(self, base_slide: BaseSlide) -> None:
|
||||
assert len(base_slide.canvas) == 0
|
||||
assert isinstance(base_slide.canvas, MutableMapping)
|
||||
|
||||
def test_add_to__and_remove_from_canvas(self, base_slide: BaseSlide) -> None:
|
||||
assert len(base_slide.canvas) == 0
|
||||
|
||||
base_slide.add_to_canvas(a=1, b=2)
|
||||
|
||||
assert len(base_slide.canvas) == 2
|
||||
assert base_slide.canvas["a"] == 1
|
||||
assert base_slide.canvas["b"] == 2
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
_ = base_slide.canvas["c"]
|
||||
|
||||
base_slide.add_to_canvas(b=3, c=4)
|
||||
|
||||
assert len(base_slide.canvas) == 3
|
||||
|
||||
assert sorted(base_slide.canvas_mobjects) == [1, 3, 4]
|
||||
|
||||
base_slide.remove_from_canvas("a", "b", "c")
|
||||
|
||||
assert len(base_slide.canvas) == 0
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
base_slide.remove_from_canvas("a")
|
||||
|
||||
def test_mobjects_without_canvas(self) -> None:
|
||||
pass # This property should be tested in test_slide.py
|
||||
|
||||
def test_wait_time_between_slides(self, base_slide: BaseSlide) -> None:
|
||||
assert base_slide.wait_time_between_slides == 0.0
|
||||
|
||||
base_slide.wait_time_between_slides = 1.0
|
||||
|
||||
assert base_slide.wait_time_between_slides == 1.0
|
||||
|
||||
base_slide.wait_time_between_slides = -1.0
|
||||
|
||||
assert base_slide.wait_time_between_slides == 0.0
|
||||
|
||||
def test_play(self) -> None:
|
||||
pass # This method should be tested in test_slide.py
|
||||
|
||||
def test_next_slide(self) -> None:
|
||||
pass # This method should be tested in test_slide.py
|
||||
|
||||
def test_add_last_slide(self) -> None:
|
||||
pass # This method should be tested in test_slide.py
|
||||
|
||||
def test_save_slides(self) -> None:
|
||||
pass # This method should be tested in test_slide.py
|
||||
|
||||
def test_zoom(self) -> None:
|
||||
pass # This method should be tested in test_slide.py
|
||||
|
||||
def test_wipe(self) -> None:
|
||||
pass # This method should be tested in test_slide.py
|
@ -95,7 +95,7 @@ def test_folder_path_option() -> None:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("verbosity",),
|
||||
[("PeRF",), ("DEBUG",), ("info",), ("waRNING",), ("eRRor",), ("CrItIcAl",)],
|
||||
[("DEBUG",), ("info",), ("waRNING",), ("eRRor",), ("CrItIcAl",)],
|
||||
)
|
||||
def test_valid_verbosity_option(verbosity: str) -> None:
|
||||
@click.command()
|
||||
|