mirror of
https://github.com/jeertmans/manim-slides.git
synced 2025-05-18 03:05:21 +08:00
Compare commits
181 Commits
Author | SHA1 | Date | |
---|---|---|---|
f15a3e9b59 | |||
205972125c | |||
e9d28dc0a8 | |||
70b5ee39c3 | |||
616e025867 | |||
0ce4c18519 | |||
68ff5269eb | |||
753f4e788b | |||
f1f98bf241 | |||
4b413c1528 | |||
478e1d7d76 | |||
2b224530ab | |||
cd7a054cf1 | |||
1ff2330ff2 | |||
1e150bbb84 | |||
13f19649aa | |||
4c97bdd3a3 | |||
777ff444a3 | |||
9cb1c35f00 | |||
1fed193cb3 | |||
9f227936f7 | |||
2fe6139d18 | |||
54f2c60c4e | |||
9810425ff2 | |||
3dc543e3a6 | |||
c0c73ad4d4 | |||
a82ca81dc5 | |||
a68a4e1517 | |||
519dd47ac6 | |||
0565a99639 | |||
8dfe600656 | |||
03107867ab | |||
bf64962c46 | |||
97e7bf8cb0 | |||
1bca2683e1 | |||
d6bb82261c | |||
0c682e4ec9 | |||
2f0453c9a6 | |||
85ea9f3096 | |||
1ae8db7966 | |||
82cccc3fc2 | |||
726b0abf5a | |||
80f4f4e3f7 | |||
82eebae686 | |||
7367cc2cb5 | |||
f26541eb32 | |||
06890ceacd | |||
9aa715a0e4 | |||
a373bdb460 | |||
e3e79617c0 | |||
668de2c023 | |||
929caec018 | |||
48cc3343bd | |||
144e7dac5b | |||
8b56f42183 | |||
534bc21672 | |||
b1a8768963 | |||
422e355758 | |||
3eb9fa0b74 | |||
8f519ed134 | |||
916e2aa2ab | |||
4d5f664348 | |||
cb6a5bb35f | |||
bba05cce16 | |||
ad02c8296b | |||
0778cebef7 | |||
163260415b | |||
241419a781 | |||
bac21815b2 | |||
2f8f7561a6 | |||
a489dfd0e8 | |||
76ef16d98b | |||
88125bf1ae | |||
cffc4ebbc5 | |||
d717bc651d | |||
bc3d55fce2 | |||
5b9cb1523c | |||
51a87840ce | |||
42550e8b29 | |||
501813483c | |||
0ae99c0f4d | |||
c2315928bd | |||
f3c8f3cc24 | |||
14a266b139 | |||
b697442fc0 | |||
4f8fae75cf | |||
d6ec0d3da9 | |||
546451e019 | |||
2457ca8a05 | |||
9900b3123e | |||
ee92e0aa88 | |||
cbee6320f5 | |||
382084f9ef | |||
068484b828 | |||
91f8d97acf | |||
49cdedc6fe | |||
fe1fa059f6 | |||
3f6d2e5e57 | |||
99ad798155 | |||
84c25f1ed5 | |||
7fb3fa01dd | |||
2d2a225afe | |||
b9d2cd92b5 | |||
620bb30960 | |||
b35a87befe | |||
84eb562f1b | |||
138cf014d2 | |||
4816fc9a41 | |||
f6f851bd09 | |||
87dba671ac | |||
c6c19bce89 | |||
58999d0681 | |||
8696fca829 | |||
2856aeb89b | |||
2ba0d48ac1 | |||
5f730593fb | |||
dfc5c9eb6c | |||
14c17e1d24 | |||
449ff4cd00 | |||
606c521573 | |||
b199fc7023 | |||
4b05f22c8c | |||
1da3492732 | |||
0d97bdabb8 | |||
8e50adf0ba | |||
d583d591c1 | |||
c243fe3481 | |||
eba84a44fc | |||
c8cf2e6074 | |||
743329c043 | |||
ed1b2eb698 | |||
cda304fef0 | |||
3e6c562b1d | |||
85c295a2c1 | |||
ac5582753c | |||
c53e410ff8 | |||
e7c2bcfe98 | |||
ac486f4f23 | |||
0bd29202e7 | |||
061706922b | |||
574c545f86 | |||
dab96c980d | |||
78941e10fc | |||
255c86ba25 | |||
981b34faf5 | |||
cc42a1bac5 | |||
efa6fe05cf | |||
11494209e4 | |||
e0669707e7 | |||
51ca828920 | |||
2076d65944 | |||
9640605ae9 | |||
efbe488660 | |||
e82ab99186 | |||
7128c01ab5 | |||
c6d44b0b3b | |||
14b334fa6f | |||
68858c3c33 | |||
3520c42d7c | |||
1720a7d742 | |||
d9eab15fa5 | |||
b3210ec285 | |||
db5fa33e0c | |||
da0972ef43 | |||
8081617e29 | |||
9247568531 | |||
94e399ba91 | |||
b3f988421a | |||
b9eb2b395a | |||
f3d2c4e731 | |||
ddeb20646d | |||
588c4b285f | |||
6fdf6d670b | |||
2a8b6734ec | |||
d2f04341fa | |||
19fa17212c | |||
73107f5065 | |||
0429cf3543 | |||
87bbc9a8c6 | |||
c0a5a70f93 | |||
ea171e1301 |
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [jeertmans]
|
40
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
40
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
@ -0,0 +1,40 @@
|
||||
name: Bug
|
||||
description: Report an issue to help improve the project.
|
||||
labels: bug
|
||||
title: '[BUG] <description>'
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: A brief description of the question or issue, also include what you tried and what didn't work
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: Which version of Manim Slides are you using? You can use `manim-slides --version` to get that information.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
description: What is your platform. Linux, macOS, or Windows?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: Please add screenshots if applicable
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: extrainfo
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Is there anything else we should know about this bug?
|
||||
validations:
|
||||
required: false
|
59
.github/ISSUE_TEMPLATE/documentation.yml
vendored
Normal file
59
.github/ISSUE_TEMPLATE/documentation.yml
vendored
Normal file
@ -0,0 +1,59 @@
|
||||
name: Documentation
|
||||
description: Ask / Report an issue related to the documentation.
|
||||
title: 'DOC: <description>'
|
||||
labels: [bug, docs]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: >
|
||||
**Thank you for wanting to report a problem with manim-slides docs!**
|
||||
|
||||
|
||||
If the problem seems straightforward, feel free to submit a PR instead!
|
||||
|
||||
|
||||
⚠
|
||||
Verify first that your issue is not already reported on GitHub [Issues].
|
||||
|
||||
|
||||
[Issues]:
|
||||
https://github.com/jeertmans/manim-slides/issues
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the Issue
|
||||
description: A clear and concise description of the issue you encountered.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: Affected Page
|
||||
description: Add a link to page with the problem.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Issue Type
|
||||
description: >
|
||||
Please select the option in the drop-down.
|
||||
|
||||
<details>
|
||||
<summary>
|
||||
<em>Issue?</em>
|
||||
</summary>
|
||||
</details>
|
||||
options:
|
||||
- Documentation Enhancement
|
||||
- Documentation Report
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Recommended fix or suggestions
|
||||
description: A clear and concise description of how you want to update it.
|
||||
validations:
|
||||
required: false
|
26
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
26
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
name: Feature Request
|
||||
description: Have a new idea/feature? Please suggest!
|
||||
labels: enhancement
|
||||
title: '[FEATURE] <description>'
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: A brief description of the enhancement you propose, also include what you tried and what worked.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: Please add screenshots if applicable
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: extrainfo
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Is there anything else we should know about this idea?
|
||||
validations:
|
||||
required: false
|
14
.github/ISSUE_TEMPLATE/support.yml
vendored
Normal file
14
.github/ISSUE_TEMPLATE/support.yml
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
name: Question/Help/Support
|
||||
description: Ask us about Manim Slides
|
||||
title: 'Support: Ask us anything'
|
||||
labels: [help, question]
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Please explain the issue you're experiencing (with as much detail as possible):"
|
||||
description: >
|
||||
Please make sure to leave a reference to the document/code you're
|
||||
referring to.
|
||||
validations:
|
||||
required: true
|
26
.github/pull_request_template.md
vendored
Normal file
26
.github/pull_request_template.md
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
<!-- If your PR fixes an open issue, use `Closes #999` to link your PR with the issue. #999 stands for the issue number you are fixing -->
|
||||
|
||||
## Fixes Issue
|
||||
|
||||
<!-- Remove this section if not applicable -->
|
||||
|
||||
<!-- Example: Closes #31 -->
|
||||
|
||||
## Description
|
||||
|
||||
<!-- Describe all the proposed changes in your PR -->
|
||||
|
||||
## Check List (Check all the applicable boxes)
|
||||
|
||||
- [ ] I understand that my contributions needs to pass the checks.
|
||||
- [ ] If I created new functions / methods, I documented them and add type hints.
|
||||
- [ ] If I modified already existing code, I updated the documentation accordingly.
|
||||
- [ ] The title of my pull request is a short description of the requested changes.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- Add all the screenshots which support your changes -->
|
||||
|
||||
## Note to reviewers
|
||||
|
||||
<!-- Add notes to reviewers if applicable -->
|
72
.github/workflows/codeql-analysis.yml
vendored
Normal file
72
.github/workflows/codeql-analysis.yml
vendored
Normal file
@ -0,0 +1,72 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: 45 3 * * 2
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [python]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
22
.github/workflows/deploy-pipy.yaml
vendored
22
.github/workflows/deploy-pipy.yaml
vendored
@ -1,22 +0,0 @@
|
||||
name: Publish top PyPI
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish to PyPI
|
||||
if: startsWith(github.ref, 'refs/tags')
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Install
|
||||
run: python -m pip install build --user
|
||||
- name: Build binary wheel and a source tarball
|
||||
run: python -m build --sdist --wheel --outdir dist/ .
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@master
|
||||
with:
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
15
.github/workflows/languagetool.yml
vendored
Normal file
15
.github/workflows/languagetool.yml
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
name: LanguageTool check
|
||||
|
||||
jobs:
|
||||
languagetool_check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: reviewdog/action-languagetool@v1
|
||||
with:
|
||||
reporter: github-pr-review
|
||||
level: warning
|
79
.github/workflows/pages.yml
vendored
Normal file
79
.github/workflows/pages.yml
vendored
Normal file
@ -0,0 +1,79 @@
|
||||
# Simple workflow for deploying static content to GitHub Pages
|
||||
name: Deploy static content to Pages
|
||||
|
||||
on:
|
||||
# Runs on pushes targeting the default branch
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
pull_request:
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# Allow one concurrent deployment
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Single deploy job since we're just deploying
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Install Poetry
|
||||
run: pipx install poetry
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.9'
|
||||
cache: poetry
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v2
|
||||
- name: Install Linux Dependencies
|
||||
run: sudo apt install libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev
|
||||
- name: Install Python dependencies
|
||||
run: pip install manim sphinx sphinx_click furo
|
||||
- name: Install local Python package
|
||||
run: poetry install --with docs
|
||||
- name: Restore cached media
|
||||
id: cache-media-restore
|
||||
uses: actions/cache/restore@v3
|
||||
with:
|
||||
path: media
|
||||
key: ${{ runner.os }}-media
|
||||
- name: Build animation and convert it into HTML slides
|
||||
run: |
|
||||
poetry run manim example.py ConvertExample BasicExample ThreeDExample
|
||||
poetry run manim-slides convert ConvertExample docs/source/_static/slides.html -ccontrols=true
|
||||
poetry run manim-slides convert BasicExample docs/source/_static/basic_example.html -ccontrols=true
|
||||
poetry run manim-slides convert ThreeDExample docs/source/_static/three_d_example.html -ccontrols=true
|
||||
- name: Save media to cache
|
||||
id: cache-media-save
|
||||
uses: actions/cache/save@v3
|
||||
with:
|
||||
path: media
|
||||
key: ${{ steps.cache-media-restore.outputs.cache-primary-key }}
|
||||
- name: Build docs
|
||||
run: cd docs && poetry run make html
|
||||
- name: Upload artifact
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-pages-artifact@v1
|
||||
with:
|
||||
# Upload docs/build/html dir
|
||||
path: docs/build/html/
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/deploy-pages@v1
|
48
.github/workflows/python-publish.yml
vendored
Normal file
48
.github/workflows/python-publish.yml
vendored
Normal file
@ -0,0 +1,48 @@
|
||||
# Modified from: https://github.com/pypa/cibuildwheel
|
||||
name: Upload Python Package
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build_wheels:
|
||||
name: Build wheels on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- uses: actions/setup-python@v2
|
||||
|
||||
- name: Install build package
|
||||
run: python -m pip install -U build
|
||||
|
||||
- name: Build wheels
|
||||
run: python -m build --sdist
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: dist
|
||||
path: dist/*.tar.*
|
||||
|
||||
release:
|
||||
name: Release
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build_wheels]
|
||||
steps:
|
||||
- uses: actions/download-artifact@v2
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
- name: Upload to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@master
|
||||
with:
|
||||
user: __token__
|
||||
password: ${{ secrets.PYPI_API_TOKEN }}
|
117
.github/workflows/test_examples.yml
vendored
Normal file
117
.github/workflows/test_examples.yml
vendored
Normal file
@ -0,0 +1,117 @@
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.py'
|
||||
- .github/workflows/test_examples.yml
|
||||
workflow_dispatch:
|
||||
|
||||
name: Test Examples
|
||||
|
||||
env:
|
||||
QT_QPA_PLATFORM: offscreen
|
||||
MANIM_SLIDES_VERBOSITY: debug
|
||||
PYTHONFAULTHANDLER: 1
|
||||
DISPLAY: :99
|
||||
|
||||
jobs:
|
||||
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 }}
|
||||
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
|
||||
|
||||
# Path related stuff
|
||||
- name: Append to Path on MacOS
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: |
|
||||
echo "${HOME}/.local/bin" >> $GITHUB_PATH
|
||||
echo "/Users/runner/Library/Python/${{ matrix.pyversion }}/bin" >> $GITHUB_PATH
|
||||
- name: Append to Path on Ubuntu
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: echo "${HOME}/.local/bin" >> $GITHUB_PATH
|
||||
- name: Append to Path on Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: echo "${HOME}/.local/bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
|
||||
# OS depedencies
|
||||
- name: Install manim dependencies on MacOs
|
||||
if: matrix.os == 'macos-latest' && matrix.manim == 'manim'
|
||||
run: brew install ffmpeg py3cairo
|
||||
- name: Install manimgl dependencies on MacOS
|
||||
if: matrix.os == 'macos-latest' && matrix.manim == 'manimgl'
|
||||
run: brew install ffmpeg
|
||||
- name: Install manim dependencies on Ubuntu
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.manim == 'manim'
|
||||
run: |
|
||||
sudo apt-get install libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev
|
||||
- name: Install manimgl dependencies on Ubuntu
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.manim == 'manimgl'
|
||||
run: |
|
||||
sudo apt-get install libpango1.0-dev ffmpeg freeglut3-dev
|
||||
- name: Install xvfb on Ubuntu
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.manim == 'manimgl'
|
||||
run: |
|
||||
sudo apt-get install xvfb
|
||||
nohup Xvfb $DISPLAY &
|
||||
- name: Install Windows dependencies
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: choco install ffmpeg
|
||||
|
||||
# Install Manim Slides
|
||||
- name: Install Manim Slides
|
||||
run: |
|
||||
poetry config experimental.new-installer false
|
||||
poetry install --with test
|
||||
|
||||
# Render slides
|
||||
- name: Render slides
|
||||
if: matrix.manim == 'manim'
|
||||
run: poetry run manim -ql example.py BasicExample ThreeDExample
|
||||
- name: Render slides
|
||||
if: matrix.manim == 'manimgl'
|
||||
run: poetry run -v manimgl -l example.py BasicExample ThreeDExample
|
||||
|
||||
# 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
|
28
.gitignore
vendored
28
.gitignore
vendored
@ -8,4 +8,30 @@ __pycache__/
|
||||
/media
|
||||
/presentation
|
||||
|
||||
/.vscode
|
||||
/.vscode
|
||||
|
||||
slides/
|
||||
|
||||
.manim-slides.json
|
||||
|
||||
videos/
|
||||
|
||||
images/
|
||||
|
||||
docs/build/
|
||||
|
||||
docs/source/_static/slides_assets/
|
||||
|
||||
docs/source/_static/slides.html
|
||||
|
||||
slides_assets/
|
||||
|
||||
slides.html
|
||||
|
||||
docs/source/_static/basic_example_assets/
|
||||
|
||||
docs/source/_static/basic_example.html
|
||||
|
||||
docs/source/_static/three_d_example.html
|
||||
|
||||
docs/source/_static/three_d_example_assets/
|
||||
|
34
.pre-commit-config.yaml
Normal file
34
.pre-commit-config.yaml
Normal file
@ -0,0 +1,34 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.4.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.6.0
|
||||
hooks:
|
||||
- id: pretty-format-yaml
|
||||
args: [--autofix]
|
||||
- id: pretty-format-toml
|
||||
exclude: poetry.lock
|
||||
args: [--autofix]
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 22.12.0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||
rev: v0.0.237
|
||||
hooks:
|
||||
- id: ruff
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v0.991
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies: [types-requests, types-setuptools]
|
@ -671,4 +671,4 @@ into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
211
README.md
211
README.md
@ -1,111 +1,186 @@
|
||||
# manim-presentation
|
||||

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

|
||||
# Manim Slides
|
||||
|
||||
## Install
|
||||
Tool for live presentations using either [Manim (community edition)](https://www.manim.community/) or [ManimGL](https://3b1b.github.io/manim/). Manim Slides will *automatically* detect the one you are using!
|
||||
|
||||
> **_NOTE:_** This project extends the work of [`manim-presentation`](https://github.com/galatolofederico/manim-presentation), with a lot more features!
|
||||
|
||||
- [Installation](#installation)
|
||||
* [Dependencies](#dependencies)
|
||||
* [Pip install](#pip-install)
|
||||
* [Install From Repository](#install-from-repository)
|
||||
- [Usage](#usage)
|
||||
* [Basic Example](#basic-example)
|
||||
* [Key Bindings](#key-bindings)
|
||||
* [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)
|
||||
- [Contributing](#contributing)
|
||||
|
||||
## 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
|
||||
```
|
||||
pip install manim-presentation opencv-python
|
||||
```
|
||||
|
||||
### Install From Repository
|
||||
|
||||
An alternative way to install Manim Slides is to clone the git repository, and install from there: read the [contributing guide](https://eertmans.be/manim-slides/contributing/workflow.html) to know how.
|
||||
|
||||
<!-- end install -->
|
||||
|
||||
## Usage
|
||||
|
||||
Use the class `Slide` as your scenes base class
|
||||
<!-- start usage -->
|
||||
|
||||
Using Manim Slides is a two-step process:
|
||||
1. Render animations using `Slide` (resp. `ThreeDSlide`) as a base class instead of `Scene` (resp. `ThreeDScene`), and add calls to `self.pause()` everytime you want to create a new slide.
|
||||
2. Run `manim-slides` on rendered animations and display them like a *Power Point* presentation.
|
||||
|
||||
The documentation is available [online](https://eertmans.be/manim-slides/).
|
||||
|
||||
### Basic Example
|
||||
|
||||
Wrap a series of animations between `self.start_loop()` and `self.stop_loop()` when you want to loop them (until input to continue):
|
||||
|
||||
```python
|
||||
from manim_presentation import Slide
|
||||
# example.py
|
||||
|
||||
class Example(Slide):
|
||||
def construct(self):
|
||||
...
|
||||
```
|
||||
|
||||
call `self.pause()` when you want to pause the playback and wait for an input to continue (check the keybindings)
|
||||
|
||||
Wrap a series of animations between `self.start_loop()` and `self.stop_loop()` when you want to loop them (until input to continue)
|
||||
|
||||
```python
|
||||
from manim import *
|
||||
from manim_presentation import Slide
|
||||
# or: from manimlib import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class Example(Slide):
|
||||
class BasicExample(Slide):
|
||||
def construct(self):
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
dot = Dot()
|
||||
|
||||
self.play(GrowFromCenter(circle))
|
||||
self.pause()
|
||||
self.pause() # Waits user to press continue to go to the next slide
|
||||
|
||||
self.start_loop()
|
||||
self.start_loop() # Start loop
|
||||
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
|
||||
self.end_loop()
|
||||
self.end_loop() # This will loop until user inputs a key
|
||||
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
self.pause()
|
||||
|
||||
self.wait()
|
||||
self.pause() # Waits user to press continue to go to the next slide
|
||||
```
|
||||
|
||||
You **must** end your `Slide` with a `self.play(...)` or a `self.wait(..)`
|
||||
First, render the animation files:
|
||||
|
||||
```bash
|
||||
manim example.py BasicExample
|
||||
# or
|
||||
manimgl example.py BasicExample
|
||||
```
|
||||
|
||||
To start the presentation using `Scene1`, `Scene2` and so on simply run:
|
||||
|
||||
```
|
||||
manim_presentation Scene1 Scene2...
|
||||
```bash
|
||||
manim-slides [OPTIONS] Scene1 Scene2...
|
||||
```
|
||||
|
||||
## Default Keybindings
|
||||
Or in this example:
|
||||
|
||||
Default keybindings to control the presentation
|
||||
|
||||
| Keybinding | Action |
|
||||
|:-----------:|:------------------------:|
|
||||
| Right Arrow | Continue/Next Slide |
|
||||
| Left Arrow | Previous Slide |
|
||||
| R | Re-Animate Current Slide |
|
||||
| Spacebar | Play/Pause |
|
||||
| Q | Quit |
|
||||
|
||||
|
||||
You can specify different keybindings creating a file named `manim-presentation.json` with the keys: `QUIT_KEY` `CONTINUE_KEY` `BACK_KEY` `REWIND_KEY` and `PLAYPAUSE_KEY`
|
||||
`manim-presentation` uses `cv2.waitKeyEx()` to wait for keypresses
|
||||
|
||||
## Run Example
|
||||
|
||||
Clone this repository
|
||||
|
||||
```
|
||||
git clone https://github.com/galatolofederico/manim-presentation.git
|
||||
cd manim-presentation
|
||||
```bash
|
||||
manim-slides BasicExample
|
||||
```
|
||||
|
||||
Create a virtualenv
|
||||
<!-- end usage -->
|
||||
|
||||
```
|
||||
virtualenv --python=python3.7 env
|
||||
. ./env/bin/activate
|
||||
## Key Bindings
|
||||
|
||||
The default key bindings to control the presentation are:
|
||||
|
||||

|
||||
|
||||
|
||||
You can run the **configuration wizard** to change those key bindings:
|
||||
|
||||
```bash
|
||||
manim-slides wizard
|
||||
```
|
||||
|
||||
Install `manim` and `manim_presentation`
|
||||
A default file can be created with:
|
||||
|
||||
```
|
||||
pip install manim manim-presentation opencv-python
|
||||
```bash
|
||||
manim-slides init
|
||||
```
|
||||
|
||||
Render the example scene
|
||||
> **_NOTE:_** `manim-slides` uses key codes, which are platform dependent. Using the configuration wizard is therefore highly recommended.
|
||||
|
||||
```
|
||||
manim -qh example.py
|
||||
```
|
||||
## Interactive Tutorial
|
||||
|
||||
Run the presentation
|
||||
Click on the image to watch a slides presentation that explains you how to use Manim Slides.
|
||||
|
||||
```
|
||||
manim_presentation Example
|
||||
```
|
||||
[](https://eertmans.be/manim-slides/)
|
||||
|
||||
## Contributions and license
|
||||
## Other Examples
|
||||
|
||||
The code is released as Free Software under the [GNU/GPLv3](https://choosealicense.com/licenses/gpl-3.0/) license. Copying, adapting and republishing it is not only consent but also encouraged.
|
||||
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.
|
||||
|
||||
For any further question feel free to reach me at [federico.galatolo@ing.unipi.it](mailto:federico.galatolo@ing.unipi.it) or on Telegram [@galatolo](https://t.me/galatolo)
|
||||
Below is a small recording of me playing with the slides back and forth.
|
||||
|
||||

|
||||
|
||||
|
||||
## Comparison with Similar Tools
|
||||
|
||||
There exists are variety of tools that allows to create slides presentations containing Manim animations.
|
||||
|
||||
Below is a comparison of the most used ones with Manim Slides:
|
||||
|
||||
| Project name | Manim Slides | Manim Presentation | Manim Editor | Jupyter Notebooks |
|
||||
|:------------:|:------------:|:------------------:|:------------:|:-----------------:|
|
||||
| Link | [](https://github.com/jeertmans/manim-slides) | [](https://github.com/galatolofederico/manim-presentation) | [](https://github.com/ManimCommunity/manim_editor) | [](https://github.com/jupyter/notebook) |
|
||||
| Activity | [](https://github.com/jeertmans/manim-slides) | [](https://github.com/galatolofederico/manim-presentation) | [](https://github.com/ManimCommunity/manim_editor) | [](https://github.com/jupyter/notebook) |
|
||||
| Usage | Command-line | Command-line | Web Browser | Notebook |
|
||||
| Note | Requires minimal modif. in scenes files | Requires minimal modif. in scenes files | Requires the usage of sections, and configuration through graphical interface | Relies on `nbconvert` to create slides from a Notebook |
|
||||
| Support for ManimGL | Yes | No | No | No |
|
||||
| Web Browser presentations | Yes | No | Yes | No |
|
||||
| Offline presentations | Yes, with Qt | Yes, with OpenCV | No | No
|
||||
|
||||
## F.A.Q
|
||||
|
||||
### How to increase quality on Windows
|
||||
|
||||
On Windows platform, one may encounter a lower image resolution than expected. Usually, this is observed because Windows rescales every application to fit the screen.
|
||||
As found by [@arashash](https://github.com/arashash), in [#20](https://github.com/jeertmans/manim-slides/issues/20), the problem can be addressed by changing the scaling factor to 100%:
|
||||
|
||||

|
||||
|
||||
in *Settings*->*Display*.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are more than welcome! Please read through [our contributing section](https://eertmans.be/manim-slides/contributing/index.html).
|
||||
|
||||
[pypi-version-badge]: https://img.shields.io/pypi/v/manim-slides?label=manim-slides
|
||||
[pypi-version-url]: https://pypi.org/project/manim-slides/
|
||||
[pypi-python-version-badge]: https://img.shields.io/pypi/pyversions/manim-slides
|
||||
|
20
docs/Makefile
Normal file
20
docs/Makefile
Normal file
@ -0,0 +1,20 @@
|
||||
# Minimal makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line, and also
|
||||
# from the environment for the first two.
|
||||
SPHINXOPTS ?=
|
||||
SPHINXBUILD ?= sphinx-build
|
||||
SOURCEDIR = source
|
||||
BUILDDIR = build
|
||||
|
||||
# Put it first so that "make" without argument is like "make help".
|
||||
help:
|
||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
.PHONY: help Makefile
|
||||
|
||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||
%: Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
35
docs/make.bat
Normal file
35
docs/make.bat
Normal file
@ -0,0 +1,35 @@
|
||||
@ECHO OFF
|
||||
|
||||
pushd %~dp0
|
||||
|
||||
REM Command file for Sphinx documentation
|
||||
|
||||
if "%SPHINXBUILD%" == "" (
|
||||
set SPHINXBUILD=sphinx-build
|
||||
)
|
||||
set SOURCEDIR=source
|
||||
set BUILDDIR=build
|
||||
|
||||
%SPHINXBUILD% >NUL 2>NUL
|
||||
if errorlevel 9009 (
|
||||
echo.
|
||||
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
||||
echo.installed, then set the SPHINXBUILD environment variable to point
|
||||
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
||||
echo.may add the Sphinx directory to PATH.
|
||||
echo.
|
||||
echo.If you don't have Sphinx installed, grab it from
|
||||
echo.https://www.sphinx-doc.org/
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if "%1" == "" goto help
|
||||
|
||||
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||
goto end
|
||||
|
||||
:help
|
||||
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||
|
||||
:end
|
||||
popd
|
BIN
docs/source/_static/logo.png
Normal file
BIN
docs/source/_static/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 110 KiB |
3
docs/source/changelog.md
Normal file
3
docs/source/changelog.md
Normal file
@ -0,0 +1,3 @@
|
||||
# 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).
|
61
docs/source/conf.py
Normal file
61
docs/source/conf.py
Normal file
@ -0,0 +1,61 @@
|
||||
# type: ignore
|
||||
# Configuration file for the Sphinx documentation builder.
|
||||
#
|
||||
# For the full list of built-in configuration values, see the documentation:
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
||||
|
||||
project = "Manim Slides"
|
||||
copyright = "2023, Jérome Eertmans"
|
||||
author = "Jérome Eertmans"
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||
|
||||
extensions = [
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.intersphinx",
|
||||
"sphinx.ext.viewcode",
|
||||
"sphinxext.opengraph",
|
||||
"sphinx_click",
|
||||
"myst_parser",
|
||||
"sphinx_copybutton",
|
||||
]
|
||||
|
||||
templates_path = ["_templates"]
|
||||
exclude_patterns = []
|
||||
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
|
||||
|
||||
html_theme = "furo"
|
||||
html_static_path = ["_static"]
|
||||
|
||||
html_theme_options = {
|
||||
"footer_icons": [
|
||||
{
|
||||
"name": "GitHub",
|
||||
"url": "https://github.com/jeertmans/manim-slides",
|
||||
"html": """
|
||||
<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"></path>
|
||||
</svg>
|
||||
""",
|
||||
"class": "",
|
||||
},
|
||||
],
|
||||
"source_repository": "https://github.com/jeertmans/manim-slides/",
|
||||
"source_branch": "main",
|
||||
"source_directory": "docs/source/",
|
||||
}
|
||||
|
||||
## -- Intersphinx mapping
|
||||
|
||||
intersphinx_mapping = {
|
||||
"python": ("https://docs.python.org/3", None),
|
||||
"manim": ("https://docs.manim.community/en/stable/", None),
|
||||
"manimlib": ("https://3b1b.github.io/manim/", None),
|
||||
}
|
21
docs/source/contributing/index.md
Normal file
21
docs/source/contributing/index.md
Normal file
@ -0,0 +1,21 @@
|
||||
# Contributing
|
||||
|
||||
Thank you for your interest in Manim Slides! ✨
|
||||
|
||||
Manim Slides is an open source project, first created as a fork of [manim-presentation](https://github.com/galatolofederico/manim-presentation) (now deprecated in favor to Manim Slides), and we welcome contributions of all forms.
|
||||
|
||||
This section is here to help fist-time contributors know how they can help this project grow. Whether you are already familiar with Manim or GitHub, it is worth taking a few minutes to read those documents!
|
||||
|
||||
|
||||
```{toctree}
|
||||
:hidden:
|
||||
|
||||
workflow
|
||||
internals
|
||||
```
|
||||
|
||||
[Workflow](./workflow)
|
||||
: how to work on this project. Start here if you're a new contributor.
|
||||
|
||||
[Internals](./internals)
|
||||
: how Manim Slides is built and how the various parts of it work.
|
11
docs/source/contributing/internals.md
Normal file
11
docs/source/contributing/internals.md
Normal file
@ -0,0 +1,11 @@
|
||||
# Internals
|
||||
|
||||
Manim-Slides' work in split in two steps: first, when rendering animation, and, second, when converting multiple animations into one slides presentation.
|
||||
|
||||
## Rendering
|
||||
|
||||
To render animations, Manim Slides simply uses Manim or ManimGL, and creates some additional output files that it needs for the presentation.
|
||||
|
||||
## Slides presentation
|
||||
|
||||
Manim Slides searches for the local artifacts it generated previously, and concatenates them into one presentation. For the graphical interface, it uses `PySide6`.
|
57
docs/source/contributing/workflow.md
Normal file
57
docs/source/contributing/workflow.md
Normal file
@ -0,0 +1,57 @@
|
||||
# Workflow
|
||||
|
||||
This document is there to help you recreate a working environment for Manim Slides.
|
||||
|
||||
## Dependencies
|
||||
|
||||
```{include} ../../../README.md
|
||||
:start-after: <!-- start deps -->
|
||||
:end-before: <!-- end deps -->
|
||||
```
|
||||
|
||||
## Forking the repository and cloning it locally
|
||||
|
||||
We used GitHub to host Manim Slides' repository, and we encourage contributors to use git.
|
||||
|
||||
Useful links:
|
||||
|
||||
* [GitHub's Hello World](https://docs.github.com/en/get-started/quickstart/hello-world).
|
||||
* [GitHub Pull Request in 100 Seconds](https://www.youtube.com/watch?v=8lGpZkjnkt4&ab_channel=Fireship).
|
||||
|
||||
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.
|
||||
|
||||
## Installing Python modules
|
||||
|
||||
With Poetry, installation becomes straightforward:
|
||||
|
||||
```bash
|
||||
poetry install
|
||||
```
|
||||
|
||||
## 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.:
|
||||
|
||||
```bash
|
||||
poetry run manim-slides wizard
|
||||
```
|
||||
|
||||
or enter a new shell that uses this new Python environment:
|
||||
|
||||
```
|
||||
poetry run
|
||||
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.
|
||||
|
||||
## 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!
|
||||
|
||||
Many thanks to you!
|
41
docs/source/index.md
Normal file
41
docs/source/index.md
Normal file
@ -0,0 +1,41 @@
|
||||
---
|
||||
hide-toc: true
|
||||
---
|
||||
|
||||
```{eval-rst}
|
||||
.. image:: _static/logo.png
|
||||
:width: 600px
|
||||
:align: center
|
||||
:alt: Manim Slide logo
|
||||
```
|
||||
|
||||
# Welcome to Manim Slide's documentation
|
||||
|
||||
Manim Slides makes creating slides with Manim super easy!
|
||||
|
||||
In a [very few steps](./quickstart), you can create slides and present them either using the GUI, or your browser.
|
||||
|
||||
|
||||
Slide through the demo below to get a quick glimpse on what you can do with Manin Slides.
|
||||
|
||||
|
||||
<!-- 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>
|
||||
|
||||
|
||||
```{toctree}
|
||||
:hidden:
|
||||
|
||||
quickstart
|
||||
reference/index
|
||||
```
|
||||
|
||||
```{toctree}
|
||||
:caption: Development
|
||||
:hidden:
|
||||
|
||||
contributing/index
|
||||
changelog
|
||||
license
|
||||
```
|
5
docs/source/license.md
Normal file
5
docs/source/license.md
Normal file
@ -0,0 +1,5 @@
|
||||
# License
|
||||
|
||||
|
||||
```{include} ../../LICENSE.md
|
||||
```
|
21
docs/source/quickstart.md
Normal file
21
docs/source/quickstart.md
Normal file
@ -0,0 +1,21 @@
|
||||
# Quickstart
|
||||
|
||||
## Installation
|
||||
|
||||
```{include} ../../README.md
|
||||
:start-after: <!-- start install -->
|
||||
:end-before: <!-- end install -->
|
||||
```
|
||||
|
||||
## Creating your first slides
|
||||
|
||||
```{include} ../../README.md
|
||||
:start-after: <!-- start usage -->
|
||||
:end-before: <!-- end usage -->
|
||||
```
|
||||
|
||||
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>
|
||||
|
||||
For more advanced examples, see the [Examples](reference/examples) section.
|
13
docs/source/reference/api.md
Normal file
13
docs/source/reference/api.md
Normal file
@ -0,0 +1,13 @@
|
||||
# Application Programming Interface
|
||||
|
||||
Manim Slides' API is very limited: it simply consists in two classes, `Slide` and `ThreeDSlide`, which are subclasses of `Scene` and `ThreeDScene` from Manim.
|
||||
|
||||
Thefore, we only document here the methods we think the end-user will ever use, not the methods used internally when rendering.
|
||||
|
||||
```{eval-rst}
|
||||
.. autoclass:: manim_slides.Slide
|
||||
:members: start_loop, end_loop, pause, play
|
||||
|
||||
.. autoclass:: manim_slides.ThreeDSlide
|
||||
:members:
|
||||
```
|
10
docs/source/reference/cli.md
Normal file
10
docs/source/reference/cli.md
Normal file
@ -0,0 +1,10 @@
|
||||
# Command Line Interface
|
||||
|
||||
This page contains an exhaustive list of all the commands available with `manim-slides`.
|
||||
|
||||
|
||||
```{eval-rst}
|
||||
.. click:: manim_slides.__main__:cli
|
||||
:prog: manim-slides
|
||||
:nested: full
|
||||
```
|
80
docs/source/reference/examples.md
Normal file
80
docs/source/reference/examples.md
Normal file
@ -0,0 +1,80 @@
|
||||
# Examples
|
||||
|
||||
Contents of `example.py`.
|
||||
|
||||
Do not forget to import Manim Slides and Manim or ManimGL:
|
||||
|
||||
```python
|
||||
from manim import *
|
||||
from manim_slides import Slide, ThreeDSlide
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```python
|
||||
from manimlib import *
|
||||
from manim_slides import Slide, ThreeDSlide
|
||||
```
|
||||
|
||||
Then, each presentation, named `SCENE`, was generated with those two commands:
|
||||
|
||||
```bash
|
||||
manim example.py SCENE # or manimgl example SCENE
|
||||
manim-slides convert SCENE -ccontrols=true
|
||||
```
|
||||
|
||||
where `-ccontrols=true` indicates that we want to display the blue navigation arrows.
|
||||
|
||||
## Basic Example
|
||||
|
||||
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}
|
||||
.. literalinclude:: ../../../example.py
|
||||
:language: python
|
||||
:linenos:
|
||||
:pyobject: BasicExample
|
||||
```
|
||||
|
||||
## 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>
|
||||
|
||||
### With Manim
|
||||
|
||||
```{eval-rst}
|
||||
.. literalinclude:: ../../../example.py
|
||||
:language: python
|
||||
:linenos:
|
||||
:dedent: 4
|
||||
:start-after: [manim-3d]
|
||||
:end-before: [manim-3d]
|
||||
```
|
||||
|
||||
### With ManimGL
|
||||
|
||||
```{eval-rst}
|
||||
.. literalinclude:: ../../../example.py
|
||||
:language: python
|
||||
:linenos:
|
||||
:dedent: 4
|
||||
:start-after: [manimgl-3d]
|
||||
:end-before: [manimgl-3d]
|
||||
```
|
||||
|
||||
## 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}
|
||||
.. literalinclude:: ../../../example.py
|
||||
:language: python
|
||||
:linenos:
|
||||
:pyobject: ConvertExample
|
||||
```
|
17
docs/source/reference/index.md
Normal file
17
docs/source/reference/index.md
Normal file
@ -0,0 +1,17 @@
|
||||
# Reference Documentation
|
||||
|
||||
Automatically generated reference for Manim Slides.
|
||||
|
||||
```{toctree}
|
||||
:hidden:
|
||||
|
||||
api
|
||||
cli
|
||||
examples
|
||||
```
|
||||
|
||||
[Application Programming Interface](./api): list of classes and methods that may be useful to the end-user.
|
||||
|
||||
[Command Line Interface](./cli): list of all commands available using Manim Slides' executable.
|
||||
|
||||
[Examples](./examples): curated list of examples and their output.
|
335
example.py
335
example.py
@ -1,28 +1,339 @@
|
||||
from manim import *
|
||||
from manim_presentation import Slide
|
||||
# flake8: noqa: F403, F405
|
||||
# type: ignore
|
||||
import sys
|
||||
|
||||
class Example(Slide):
|
||||
if "manim" in sys.modules:
|
||||
from manim import *
|
||||
|
||||
MANIMGL = False
|
||||
elif "manimlib" in sys.modules:
|
||||
from manimlib import *
|
||||
|
||||
MANIMGL = True
|
||||
else:
|
||||
raise ImportError("This script must be run with either `manim` or `manimgl`")
|
||||
|
||||
from manim_slides import Slide, ThreeDSlide
|
||||
|
||||
|
||||
class BasicExample(Slide):
|
||||
def construct(self):
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
dot = Dot()
|
||||
|
||||
self.play(GrowFromCenter(circle))
|
||||
self.pause()
|
||||
self.pause() # Waits user to press continue to go to the next slide
|
||||
|
||||
self.start_loop()
|
||||
self.start_loop() # Start loop
|
||||
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
|
||||
self.end_loop()
|
||||
self.end_loop() # This will loop until user inputs a key
|
||||
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
self.pause() # Waits user to press continue to go to the next slide
|
||||
|
||||
|
||||
class ConvertExample(Slide):
|
||||
"""WARNING: this example does not seem to work with ManimGL."""
|
||||
|
||||
def tinywait(self):
|
||||
self.wait(0.1)
|
||||
|
||||
def construct(self):
|
||||
|
||||
title = VGroup(
|
||||
Text("From Manim animations", t2c={"From": BLUE}),
|
||||
Text("to slides presentation", t2c={"to": BLUE}),
|
||||
Text("with Manim Slides", t2w={"[-12:]": BOLD}, t2c={"[-13:]": YELLOW}),
|
||||
).arrange(DOWN)
|
||||
|
||||
step_1 = Text("1. In your scenes file, import Manim Slides")
|
||||
step_2 = Text("2. Replace Scene with Slide")
|
||||
step_3 = Text("3. In construct, add pauses where you need")
|
||||
step_4 = Text("4. You can also create loops")
|
||||
step_5 = Text("5. Render you scene with Manim")
|
||||
step_6 = Text("6. Open your presentation with Manim Slides")
|
||||
|
||||
for step in [step_1, step_2, step_3, step_4, step_5, step_6]:
|
||||
step.scale(0.5).to_corner(UL)
|
||||
|
||||
step = step_1
|
||||
|
||||
self.play(FadeIn(title))
|
||||
|
||||
self.pause()
|
||||
|
||||
self.play(dot.animate.move_to(RIGHT*3))
|
||||
code = Code(
|
||||
code="""from manim import *
|
||||
|
||||
|
||||
class Example(Scene):
|
||||
def construct(self):
|
||||
dot = Dot()
|
||||
self.add(dot)
|
||||
|
||||
self.play(Indicate(dot, scale_factor=2))
|
||||
|
||||
square = Square()
|
||||
self.play(Transform(dot, square))
|
||||
|
||||
self.play(Rotate(square, angle=PI/2))
|
||||
""",
|
||||
language="python",
|
||||
)
|
||||
|
||||
code_step_1 = Code(
|
||||
code="""from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class Example(Scene):
|
||||
def construct(self):
|
||||
dot = Dot()
|
||||
self.add(dot)
|
||||
|
||||
self.play(Indicate(dot, scale_factor=2))
|
||||
|
||||
square = Square()
|
||||
self.play(Transform(dot, square))
|
||||
|
||||
self.play(Rotate(square, angle=PI/2))
|
||||
""",
|
||||
language="python",
|
||||
)
|
||||
|
||||
code_step_2 = Code(
|
||||
code="""from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class Example(Slide):
|
||||
def construct(self):
|
||||
dot = Dot()
|
||||
self.add(dot)
|
||||
|
||||
self.play(Indicate(dot, scale_factor=2))
|
||||
|
||||
square = Square()
|
||||
self.play(Transform(dot, square))
|
||||
|
||||
self.play(Rotate(square, angle=PI/2))
|
||||
""",
|
||||
language="python",
|
||||
)
|
||||
|
||||
code_step_3 = Code(
|
||||
code="""from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class Example(Slide):
|
||||
def construct(self):
|
||||
dot = Dot()
|
||||
self.add(dot)
|
||||
|
||||
self.play(Indicate(dot, scale_factor=2))
|
||||
self.pause()
|
||||
|
||||
square = Square()
|
||||
self.play(Transform(dot, square))
|
||||
self.pause()
|
||||
self.play(Rotate(square, angle=PI/2))
|
||||
""",
|
||||
language="python",
|
||||
)
|
||||
|
||||
code_step_4 = Code(
|
||||
code="""from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class Example(Slide):
|
||||
def construct(self):
|
||||
dot = Dot()
|
||||
self.add(dot)
|
||||
self.start_loop()
|
||||
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
|
||||
self.play(Indicate(dot, scale_factor=2))
|
||||
self.end_loop()
|
||||
square = Square()
|
||||
self.play(Transform(dot, square))
|
||||
self.pause()
|
||||
self.play(Rotate(square, angle=PI/2))
|
||||
""",
|
||||
language="python",
|
||||
)
|
||||
|
||||
# Each slide MUST end with an animation (a self.wait is considered an animation)
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
|
||||
code_step_5 = Code(
|
||||
code="manim example.py Example",
|
||||
language="console",
|
||||
)
|
||||
|
||||
code_step_6 = Code(
|
||||
code="manim-slides Example",
|
||||
language="console",
|
||||
)
|
||||
|
||||
or_text = Text("or generate HTML presentation").scale(0.5)
|
||||
|
||||
code_step_7 = Code(
|
||||
code="manim-slides convert Example slides.html --open",
|
||||
language="console",
|
||||
).shift(DOWN)
|
||||
|
||||
self.clear()
|
||||
|
||||
self.play(FadeIn(code))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
|
||||
self.play(FadeIn(step, shift=RIGHT))
|
||||
self.play(Transform(code, code_step_1))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
|
||||
self.play(Transform(step, step_2))
|
||||
self.play(Transform(code, code_step_2))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
|
||||
self.play(Transform(step, step_3))
|
||||
self.play(Transform(code, code_step_3))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
|
||||
self.play(Transform(step, step_4))
|
||||
self.play(Transform(code, code_step_4))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
|
||||
self.play(Transform(step, step_5))
|
||||
self.play(Transform(code, code_step_5))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
|
||||
self.play(Transform(step, step_6))
|
||||
self.play(Transform(code, code_step_6))
|
||||
self.play(code.animate.shift(UP), FadeIn(code_step_7), FadeIn(or_text))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
|
||||
watch_text = Text("Watch result on next slides!").shift(2 * DOWN).scale(0.5)
|
||||
|
||||
self.start_loop()
|
||||
self.play(FadeIn(watch_text))
|
||||
self.play(FadeOut(watch_text))
|
||||
self.end_loop()
|
||||
self.clear()
|
||||
|
||||
dot = Dot()
|
||||
self.add(dot)
|
||||
self.start_loop()
|
||||
self.play(Indicate(dot, scale_factor=2))
|
||||
self.end_loop()
|
||||
square = Square()
|
||||
self.play(Transform(dot, square))
|
||||
self.remove(dot)
|
||||
self.add(square)
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
self.play(Rotate(square, angle=PI / 4))
|
||||
self.tinywait()
|
||||
self.pause()
|
||||
|
||||
learn_more_text = (
|
||||
VGroup(
|
||||
Text("Learn more about Manim Slides:"),
|
||||
Text("https://github.com/jeertmans/manim-slides", color=YELLOW),
|
||||
)
|
||||
.arrange(DOWN)
|
||||
.scale(0.75)
|
||||
)
|
||||
|
||||
self.play(Transform(square, learn_more_text))
|
||||
self.tinywait()
|
||||
|
||||
|
||||
# For ThreeDExample, things are different
|
||||
|
||||
if not MANIMGL:
|
||||
|
||||
# [manim-3d]
|
||||
class ThreeDExample(ThreeDSlide):
|
||||
def construct(self):
|
||||
axes = ThreeDAxes()
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
dot = Dot(color=RED)
|
||||
|
||||
self.add(axes)
|
||||
|
||||
self.set_camera_orientation(phi=75 * DEGREES, theta=30 * DEGREES)
|
||||
|
||||
self.play(GrowFromCenter(circle))
|
||||
self.begin_ambient_camera_rotation(rate=75 * DEGREES / 4)
|
||||
|
||||
self.pause()
|
||||
|
||||
self.start_loop()
|
||||
self.play(MoveAlongPath(dot, circle), run_time=4, rate_func=linear)
|
||||
self.end_loop()
|
||||
|
||||
self.stop_ambient_camera_rotation()
|
||||
self.move_camera(phi=75 * DEGREES, theta=30 * DEGREES)
|
||||
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
self.pause()
|
||||
|
||||
self.play(dot.animate.move_to(RIGHT * 3))
|
||||
self.pause()
|
||||
|
||||
self.start_loop()
|
||||
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
|
||||
self.end_loop()
|
||||
|
||||
# Each slide MUST end with an animation (a self.wait is considered an animation)
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
|
||||
# [manim-3d]
|
||||
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,
|
||||
}
|
||||
|
||||
def construct(self):
|
||||
axes = ThreeDAxes()
|
||||
circle = Circle(radius=3, color=BLUE)
|
||||
dot = Dot(color=RED)
|
||||
|
||||
self.add(axes)
|
||||
|
||||
frame = self.camera.frame
|
||||
frame.set_euler_angles(
|
||||
theta=30 * DEGREES,
|
||||
phi=75 * DEGREES,
|
||||
gamma=0,
|
||||
)
|
||||
|
||||
self.play(GrowFromCenter(circle))
|
||||
updater = lambda m, dt: m.increment_theta((75 * DEGREES / 4) * dt)
|
||||
frame.add_updater(updater)
|
||||
|
||||
self.pause()
|
||||
|
||||
self.start_loop()
|
||||
self.play(MoveAlongPath(dot, circle), run_time=4, rate_func=linear)
|
||||
self.end_loop()
|
||||
|
||||
frame.remove_updater(updater)
|
||||
self.play(frame.animate.set_theta(30 * DEGREES))
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
self.pause()
|
||||
|
||||
self.play(dot.animate.move_to(RIGHT * 3))
|
||||
self.pause()
|
||||
|
||||
self.start_loop()
|
||||
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
|
||||
self.end_loop()
|
||||
|
||||
# Each slide MUST end with an animation (a self.wait is considered an animation)
|
||||
self.play(dot.animate.move_to(ORIGIN))
|
||||
|
||||
# [manimgl-3d]
|
||||
|
6
manim-slides.qrc
Normal file
6
manim-slides.qrc
Normal file
@ -0,0 +1,6 @@
|
||||
<!DOCTYPE RCC>
|
||||
<RCC version="1.0">
|
||||
<qresource>
|
||||
<file alias="icon.png">static/icon.png</file>
|
||||
</qresource>
|
||||
</RCC>
|
@ -1 +0,0 @@
|
||||
from manim_presentation.slide import Slide
|
@ -1,299 +0,0 @@
|
||||
import cv2
|
||||
import numpy as np
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
import argparse
|
||||
from enum import Enum
|
||||
import platform
|
||||
|
||||
class Config:
|
||||
@classmethod
|
||||
def init(cls):
|
||||
if platform.system() == "Windows":
|
||||
cls.QUIT_KEY = ord("q")
|
||||
cls.CONTINUE_KEY = 2555904 #right arrow
|
||||
cls.BACK_KEY = 2424832 #left arrow
|
||||
cls.REWIND_KEY = ord("r")
|
||||
cls.PLAYPAUSE_KEY = 32 #spacebar
|
||||
else:
|
||||
cls.QUIT_KEY = ord("q")
|
||||
cls.CONTINUE_KEY = 65363 #right arrow
|
||||
cls.BACK_KEY = 65361 #left arrow
|
||||
cls.REWIND_KEY = ord("r")
|
||||
cls.PLAYPAUSE_KEY = 32 #spacebar
|
||||
|
||||
if os.path.exists(os.path.join(os.getcwd(), "./manim-presentation.json")):
|
||||
json_config = json.load(open(os.path.join(os.getcwd(), "./manim-presentation.json"), "r"))
|
||||
for key, value in json_config.items():
|
||||
setattr(cls, key, value)
|
||||
|
||||
class State(Enum):
|
||||
PLAYING = 0
|
||||
PAUSED = 1
|
||||
WAIT = 2
|
||||
END = 3
|
||||
|
||||
def __str__(self):
|
||||
if self.value == 0: return "Playing"
|
||||
if self.value == 1: return "Paused"
|
||||
if self.value == 2: return "Wait"
|
||||
if self.value == 3: return "End"
|
||||
return "..."
|
||||
|
||||
def now():
|
||||
return round(time.time() * 1000)
|
||||
|
||||
def fix_time(x):
|
||||
return x if x > 0 else 1
|
||||
|
||||
class Presentation:
|
||||
def __init__(self, config, last_frame_next=False):
|
||||
self.last_frame_next = last_frame_next
|
||||
self.slides = config["slides"]
|
||||
self.files = config["files"]
|
||||
|
||||
self.lastframe = []
|
||||
|
||||
self.reset()
|
||||
self.load_files()
|
||||
self.add_last_slide()
|
||||
|
||||
def add_last_slide(self):
|
||||
last_slide_end = self.slides[-1]["end_animation"]
|
||||
last_animation = len(self.files)
|
||||
self.slides.append(dict(
|
||||
start_animation = last_slide_end,
|
||||
end_animation = last_animation,
|
||||
type = "last",
|
||||
number = len(self.slides) + 1,
|
||||
terminated = False
|
||||
))
|
||||
|
||||
|
||||
|
||||
def reset(self):
|
||||
self.current_animation = 0
|
||||
self.current_slide_i = 0
|
||||
self.slides[-1]["terminated"] = False
|
||||
|
||||
def load_files(self):
|
||||
self.caps = list()
|
||||
for f in self.files:
|
||||
self.caps.append(cv2.VideoCapture(f))
|
||||
|
||||
def next(self):
|
||||
if self.current_slide["type"] == "last":
|
||||
self.current_slide["terminated"] = True
|
||||
else:
|
||||
self.current_slide_i = min(len(self.slides) - 1, self.current_slide_i + 1)
|
||||
self.rewind_slide()
|
||||
|
||||
def prev(self):
|
||||
self.current_slide_i = max(0, self.current_slide_i - 1)
|
||||
self.rewind_slide()
|
||||
|
||||
def rewind_slide(self):
|
||||
self.current_animation = self.current_slide["start_animation"]
|
||||
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
|
||||
@property
|
||||
def current_slide(self):
|
||||
return self.slides[self.current_slide_i]
|
||||
|
||||
@property
|
||||
def current_cap(self):
|
||||
return self.caps[self.current_animation]
|
||||
|
||||
@property
|
||||
def fps(self):
|
||||
return self.current_cap.get(cv2.CAP_PROP_FPS)
|
||||
|
||||
# This function updates the state given the previous state.
|
||||
# It does this by reading the video information and checking if the state is still correct.
|
||||
# It returns the frame to show (lastframe) and the new state.
|
||||
def update_state(self, state):
|
||||
if state == State.PAUSED:
|
||||
if len(self.lastframe) == 0:
|
||||
_, self.lastframe = self.current_cap.read()
|
||||
return self.lastframe, state
|
||||
still_playing, frame = self.current_cap.read()
|
||||
if still_playing:
|
||||
self.lastframe = frame
|
||||
elif state in [state.WAIT, state.PAUSED]:
|
||||
return self.lastframe, state
|
||||
elif self.current_slide["type"] == "last" and self.current_slide["terminated"]:
|
||||
return self.lastframe, State.END
|
||||
|
||||
if not still_playing:
|
||||
if self.current_slide["end_animation"] == self.current_animation + 1:
|
||||
if self.current_slide["type"] == "slide":
|
||||
# To fix "it always ends one frame before the animation", uncomment this.
|
||||
# But then clears on the next slide will clear the stationary after this slide.
|
||||
if self.last_frame_next:
|
||||
self.next_cap = self.caps[self.current_animation + 1]
|
||||
self.next_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
_, self.lastframe = self.next_cap.read()
|
||||
state = State.WAIT
|
||||
elif self.current_slide["type"] == "loop":
|
||||
self.current_animation = self.current_slide["start_animation"]
|
||||
state = State.PLAYING
|
||||
self.rewind_slide()
|
||||
elif self.current_slide["type"] == "last":
|
||||
self.current_slide["terminated"] = True
|
||||
elif self.current_slide["type"] == "last" and self.current_slide["end_animation"] == self.current_animation:
|
||||
state = State.WAIT
|
||||
else:
|
||||
# Play next video!
|
||||
self.current_animation += 1
|
||||
# Reset video to position zero if it has been played before
|
||||
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
|
||||
return self.lastframe, state
|
||||
|
||||
|
||||
class Display:
|
||||
def __init__(self, presentations, start_paused=False, fullscreen=False):
|
||||
self.presentations = presentations
|
||||
self.start_paused = start_paused
|
||||
|
||||
self.state = State.PLAYING
|
||||
self.lastframe = None
|
||||
self.current_presentation_i = 0
|
||||
|
||||
self.lag = 0
|
||||
self.last_time = now()
|
||||
|
||||
if fullscreen:
|
||||
cv2.namedWindow("Video", cv2.WND_PROP_FULLSCREEN)
|
||||
cv2.setWindowProperty("Video", cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN)
|
||||
|
||||
@property
|
||||
def current_presentation(self):
|
||||
return self.presentations[self.current_presentation_i]
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
self.lastframe, self.state = self.current_presentation.update_state(self.state)
|
||||
if self.state == State.PLAYING or self.state == State.PAUSED:
|
||||
if self.start_paused:
|
||||
self.state = State.PAUSED
|
||||
self.start_paused = False
|
||||
if self.state == State.END:
|
||||
if self.current_presentation_i == len(self.presentations) - 1:
|
||||
self.quit()
|
||||
else:
|
||||
self.current_presentation_i += 1
|
||||
self.state = State.PLAYING
|
||||
self.handle_key()
|
||||
self.show_video()
|
||||
self.show_info()
|
||||
|
||||
def show_video(self):
|
||||
self.lag = now() - self.last_time
|
||||
self.last_time = now()
|
||||
cv2.imshow("Video", self.lastframe)
|
||||
|
||||
def show_info(self):
|
||||
info = np.zeros((130, 420), np.uint8)
|
||||
font_args = (cv2.FONT_HERSHEY_SIMPLEX, 0.7, 255)
|
||||
grid_x = [30, 230]
|
||||
grid_y = [30, 70, 110]
|
||||
|
||||
cv2.putText(
|
||||
info,
|
||||
f"Animation: {self.current_presentation.current_animation}",
|
||||
(grid_x[0], grid_y[0]),
|
||||
*font_args
|
||||
)
|
||||
cv2.putText(
|
||||
info,
|
||||
f"State: {self.state}",
|
||||
(grid_x[1], grid_y[0]),
|
||||
*font_args
|
||||
)
|
||||
|
||||
cv2.putText(
|
||||
info,
|
||||
f"Slide {self.current_presentation.current_slide['number']}/{len(self.current_presentation.slides)}",
|
||||
(grid_x[0], grid_y[1]),
|
||||
*font_args
|
||||
)
|
||||
cv2.putText(
|
||||
info,
|
||||
f"Slide Type: {self.current_presentation.current_slide['type']}",
|
||||
(grid_x[1], grid_y[1]),
|
||||
*font_args
|
||||
)
|
||||
|
||||
cv2.putText(
|
||||
info,
|
||||
f"Scene {self.current_presentation_i + 1}/{len(self.presentations)}",
|
||||
((grid_x[0]+grid_x[1])//2, grid_y[2]),
|
||||
*font_args
|
||||
)
|
||||
|
||||
cv2.imshow("Info", info)
|
||||
|
||||
def handle_key(self):
|
||||
sleep_time = math.ceil(1000/self.current_presentation.fps)
|
||||
key = cv2.waitKeyEx(fix_time(sleep_time - self.lag))
|
||||
|
||||
if key == Config.QUIT_KEY:
|
||||
self.quit()
|
||||
elif self.state == State.PLAYING and key == Config.PLAYPAUSE_KEY:
|
||||
self.state = State.PAUSED
|
||||
elif self.state == State.PAUSED and key == Config.PLAYPAUSE_KEY:
|
||||
self.state = State.PLAYING
|
||||
elif self.state == State.WAIT and (key == Config.CONTINUE_KEY or key == Config.PLAYPAUSE_KEY):
|
||||
self.current_presentation.next()
|
||||
self.state = State.PLAYING
|
||||
elif self.state == State.PLAYING and key == Config.CONTINUE_KEY:
|
||||
self.current_presentation.next()
|
||||
elif key == Config.BACK_KEY:
|
||||
if self.current_presentation.current_slide_i == 0:
|
||||
self.current_presentation_i = max(0, self.current_presentation_i - 1)
|
||||
self.current_presentation.reset()
|
||||
self.state = State.PLAYING
|
||||
else:
|
||||
self.current_presentation.prev()
|
||||
self.state = State.PLAYING
|
||||
elif key == Config.REWIND_KEY:
|
||||
self.current_presentation.rewind_slide()
|
||||
self.state = State.PLAYING
|
||||
|
||||
|
||||
def quit(self):
|
||||
cv2.destroyAllWindows()
|
||||
sys.exit()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
|
||||
parser.add_argument("scenes", metavar="scenes", type=str, nargs="+", help="Scenes to present")
|
||||
parser.add_argument("--folder", type=str, default="./presentation", help="Presentation files folder")
|
||||
parser.add_argument("--start-paused", action="store_true", help="Start paused")
|
||||
parser.add_argument("--fullscreen", action="store_true", help="Fullscreen")
|
||||
parser.add_argument("--last-frame-next", action="store_true", help="Show the next animation first frame as last frame (hack)")
|
||||
|
||||
args = parser.parse_args()
|
||||
args.folder = os.path.normcase(args.folder)
|
||||
|
||||
Config.init()
|
||||
|
||||
presentations = list()
|
||||
for scene in args.scenes:
|
||||
config_file = os.path.join(args.folder, f"{scene}.json")
|
||||
if not os.path.exists(config_file):
|
||||
raise Exception(f"File {config_file} does not exist, check the scene name and make sure to use Slide as your scene base class")
|
||||
config = json.load(open(config_file))
|
||||
presentations.append(Presentation(config, last_frame_next=args.last_frame_next))
|
||||
|
||||
display = Display(presentations, start_paused=args.start_paused, fullscreen=args.fullscreen)
|
||||
display.run()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
@ -1,83 +0,0 @@
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
from manim import Scene, config
|
||||
from manim.animation.animation import Wait
|
||||
|
||||
class Slide(Scene):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.output_folder = kwargs.pop("output_folder", "./presentation")
|
||||
super(Slide, self).__init__(*args, **kwargs)
|
||||
self.slides = list()
|
||||
self.current_slide = 1
|
||||
self.current_animation = 0
|
||||
self.loop_start_animation = None
|
||||
self.pause_start_animation = 0
|
||||
|
||||
def play(self, *args, **kwargs):
|
||||
super(Slide, self).play(*args, **kwargs)
|
||||
self.current_animation += 1
|
||||
|
||||
def pause(self):
|
||||
self.slides.append(dict(
|
||||
type="slide",
|
||||
start_animation=self.pause_start_animation,
|
||||
end_animation=self.current_animation,
|
||||
number=self.current_slide
|
||||
))
|
||||
self.current_slide += 1
|
||||
self.pause_start_animation = self.current_animation
|
||||
|
||||
def start_loop(self):
|
||||
assert self.loop_start_animation is None, "You cant nest loops"
|
||||
self.loop_start_animation = self.current_animation
|
||||
|
||||
def end_loop(self):
|
||||
assert self.loop_start_animation is not None, "You have to start a loop before ending it"
|
||||
self.slides.append(dict(
|
||||
type="loop",
|
||||
start_animation=self.loop_start_animation,
|
||||
end_animation=self.current_animation,
|
||||
number=self.current_slide
|
||||
))
|
||||
self.current_slide += 1
|
||||
self.loop_start_animation = None
|
||||
self.pause_start_animation = self.current_animation
|
||||
|
||||
def render(self, *args, **kwargs):
|
||||
# We need to disable the caching limit since we rely on intermidiate files
|
||||
max_files_cached = config["max_files_cached"]
|
||||
config["max_files_cached"] = float("inf")
|
||||
|
||||
super(Slide, self).render(*args, **kwargs)
|
||||
|
||||
config["max_files_cached"] = max_files_cached
|
||||
|
||||
if not os.path.exists(self.output_folder):
|
||||
os.mkdir(self.output_folder)
|
||||
|
||||
files_folder = os.path.join(self.output_folder, "files")
|
||||
if not os.path.exists(files_folder):
|
||||
os.mkdir(files_folder)
|
||||
|
||||
scene_name = type(self).__name__
|
||||
scene_files_folder = os.path.join(files_folder, scene_name)
|
||||
|
||||
if os.path.exists(scene_files_folder):
|
||||
shutil.rmtree(scene_files_folder)
|
||||
|
||||
if not os.path.exists(scene_files_folder):
|
||||
os.mkdir(scene_files_folder)
|
||||
|
||||
files = list()
|
||||
for src_file in self.renderer.file_writer.partial_movie_files:
|
||||
dst_file = os.path.join(scene_files_folder, os.path.basename(src_file))
|
||||
shutil.copyfile(src_file, dst_file)
|
||||
files.append(dst_file)
|
||||
|
||||
f = open(os.path.join(self.output_folder, "%s.json" % (scene_name, )), "w")
|
||||
json.dump(dict(
|
||||
slides=self.slides,
|
||||
files=files
|
||||
), f)
|
||||
f.close()
|
3
manim_slides/__init__.py
Normal file
3
manim_slides/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
# flake8: noqa: F401
|
||||
from .__version__ import __version__
|
||||
from .slide import Slide, ThreeDSlide
|
73
manim_slides/__main__.py
Normal file
73
manim_slides/__main__.py
Normal file
@ -0,0 +1,73 @@
|
||||
import json
|
||||
|
||||
import click
|
||||
import requests
|
||||
from click_default_group import DefaultGroup
|
||||
|
||||
from . import __version__
|
||||
from .convert import convert
|
||||
from .manim import logger
|
||||
from .present import list_scenes, present
|
||||
from .wizard import init, wizard
|
||||
|
||||
|
||||
@click.group(cls=DefaultGroup, default="present", default_if_no_args=True)
|
||||
@click.option(
|
||||
"--notify-outdated-version/--silent",
|
||||
" /-S",
|
||||
is_flag=True,
|
||||
default=True,
|
||||
help="Check if a new version of Manim Slides is available.",
|
||||
)
|
||||
@click.version_option(__version__, "-v", "--version")
|
||||
@click.help_option("-h", "--help")
|
||||
def cli(notify_outdated_version: bool) -> None:
|
||||
"""
|
||||
Manim Slides command-line utilities.
|
||||
|
||||
If no command is specified, defaults to `present`.
|
||||
"""
|
||||
# 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.raise_for_status()
|
||||
stable = req_info.json()["info"]["version"]
|
||||
if stable != __version__:
|
||||
click.echo(
|
||||
"You are using Manim Slides version "
|
||||
+ click.style(f"v{__version__}", fg="red")
|
||||
+ ", but version "
|
||||
+ click.style(f"v{stable}", fg="green")
|
||||
+ " is available."
|
||||
)
|
||||
click.echo(
|
||||
"You should consider upgrading via "
|
||||
+ click.style("pip install -U manim-slides", fg="yellow")
|
||||
)
|
||||
except requests.exceptions.HTTPError:
|
||||
logger.debug(f"HTTP Error: {warn_prompt}")
|
||||
except requests.exceptions.ConnectionError:
|
||||
logger.debug(f"Connection Error: {warn_prompt}")
|
||||
except requests.exceptions.Timeout:
|
||||
logger.debug(f"Timed Out: {warn_prompt}")
|
||||
except json.JSONDecodeError:
|
||||
logger.debug(warn_prompt)
|
||||
logger.debug(f"Error decoding JSON from {manim_info_url}")
|
||||
except Exception:
|
||||
logger.debug(f"Something went wrong: {warn_prompt}")
|
||||
|
||||
|
||||
cli.add_command(convert)
|
||||
cli.add_command(init)
|
||||
cli.add_command(list_scenes)
|
||||
cli.add_command(present)
|
||||
cli.add_command(wizard)
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
1
manim_slides/__version__.py
Normal file
1
manim_slides/__version__.py
Normal file
@ -0,0 +1 @@
|
||||
__version__ = "4.8.2"
|
82
manim_slides/commons.py
Normal file
82
manim_slides/commons.py
Normal file
@ -0,0 +1,82 @@
|
||||
from typing import Any, Callable
|
||||
|
||||
import click
|
||||
from click import Context, Parameter
|
||||
|
||||
from .defaults import CONFIG_PATH, FOLDER_PATH
|
||||
from .manim import logger
|
||||
|
||||
F = Callable[..., Any]
|
||||
Wrapper = Callable[[F], F]
|
||||
|
||||
|
||||
def config_path_option(function: F) -> F:
|
||||
"""Wraps a function to add configuration path option."""
|
||||
wrapper: Wrapper = click.option(
|
||||
"-c",
|
||||
"--config",
|
||||
"config_path",
|
||||
metavar="FILE",
|
||||
default=CONFIG_PATH,
|
||||
type=click.Path(dir_okay=False),
|
||||
help="Set path to configuration file.",
|
||||
show_default=True,
|
||||
)
|
||||
return wrapper(function)
|
||||
|
||||
|
||||
def config_options(function: F) -> F:
|
||||
"""Wraps a function to add configuration options."""
|
||||
function = config_path_option(function)
|
||||
function = click.option(
|
||||
"-f", "--force", is_flag=True, help="Overwrite any existing configuration file."
|
||||
)(function)
|
||||
function = click.option(
|
||||
"-m",
|
||||
"--merge",
|
||||
is_flag=True,
|
||||
help="Merge any existing configuration file with the new configuration.",
|
||||
)(function)
|
||||
return function
|
||||
|
||||
|
||||
def verbosity_option(function: F) -> F:
|
||||
"""Wraps a function to add verbosity option."""
|
||||
|
||||
def callback(ctx: Context, param: Parameter, value: bool) -> None:
|
||||
|
||||
if not value or ctx.resilient_parsing:
|
||||
return
|
||||
|
||||
logger.setLevel(value)
|
||||
|
||||
wrapper: Wrapper = click.option(
|
||||
"-v",
|
||||
"--verbosity",
|
||||
type=click.Choice(
|
||||
["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
||||
case_sensitive=False,
|
||||
),
|
||||
help="Verbosity of CLI output",
|
||||
default=None,
|
||||
expose_value=False,
|
||||
envvar="MANIM_SLIDES_VERBOSITY",
|
||||
show_envvar=True,
|
||||
callback=callback,
|
||||
)
|
||||
|
||||
return wrapper(function)
|
||||
|
||||
|
||||
def folder_path_option(function: F) -> F:
|
||||
"""Wraps a function to add folder path option."""
|
||||
wrapper: Wrapper = click.option(
|
||||
"--folder",
|
||||
metavar="DIRECTORY",
|
||||
default=FOLDER_PATH,
|
||||
type=click.Path(exists=True, file_okay=False),
|
||||
help="Set slides folder.",
|
||||
show_default=True,
|
||||
)
|
||||
|
||||
return wrapper(function)
|
253
manim_slides/config.py
Normal file
253
manim_slides/config.py
Normal file
@ -0,0 +1,253 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from enum import Enum
|
||||
from typing import Callable, Dict, List, Optional, Set, Union
|
||||
|
||||
from pydantic import BaseModel, root_validator, validator
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from .manim import FFMPEG_BIN, logger
|
||||
|
||||
|
||||
def merge_basenames(files: List[str]) -> str:
|
||||
"""
|
||||
Merge multiple filenames by concatenating basenames.
|
||||
"""
|
||||
|
||||
dirname = os.path.dirname(files[0])
|
||||
_, ext = os.path.splitext(files[0])
|
||||
|
||||
basename = "_".join(os.path.splitext(os.path.basename(file))[0] for file in files)
|
||||
|
||||
return os.path.join(dirname, basename + ext)
|
||||
|
||||
|
||||
class Key(BaseModel): # type: ignore
|
||||
"""Represents a list of key codes, with optionally a name."""
|
||||
|
||||
ids: Set[int]
|
||||
name: Optional[str] = None
|
||||
|
||||
def set_ids(self, *ids: int) -> None:
|
||||
self.ids = set(ids)
|
||||
|
||||
@validator("ids", each_item=True)
|
||||
def id_is_posint(cls, v: int) -> int:
|
||||
if v < 0:
|
||||
raise ValueError("Key ids cannot be negative integers")
|
||||
return v
|
||||
|
||||
def match(self, key_id: int) -> bool:
|
||||
m = key_id in self.ids
|
||||
|
||||
if m:
|
||||
logger.debug(f"Pressed key: {self.name}")
|
||||
|
||||
return m
|
||||
|
||||
|
||||
class Config(BaseModel): # type: ignore
|
||||
"""General Manim Slides config"""
|
||||
|
||||
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")
|
||||
HIDE_MOUSE: Key = Key(ids=[Qt.Key_H], name="HIDE / SHOW MOUSE")
|
||||
|
||||
@root_validator
|
||||
def ids_are_unique_across_keys(cls, values: Dict[str, Key]) -> Dict[str, Key]:
|
||||
ids: Set[int] = set()
|
||||
|
||||
for key in values.values():
|
||||
if len(ids.intersection(key.ids)) != 0:
|
||||
raise ValueError(
|
||||
"Two or more keys share a common key code: please make sure each key has distinct key codes"
|
||||
)
|
||||
ids.update(key.ids)
|
||||
|
||||
return values
|
||||
|
||||
def merge_with(self, other: "Config") -> "Config":
|
||||
for key_name, key in self:
|
||||
other_key = getattr(other, key_name)
|
||||
key.ids.update(other_key.ids)
|
||||
key.name = other_key.name or key.name
|
||||
|
||||
return self
|
||||
|
||||
|
||||
class SlideType(str, Enum):
|
||||
slide = "slide"
|
||||
loop = "loop"
|
||||
last = "last"
|
||||
|
||||
|
||||
class SlideConfig(BaseModel): # type: ignore
|
||||
type: SlideType
|
||||
start_animation: int
|
||||
end_animation: int
|
||||
number: int
|
||||
terminated: bool = False
|
||||
|
||||
@validator("start_animation", "end_animation")
|
||||
def index_is_posint(cls, v: int) -> int:
|
||||
if v < 0:
|
||||
raise ValueError("Animation index (start or end) cannot be negative")
|
||||
return v
|
||||
|
||||
@validator("number")
|
||||
def number_is_strictly_posint(cls, v: int) -> int:
|
||||
if v <= 0:
|
||||
raise ValueError("Slide number cannot be negative or zero")
|
||||
return v
|
||||
|
||||
@root_validator
|
||||
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:
|
||||
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(...)`."
|
||||
)
|
||||
|
||||
raise ValueError(
|
||||
"Start animation index must be strictly lower than end animation index"
|
||||
)
|
||||
|
||||
return values
|
||||
|
||||
def is_slide(self) -> bool:
|
||||
return self.type == SlideType.slide
|
||||
|
||||
def is_loop(self) -> bool:
|
||||
return self.type == SlideType.loop
|
||||
|
||||
def is_last(self) -> bool:
|
||||
return self.type == SlideType.last
|
||||
|
||||
@property
|
||||
def slides_slice(self) -> slice:
|
||||
return slice(self.start_animation, self.end_animation)
|
||||
|
||||
|
||||
class PresentationConfig(BaseModel): # type: ignore
|
||||
slides: List[SlideConfig]
|
||||
files: List[str]
|
||||
|
||||
@validator("files", pre=True, each_item=True)
|
||||
def is_file_and_exists(cls, v: str) -> str:
|
||||
if not os.path.exists(v):
|
||||
raise ValueError(
|
||||
f"Animation file {v} does not exist. Are you in the right directory?"
|
||||
)
|
||||
|
||||
if not os.path.isfile(v):
|
||||
raise ValueError(f"Animation file {v} is not a file")
|
||||
|
||||
return v
|
||||
|
||||
@root_validator
|
||||
def animation_indices_match_files(
|
||||
cls, values: Dict[str, Union[List[SlideConfig], List[str]]]
|
||||
) -> Dict[str, Union[List[SlideConfig], List[str]]]:
|
||||
files = values.get("files")
|
||||
slides = values.get("slides")
|
||||
|
||||
if files is None or slides is None:
|
||||
return values
|
||||
|
||||
n_files = len(files)
|
||||
|
||||
for slide in slides:
|
||||
if slide.end_animation > n_files: # type: ignore
|
||||
raise ValueError(
|
||||
f"The following slide's contains animations not listed in files {files}: {slide}"
|
||||
)
|
||||
|
||||
return values
|
||||
|
||||
def move_to(self, dest: str, copy: bool = True) -> "PresentationConfig":
|
||||
"""
|
||||
Moves (or copy) the files to a given directory.
|
||||
"""
|
||||
copy_func: Callable[[str, str], None] = shutil.copy
|
||||
move_func: Callable[[str, str], None] = shutil.move
|
||||
move = copy_func if copy else move_func
|
||||
|
||||
n = len(self.files)
|
||||
for i in range(n):
|
||||
file = self.files[i]
|
||||
basename = os.path.basename(file)
|
||||
dest_path = os.path.join(dest, basename)
|
||||
logger.debug(f"Moving / copying {file} to {dest_path}")
|
||||
move(file, dest_path)
|
||||
self.files[i] = dest_path
|
||||
|
||||
return self
|
||||
|
||||
def concat_animations(self, dest: Optional[str] = None) -> "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]
|
||||
|
||||
if len(files) > 1:
|
||||
dest_path = merge_basenames(files)
|
||||
|
||||
f = tempfile.NamedTemporaryFile(mode="w", delete=False)
|
||||
f.writelines(f"file '{os.path.abspath(path)}'\n" for path in files)
|
||||
f.close()
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
f.name,
|
||||
"-c",
|
||||
"copy",
|
||||
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())
|
||||
|
||||
dest_paths.append(dest_path)
|
||||
|
||||
else:
|
||||
dest_paths.append(files[0])
|
||||
|
||||
slide_config.start_animation = i
|
||||
slide_config.end_animation = i + 1
|
||||
|
||||
self.files = dest_paths
|
||||
|
||||
if dest:
|
||||
return self.move_to(dest)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
DEFAULT_CONFIG = Config()
|
469
manim_slides/convert.py
Normal file
469
manim_slides/convert.py
Normal file
@ -0,0 +1,469 @@
|
||||
import os
|
||||
import webbrowser
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Dict, Generator, List, Optional, Type, Union
|
||||
|
||||
import click
|
||||
import pkg_resources
|
||||
from click import Context, Parameter
|
||||
from pydantic import BaseModel, PositiveInt, ValidationError
|
||||
|
||||
from .commons import folder_path_option, verbosity_option
|
||||
from .config import PresentationConfig
|
||||
from .present import get_scenes_presentation_config
|
||||
|
||||
|
||||
def validate_config_option(
|
||||
ctx: Context, param: Parameter, value: Any
|
||||
) -> Dict[str, str]:
|
||||
|
||||
config = {}
|
||||
|
||||
for c_option in value:
|
||||
try:
|
||||
key, value = c_option.split("=")
|
||||
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."
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
class Converter(BaseModel): # type: ignore
|
||||
presentation_configs: List[PresentationConfig] = []
|
||||
assets_dir: str = "{basename}_assets"
|
||||
template: Optional[str] = None
|
||||
|
||||
def convert_to(self, dest: str) -> None:
|
||||
"""Converts self, i.e., a list of presentations, into a given format."""
|
||||
raise NotImplementedError
|
||||
|
||||
def load_template(self) -> str:
|
||||
"""Returns the template as a string.
|
||||
|
||||
An empty string is returned if no template is used."""
|
||||
return ""
|
||||
|
||||
def open(self, file: str) -> bool:
|
||||
"""Opens 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 {
|
||||
"html": RevealJS,
|
||||
}[s]
|
||||
|
||||
|
||||
class Str(str):
|
||||
"""A simple string, but quoted when needed."""
|
||||
|
||||
# This fixes pickling issue on Python 3.8
|
||||
__reduce_ex__ = str.__reduce_ex__
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Ensures that the string is correctly quoted."""
|
||||
if self in ["true", "false", "null"]:
|
||||
return super().__str__()
|
||||
else:
|
||||
return f"'{super().__str__()}'"
|
||||
|
||||
|
||||
Function = str # Basically, anything
|
||||
|
||||
|
||||
class JsTrue(str, Enum):
|
||||
true = "true"
|
||||
|
||||
|
||||
class JsFalse(str, Enum):
|
||||
false = "false"
|
||||
|
||||
|
||||
class JsBool(Str, Enum): # type: ignore
|
||||
true = "true"
|
||||
false = "false"
|
||||
|
||||
|
||||
class JsNull(Str, Enum): # type: ignore
|
||||
null = "null"
|
||||
|
||||
|
||||
class ControlsLayout(Str, Enum): # type: ignore
|
||||
edges = "edges"
|
||||
bottom_right = "bottom-right"
|
||||
|
||||
|
||||
class ControlsBackArrows(Str, Enum): # type: ignore
|
||||
faded = "faded"
|
||||
hidden = "hidden"
|
||||
visibly = "visibly"
|
||||
|
||||
|
||||
class SlideNumber(Str, Enum): # type: ignore
|
||||
true = "true"
|
||||
false = "false"
|
||||
hdotv = "h.v"
|
||||
handv = "h/v"
|
||||
c = "c"
|
||||
candt = "c/t"
|
||||
|
||||
|
||||
class ShowSlideNumber(Str, Enum): # type: ignore
|
||||
all = "all"
|
||||
print = "print"
|
||||
speaker = "speaker"
|
||||
|
||||
|
||||
class KeyboardCondition(Str, Enum): # type: ignore
|
||||
null = "null"
|
||||
focused = "focused"
|
||||
|
||||
|
||||
class NavigationMode(Str, Enum): # type: ignore
|
||||
default = "default"
|
||||
linear = "linear"
|
||||
grid = "grid"
|
||||
|
||||
|
||||
class AutoPlayMedia(Str, Enum): # type: ignore
|
||||
null = "null"
|
||||
true = "true"
|
||||
false = "false"
|
||||
|
||||
|
||||
PreloadIframes = AutoPlayMedia
|
||||
|
||||
|
||||
class AutoAnimateMatcher(Str, Enum): # type: ignore
|
||||
null = "null"
|
||||
|
||||
|
||||
class AutoAnimateEasing(Str, Enum): # type: ignore
|
||||
ease = "ease"
|
||||
|
||||
|
||||
AutoSlide = Union[PositiveInt, JsFalse]
|
||||
|
||||
|
||||
class AutoSlideMethod(Str, Enum): # type: ignore
|
||||
null = "null"
|
||||
|
||||
|
||||
MouseWheel = Union[JsNull, float]
|
||||
|
||||
|
||||
class Transition(Str, Enum): # type: ignore
|
||||
none = "none"
|
||||
fade = "fade"
|
||||
slide = "slide"
|
||||
convex = "convex"
|
||||
concave = "concave"
|
||||
zoom = "zoom"
|
||||
|
||||
|
||||
class TransitionSpeed(Str, Enum): # type: ignore
|
||||
default = "default"
|
||||
fast = "fast"
|
||||
slow = "slow"
|
||||
|
||||
|
||||
BackgroundTransition = Transition
|
||||
|
||||
|
||||
class Display(Str, Enum): # type: ignore
|
||||
block = "block"
|
||||
|
||||
|
||||
class RevealTheme(str, Enum):
|
||||
black = "black"
|
||||
white = "white"
|
||||
league = "league"
|
||||
beige = "beige"
|
||||
sky = "sky"
|
||||
night = "night"
|
||||
serif = "serif"
|
||||
simple = "simple"
|
||||
soralized = "solarized"
|
||||
blood = "blood"
|
||||
moon = "moon"
|
||||
|
||||
|
||||
class RevealJS(Converter):
|
||||
# Presentation size options from RevealJS
|
||||
width: Union[Str, int] = Str("100%")
|
||||
height: Union[Str, int] = Str("100%")
|
||||
margin: float = 0.04
|
||||
min_scale: float = 0.2
|
||||
max_scale: float = 2.0
|
||||
# Configuration options from RevealJS
|
||||
controls: JsBool = JsBool.false
|
||||
controls_tutorial: JsBool = JsBool.true
|
||||
controls_layout: ControlsLayout = ControlsLayout.bottom_right
|
||||
controls_back_arrows: ControlsBackArrows = ControlsBackArrows.faded
|
||||
progress: JsBool = JsBool.false
|
||||
slide_number: SlideNumber = SlideNumber.false
|
||||
show_slide_number: Union[ShowSlideNumber, Function] = ShowSlideNumber.all
|
||||
hash_one_based_index: JsBool = JsBool.false
|
||||
hash: JsBool = JsBool.false
|
||||
respond_to_hash_changes: JsBool = JsBool.false
|
||||
history: JsBool = JsBool.false
|
||||
keyboard: JsBool = JsBool.true
|
||||
keyboard_condition: Union[KeyboardCondition, Function] = KeyboardCondition.null
|
||||
disable_layout: JsBool = JsBool.false
|
||||
overview: JsBool = JsBool.true
|
||||
center: JsBool = JsBool.true
|
||||
touch: JsBool = JsBool.true
|
||||
loop: JsBool = JsBool.false
|
||||
rtl: JsBool = JsBool.false
|
||||
navigation_mode: NavigationMode = NavigationMode.default
|
||||
shuffle: JsBool = JsBool.false
|
||||
fragments: JsBool = JsBool.true
|
||||
fragment_in_url: JsBool = JsBool.true
|
||||
embedded: JsBool = JsBool.false
|
||||
help: JsBool = JsBool.true
|
||||
pause: JsBool = JsBool.true
|
||||
show_notes: JsBool = JsBool.false
|
||||
auto_play_media: AutoPlayMedia = AutoPlayMedia.null
|
||||
preload_iframes: PreloadIframes = PreloadIframes.null
|
||||
auto_animate: JsBool = JsBool.true
|
||||
auto_animate_matcher: Union[AutoAnimateMatcher, Function] = AutoAnimateMatcher.null
|
||||
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_slide: AutoSlide = 0
|
||||
auto_slide_stoppable: JsBool = JsBool.true
|
||||
auto_slide_method: Union[AutoSlideMethod, Function] = AutoSlideMethod.null
|
||||
default_timing: Union[JsNull, int] = JsNull.null
|
||||
mouse_wheel: JsBool = JsBool.false
|
||||
preview_links: JsBool = JsBool.false
|
||||
post_message: JsBool = JsBool.true
|
||||
post_message_events: JsBool = JsBool.false
|
||||
focus_body_on_page_visibility_change: JsBool = JsBool.true
|
||||
transition: Transition = Transition.none
|
||||
transition_speed: TransitionSpeed = TransitionSpeed.default
|
||||
background_transition: BackgroundTransition = BackgroundTransition.none
|
||||
pdf_max_pages_per_slide: Union[int, str] = "Number.POSITIVE_INFINITY"
|
||||
pdf_separate_fragments: JsBool = JsBool.true
|
||||
pdf_page_height_offset: int = -1
|
||||
view_distance: int = 3
|
||||
mobile_view_distance: int = 2
|
||||
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"
|
||||
reveal_theme: RevealTheme = RevealTheme.black
|
||||
title: str = "Manim Slides"
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
extra = "forbid"
|
||||
|
||||
def get_sections_iter(self) -> Generator[str, None, None]:
|
||||
"""Generates a sequence of sections, one per slide, that will be included into the html template."""
|
||||
for presentation_config in self.presentation_configs:
|
||||
for slide_config in presentation_config.slides:
|
||||
file = presentation_config.files[slide_config.start_animation]
|
||||
file = os.path.join(self.assets_dir, os.path.basename(file))
|
||||
|
||||
# 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-video="{file}" data-background-video-muted data-background-video-loop></section>'
|
||||
else:
|
||||
yield f'<section data-background-video="{file}" data-background-video-muted></section>'
|
||||
|
||||
def load_template(self) -> str:
|
||||
"""Returns the RevealJS HTML template as a string."""
|
||||
if isinstance(self.template, str):
|
||||
with open(self.template, "r") as f:
|
||||
return f.read()
|
||||
return pkg_resources.resource_string(
|
||||
__name__, "data/revealjs_template.html"
|
||||
).decode()
|
||||
|
||||
def open(self, file: str) -> bool:
|
||||
return webbrowser.open(file)
|
||||
|
||||
def convert_to(self, dest: str) -> None:
|
||||
"""Converts this configuration into a RevealJS HTML presentation, saved to DEST."""
|
||||
dirname = os.path.dirname(dest)
|
||||
basename, ext = os.path.splitext(os.path.basename(dest))
|
||||
|
||||
self.assets_dir = self.assets_dir.format(
|
||||
dirname=dirname, basename=basename, ext=ext
|
||||
)
|
||||
full_assets_dir = os.path.join(dirname, self.assets_dir)
|
||||
|
||||
os.makedirs(full_assets_dir, exist_ok=True)
|
||||
|
||||
for presentation_config in self.presentation_configs:
|
||||
presentation_config.concat_animations().move_to(full_assets_dir)
|
||||
|
||||
with open(dest, "w") as f:
|
||||
|
||||
sections = "".join(self.get_sections_iter())
|
||||
|
||||
revealjs_template = self.load_template()
|
||||
content = revealjs_template.format(sections=sections, **self.dict())
|
||||
|
||||
f.write(content)
|
||||
|
||||
|
||||
def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Wraps a function to add a `--show-config` option."""
|
||||
|
||||
def callback(ctx: Context, param: Parameter, value: bool) -> None:
|
||||
|
||||
if not value or ctx.resilient_parsing:
|
||||
return
|
||||
|
||||
to = ctx.params.get("to", "html")
|
||||
|
||||
converter = Converter.from_string(to)(presentation_configs=[])
|
||||
for key, value in converter.dict().items():
|
||||
click.echo(f"{key}: {repr(value)}")
|
||||
|
||||
ctx.exit()
|
||||
|
||||
return click.option(
|
||||
"--show-config",
|
||||
is_flag=True,
|
||||
help="Show supported options for given format and exit.",
|
||||
default=None,
|
||||
expose_value=False,
|
||||
show_envvar=True,
|
||||
callback=callback,
|
||||
)(function)
|
||||
|
||||
|
||||
def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Wraps a function to add a `--show-template` option."""
|
||||
|
||||
def callback(ctx: Context, param: Parameter, value: bool) -> None:
|
||||
|
||||
if not value or ctx.resilient_parsing:
|
||||
return
|
||||
|
||||
to = ctx.params.get("to", "html")
|
||||
template = ctx.params.get("template", None)
|
||||
|
||||
converter = Converter.from_string(to)(
|
||||
presentation_configs=[], template=template
|
||||
)
|
||||
click.echo(converter.load_template())
|
||||
|
||||
ctx.exit()
|
||||
|
||||
return click.option(
|
||||
"--show-template",
|
||||
is_flag=True,
|
||||
help="Show the template (currently) used for a given conversion format and exit.",
|
||||
default=None,
|
||||
expose_value=False,
|
||||
show_envvar=True,
|
||||
callback=callback,
|
||||
)(function)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("scenes", nargs=-1)
|
||||
@folder_path_option
|
||||
@click.argument("dest")
|
||||
@click.option(
|
||||
"--to",
|
||||
type=click.Choice(["html"], case_sensitive=False),
|
||||
default="html",
|
||||
show_default=True,
|
||||
help="Set the conversion format to use.",
|
||||
)
|
||||
@click.option(
|
||||
"--open",
|
||||
"open_result",
|
||||
is_flag=True,
|
||||
help="Open the newly created file using the approriate application.",
|
||||
)
|
||||
@click.option("-f", "--force", is_flag=True, help="Overwrite any existing file.")
|
||||
@click.option(
|
||||
"-c",
|
||||
"--config",
|
||||
"config_options",
|
||||
multiple=True,
|
||||
callback=validate_config_option,
|
||||
help="Configuration options passed to the converter. E.g., pass `-cslide_number=true` to display slide numbers.",
|
||||
)
|
||||
@click.option(
|
||||
"--use-template",
|
||||
"template",
|
||||
metavar="FILE",
|
||||
type=click.Path(exists=True, dir_okay=False),
|
||||
help="Use the template given by FILE instead of default one. To echo the default template, use `--show-template`.",
|
||||
)
|
||||
@show_template_option
|
||||
@show_config_options
|
||||
@verbosity_option
|
||||
def convert(
|
||||
scenes: List[str],
|
||||
folder: str,
|
||||
dest: str,
|
||||
to: str,
|
||||
open_result: bool,
|
||||
force: bool,
|
||||
config_options: Dict[str, str],
|
||||
template: Optional[str],
|
||||
) -> None:
|
||||
"""
|
||||
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)(
|
||||
presentation_configs=presentation_configs,
|
||||
template=template,
|
||||
**config_options,
|
||||
)
|
||||
|
||||
converter.convert_to(dest)
|
||||
|
||||
if open_result:
|
||||
converter.open(dest)
|
||||
|
||||
except ValidationError as e:
|
||||
|
||||
errors = e.errors()
|
||||
|
||||
msg = [
|
||||
f"{len(errors)} error(s) occured with configuration options for '{to}', see below."
|
||||
]
|
||||
|
||||
for error in errors:
|
||||
option = error["loc"][0]
|
||||
_msg = error["msg"]
|
||||
msg.append(f"Option '{option}': {_msg}")
|
||||
|
||||
raise click.UsageError("\n".join(msg))
|
291
manim_slides/data/revealjs_template.html
Normal file
291
manim_slides/data/revealjs_template.html
Normal file
@ -0,0 +1,291 @@
|
||||
<!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}
|
||||
|
||||
|
||||
}});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
2
manim_slides/defaults.py
Normal file
2
manim_slides/defaults.py
Normal file
@ -0,0 +1,2 @@
|
||||
FOLDER_PATH: str = "./slides"
|
||||
CONFIG_PATH: str = ".manim-slides.json"
|
81
manim_slides/manim.py
Normal file
81
manim_slides/manim.py
Normal file
@ -0,0 +1,81 @@
|
||||
import os
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from importlib.util import find_spec
|
||||
from typing import Iterator
|
||||
|
||||
__all__ = [
|
||||
"MANIM",
|
||||
"MANIM_PACKAGE_NAME",
|
||||
"MANIM_AVAILABLE",
|
||||
"MANIM_IMPORTED",
|
||||
"MANIMGL",
|
||||
"MANIMGL_PACKAGE_NAME",
|
||||
"MANIMGL_AVAILABLE",
|
||||
"MANIMGL_IMPORTED",
|
||||
"logger",
|
||||
"Scene",
|
||||
"ThreeDScene",
|
||||
"config",
|
||||
"FFMPEG_BIN",
|
||||
]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def suppress_stdout() -> Iterator[None]:
|
||||
with open(os.devnull, "w") as devnull:
|
||||
old_stdout = sys.stdout
|
||||
sys.stdout = devnull
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
|
||||
|
||||
MANIM_PACKAGE_NAME = "manim"
|
||||
MANIM_AVAILABLE = find_spec(MANIM_PACKAGE_NAME) is not None
|
||||
MANIM_IMPORTED = MANIM_PACKAGE_NAME in sys.modules
|
||||
|
||||
MANIMGL_PACKAGE_NAME = "manimlib"
|
||||
MANIMGL_AVAILABLE = find_spec(MANIMGL_PACKAGE_NAME) is not None
|
||||
MANIMGL_IMPORTED = MANIMGL_PACKAGE_NAME in sys.modules
|
||||
|
||||
if MANIM_IMPORTED and MANIMGL_IMPORTED:
|
||||
from manim import logger
|
||||
|
||||
logger.warn(
|
||||
"Both manim and manimgl are installed, therefore `manim-slide` needs to need which one to use. Please only import one of the two modules so that `manim-slide` knows which one to use. Here, manim is used by default"
|
||||
)
|
||||
MANIM = True
|
||||
MANIMGL = False
|
||||
elif MANIM_IMPORTED:
|
||||
MANIM = True
|
||||
MANIMGL = False
|
||||
elif MANIMGL_IMPORTED:
|
||||
MANIM = False
|
||||
MANIMGL = True
|
||||
elif MANIM_AVAILABLE:
|
||||
MANIM = True
|
||||
MANIMGL = False
|
||||
elif MANIMGL_AVAILABLE:
|
||||
MANIM = False
|
||||
MANIMGL = True
|
||||
else:
|
||||
raise ModuleNotFoundError(
|
||||
"Either manim (community) or manimgl (3b1b) package must be installed"
|
||||
)
|
||||
|
||||
|
||||
if MANIMGL:
|
||||
from manimlib import Scene, ThreeDScene, config
|
||||
from manimlib.constants import FFMPEG_BIN
|
||||
from manimlib.logger import log as logger
|
||||
|
||||
else:
|
||||
with suppress_stdout(): # Avoids printing "Manim Community v..."
|
||||
from manim import Scene, ThreeDScene, config, logger
|
||||
|
||||
try: # For manim<v0.16.0.post0
|
||||
from manim.constants import FFMPEG_BIN
|
||||
except ImportError:
|
||||
FFMPEG_BIN = config.ffmpeg_executable
|
861
manim_slides/present.py
Normal file
861
manim_slides/present.py
Normal file
@ -0,0 +1,861 @@
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import time
|
||||
from enum import Enum, IntEnum, auto, unique
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
import click
|
||||
import cv2
|
||||
import numpy as np
|
||||
from pydantic import ValidationError
|
||||
from PySide6.QtCore import Qt, QThread, Signal, Slot
|
||||
from PySide6.QtGui import QCloseEvent, QIcon, QImage, QKeyEvent, QPixmap, QResizeEvent
|
||||
from PySide6.QtWidgets import QApplication, QGridLayout, QLabel, QWidget
|
||||
from tqdm import tqdm
|
||||
|
||||
from .commons import config_path_option, verbosity_option
|
||||
from .config import DEFAULT_CONFIG, Config, PresentationConfig, SlideConfig
|
||||
from .defaults import FOLDER_PATH
|
||||
from .manim import logger
|
||||
from .resources import * # noqa: F401, F403
|
||||
|
||||
os.environ.pop(
|
||||
"QT_QPA_PLATFORM_PLUGIN_PATH", None
|
||||
) # See why here: https://stackoverflow.com/a/67863156
|
||||
|
||||
WINDOW_NAME = "Manim Slides"
|
||||
WINDOW_INFO_NAME = f"{WINDOW_NAME}: Info"
|
||||
WINDOWS = platform.system() == "Windows"
|
||||
|
||||
|
||||
class AspectRatio(Enum):
|
||||
ignore = Qt.IgnoreAspectRatio
|
||||
keep = Qt.KeepAspectRatio
|
||||
auto = "auto"
|
||||
|
||||
|
||||
ASPECT_RATIO_MODES = {
|
||||
"ignore": AspectRatio.ignore,
|
||||
"keep": AspectRatio.keep,
|
||||
"auto": AspectRatio.auto,
|
||||
}
|
||||
|
||||
RESIZE_MODES = {
|
||||
"fast": Qt.FastTransformation,
|
||||
"smooth": Qt.SmoothTransformation,
|
||||
}
|
||||
|
||||
|
||||
@unique
|
||||
class State(IntEnum):
|
||||
"""Represents all possible states of a slide presentation."""
|
||||
|
||||
PLAYING = auto()
|
||||
PAUSED = auto()
|
||||
WAIT = auto()
|
||||
END = auto()
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name.capitalize()
|
||||
|
||||
|
||||
def now() -> float:
|
||||
"""Returns time.time() in seconds."""
|
||||
return time.time()
|
||||
|
||||
|
||||
class Presentation:
|
||||
"""Creates presentation from a configuration object."""
|
||||
|
||||
def __init__(self, config: PresentationConfig) -> None:
|
||||
self.slides: List[SlideConfig] = config.slides
|
||||
self.files: List[str] = config.files
|
||||
|
||||
self.current_slide_index: int = 0
|
||||
self.current_animation: int = self.current_slide.start_animation
|
||||
self.current_file: str = ""
|
||||
|
||||
self.loaded_animation_cap: int = -1
|
||||
self.cap = None # cap = cv2.VideoCapture
|
||||
|
||||
self.reverse: bool = False
|
||||
self.reversed_animation: int = -1
|
||||
|
||||
self.lastframe: Optional[np.ndarray] = None
|
||||
|
||||
self.reset()
|
||||
|
||||
@property
|
||||
def current_slide(self) -> SlideConfig:
|
||||
"""Returns currently playing slide."""
|
||||
return self.slides[self.current_slide_index]
|
||||
|
||||
@property
|
||||
def first_slide(self) -> SlideConfig:
|
||||
"""Returns first slide."""
|
||||
return self.slides[0]
|
||||
|
||||
@property
|
||||
def last_slide(self) -> SlideConfig:
|
||||
"""Returns last slide."""
|
||||
return self.slides[-1]
|
||||
|
||||
def release_cap(self) -> None:
|
||||
"""Releases current Video Capture, if existing."""
|
||||
if self.cap is not None:
|
||||
self.cap.release()
|
||||
|
||||
self.loaded_animation_cap = -1
|
||||
|
||||
def load_animation_cap(self, animation: int) -> None:
|
||||
"""Loads video file of given animation."""
|
||||
# We must load a new VideoCapture file if:
|
||||
if (self.loaded_animation_cap != animation) or (
|
||||
self.reverse and self.reversed_animation != animation
|
||||
): # cap already loaded
|
||||
|
||||
logger.debug(f"Loading new cap for animation #{animation}")
|
||||
|
||||
self.release_cap()
|
||||
|
||||
file: str = self.files[animation]
|
||||
|
||||
if self.reverse:
|
||||
file = "{}_reversed{}".format(*os.path.splitext(file))
|
||||
self.reversed_animation = animation
|
||||
|
||||
self.current_file = file
|
||||
|
||||
self.cap = cv2.VideoCapture(file)
|
||||
self.loaded_animation_cap = animation
|
||||
|
||||
@property
|
||||
def current_cap(self) -> cv2.VideoCapture:
|
||||
"""Returns current VideoCapture object."""
|
||||
self.load_animation_cap(self.current_animation)
|
||||
return self.cap
|
||||
|
||||
def rewind_current_slide(self) -> None:
|
||||
"""Rewinds current slide to first frame."""
|
||||
logger.debug("Rewinding curring slide")
|
||||
if self.reverse:
|
||||
self.current_animation = self.current_slide.end_animation - 1
|
||||
else:
|
||||
self.current_animation = self.current_slide.start_animation
|
||||
|
||||
cap = self.current_cap
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
|
||||
def cancel_reverse(self) -> None:
|
||||
"""Cancels any effet produced by a reversed slide."""
|
||||
if self.reverse:
|
||||
logger.debug("Cancelling effects from previous 'reverse' action'")
|
||||
self.reverse = False
|
||||
self.reversed_animation = -1
|
||||
self.release_cap()
|
||||
|
||||
def reverse_current_slide(self) -> None:
|
||||
"""Reverses current slide."""
|
||||
self.reverse = True
|
||||
self.rewind_current_slide()
|
||||
|
||||
def load_next_slide(self) -> None:
|
||||
"""Loads next slide."""
|
||||
logger.debug("Loading next slide")
|
||||
if self.reverse:
|
||||
self.cancel_reverse()
|
||||
self.rewind_current_slide()
|
||||
elif self.current_slide.is_last():
|
||||
self.current_slide.terminated = True
|
||||
else:
|
||||
self.current_slide_index = min(
|
||||
len(self.slides) - 1, self.current_slide_index + 1
|
||||
)
|
||||
self.rewind_current_slide()
|
||||
|
||||
def load_previous_slide(self) -> None:
|
||||
"""Loads previous slide."""
|
||||
logger.debug("Loading previous slide")
|
||||
self.cancel_reverse()
|
||||
self.current_slide_index = max(0, self.current_slide_index - 1)
|
||||
self.rewind_current_slide()
|
||||
|
||||
@property
|
||||
def fps(self) -> int:
|
||||
"""Returns the number of frames per second of the current video."""
|
||||
fps = self.current_cap.get(cv2.CAP_PROP_FPS)
|
||||
if fps == 0:
|
||||
logger.warn(
|
||||
f"Something is wrong with video file {self.current_file}, as the fps returned by frame {self.current_frame_number} is 0"
|
||||
)
|
||||
return max(fps, 1) # TODO: understand why we sometimes get 0 fps
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Rests current presentation."""
|
||||
self.current_animation = 0
|
||||
self.load_animation_cap(0)
|
||||
self.current_slide_index = 0
|
||||
self.slides[-1].terminated = False
|
||||
|
||||
def load_last_slide(self) -> None:
|
||||
"""Loads last slide."""
|
||||
self.current_slide_index = len(self.slides) - 2
|
||||
assert (
|
||||
self.current_slide_index >= 0
|
||||
), "Slides should be at list of a least two elements"
|
||||
self.current_animation = self.current_slide.start_animation
|
||||
self.load_animation_cap(self.current_animation)
|
||||
self.slides[-1].terminated = False
|
||||
|
||||
@property
|
||||
def next_animation(self) -> int:
|
||||
"""Returns the next animation."""
|
||||
if self.reverse:
|
||||
return self.current_animation - 1
|
||||
else:
|
||||
return self.current_animation + 1
|
||||
|
||||
@property
|
||||
def is_last_animation(self) -> int:
|
||||
"""Returns True if current animation is the last one of current slide."""
|
||||
if self.reverse:
|
||||
return self.current_animation == self.current_slide.start_animation
|
||||
else:
|
||||
return self.next_animation == self.current_slide.end_animation
|
||||
|
||||
@property
|
||||
def current_frame_number(self) -> int:
|
||||
"""Returns current frame number."""
|
||||
return int(self.current_cap.get(cv2.CAP_PROP_POS_FRAMES))
|
||||
|
||||
def update_state(self, state: State) -> Tuple[np.ndarray, State]:
|
||||
"""
|
||||
Updates the current state given the previous one.
|
||||
|
||||
It does this by reading the video information and checking if the state is still correct.
|
||||
It returns the frame to show (lastframe) and the new state.
|
||||
"""
|
||||
if state == State.PAUSED:
|
||||
if self.lastframe is None:
|
||||
_, self.lastframe = self.current_cap.read()
|
||||
return self.lastframe, state
|
||||
still_playing, frame = self.current_cap.read()
|
||||
if still_playing:
|
||||
self.lastframe = frame
|
||||
elif state == state.WAIT or state == state.PAUSED: # type: ignore
|
||||
return self.lastframe, state
|
||||
elif self.current_slide.is_last() and self.current_slide.terminated:
|
||||
return self.lastframe, State.END
|
||||
else: # not still playing
|
||||
if self.is_last_animation:
|
||||
if self.current_slide.is_slide():
|
||||
state = State.WAIT
|
||||
elif self.current_slide.is_loop():
|
||||
if self.reverse:
|
||||
state = State.WAIT
|
||||
else:
|
||||
self.current_animation = self.current_slide.start_animation
|
||||
state = State.PLAYING
|
||||
self.rewind_current_slide()
|
||||
elif self.current_slide.is_last():
|
||||
self.current_slide.terminated = True
|
||||
elif (
|
||||
self.current_slide.is_last()
|
||||
and self.current_slide.end_animation == self.current_animation
|
||||
):
|
||||
state = State.WAIT
|
||||
else:
|
||||
# Play next video!
|
||||
self.current_animation = self.next_animation
|
||||
self.load_animation_cap(self.current_animation)
|
||||
# Reset video to position zero if it has been played before
|
||||
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
|
||||
return self.lastframe, state
|
||||
|
||||
|
||||
class Display(QThread): # type: ignore
|
||||
"""Displays one or more presentations one after each other."""
|
||||
|
||||
change_video_signal = Signal(np.ndarray)
|
||||
change_info_signal = Signal(dict)
|
||||
finished = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
presentations: List[PresentationConfig],
|
||||
config: Config = DEFAULT_CONFIG,
|
||||
start_paused: bool = False,
|
||||
skip_all: bool = False,
|
||||
record_to: Optional[str] = None,
|
||||
exit_after_last_slide: bool = False,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.presentations = presentations
|
||||
self.start_paused = start_paused
|
||||
self.config = config
|
||||
self.skip_all = skip_all
|
||||
self.record_to = record_to
|
||||
self.recordings: List[Tuple[str, int, int]] = []
|
||||
|
||||
self.state = State.PLAYING
|
||||
self.lastframe: Optional[np.ndarray] = None
|
||||
self.current_presentation_index = 0
|
||||
self.run_flag = True
|
||||
|
||||
self.key = -1
|
||||
self.exit_after_last_slide = exit_after_last_slide
|
||||
|
||||
@property
|
||||
def current_presentation(self) -> Presentation:
|
||||
"""Returns the current presentation."""
|
||||
return self.presentations[self.current_presentation_index]
|
||||
|
||||
def run(self) -> None:
|
||||
"""Runs a series of presentations until end or exit."""
|
||||
while self.run_flag:
|
||||
last_time = now()
|
||||
self.lastframe, self.state = self.current_presentation.update_state(
|
||||
self.state
|
||||
)
|
||||
if self.state == State.PLAYING or self.state == State.PAUSED:
|
||||
if self.start_paused:
|
||||
self.state = State.PAUSED
|
||||
self.start_paused = False
|
||||
if self.state == State.END:
|
||||
if self.current_presentation_index == len(self.presentations) - 1:
|
||||
if self.exit_after_last_slide:
|
||||
self.run_flag = False
|
||||
continue
|
||||
else:
|
||||
self.current_presentation_index += 1
|
||||
self.state = State.PLAYING
|
||||
|
||||
self.handle_key()
|
||||
self.show_video()
|
||||
self.show_info()
|
||||
|
||||
lag = now() - last_time
|
||||
sleep_time = 1 / self.current_presentation.fps
|
||||
sleep_time = max(sleep_time - lag, 0)
|
||||
time.sleep(sleep_time)
|
||||
last_time = now()
|
||||
self.current_presentation.release_cap()
|
||||
|
||||
if self.record_to is not None:
|
||||
self.record_movie()
|
||||
|
||||
logger.debug("Closing video thread gracully and exiting")
|
||||
self.finished.emit()
|
||||
|
||||
def record_movie(self) -> None:
|
||||
logger.debug(
|
||||
f"A total of {len(self.recordings)} frames will be saved to {self.record_to}"
|
||||
)
|
||||
file, frame_number, fps = self.recordings[0]
|
||||
|
||||
cap = cv2.VideoCapture(file)
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number - 1)
|
||||
_, frame = cap.read()
|
||||
|
||||
w, h = frame.shape[:2]
|
||||
fourcc = cv2.VideoWriter_fourcc(*"XVID")
|
||||
out = cv2.VideoWriter(self.record_to, fourcc, fps, (h, w))
|
||||
|
||||
out.write(frame)
|
||||
|
||||
for _file, frame_number, _ in tqdm(
|
||||
self.recordings[1:], desc="Creating recording file", leave=False
|
||||
):
|
||||
if file != _file:
|
||||
cap.release()
|
||||
file = _file
|
||||
cap = cv2.VideoCapture(_file)
|
||||
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number - 1)
|
||||
_, frame = cap.read()
|
||||
out.write(frame)
|
||||
|
||||
cap.release()
|
||||
out.release()
|
||||
|
||||
def show_video(self) -> None:
|
||||
"""Shows updated video."""
|
||||
if self.record_to is not None:
|
||||
pres = self.current_presentation
|
||||
self.recordings.append(
|
||||
(pres.current_file, pres.current_frame_number, pres.fps)
|
||||
)
|
||||
|
||||
frame: np.ndarray = self.lastframe
|
||||
self.change_video_signal.emit(frame)
|
||||
|
||||
def show_info(self) -> None:
|
||||
"""Shows updated information about presentations."""
|
||||
self.change_info_signal.emit(
|
||||
{
|
||||
"animation": self.current_presentation.current_animation,
|
||||
"state": self.state,
|
||||
"slide_index": self.current_presentation.current_slide.number,
|
||||
"n_slides": len(self.current_presentation.slides),
|
||||
"type": self.current_presentation.current_slide.type,
|
||||
"scene_index": self.current_presentation_index + 1,
|
||||
"n_scenes": len(self.presentations),
|
||||
}
|
||||
)
|
||||
|
||||
@Slot(int)
|
||||
def set_key(self, key: int) -> None:
|
||||
"""Sets the next key to be handled."""
|
||||
self.key = key
|
||||
|
||||
def handle_key(self) -> None:
|
||||
"""Handles key strokes."""
|
||||
|
||||
key = self.key
|
||||
|
||||
if self.config.QUIT.match(key):
|
||||
self.run_flag = False
|
||||
elif self.state == State.PLAYING and self.config.PLAY_PAUSE.match(key):
|
||||
self.state = State.PAUSED
|
||||
elif self.state == State.PAUSED and self.config.PLAY_PAUSE.match(key):
|
||||
self.state = State.PLAYING
|
||||
elif self.state == State.WAIT and (
|
||||
self.config.CONTINUE.match(key) or self.config.PLAY_PAUSE.match(key)
|
||||
):
|
||||
self.current_presentation.load_next_slide()
|
||||
self.state = State.PLAYING
|
||||
elif (
|
||||
self.state == State.PLAYING and self.config.CONTINUE.match(key)
|
||||
) or self.skip_all:
|
||||
self.current_presentation.load_next_slide()
|
||||
elif self.config.BACK.match(key):
|
||||
if self.current_presentation.current_slide_index == 0:
|
||||
if self.current_presentation_index == 0:
|
||||
self.current_presentation.load_previous_slide()
|
||||
else:
|
||||
self.current_presentation_index -= 1
|
||||
self.current_presentation.load_last_slide()
|
||||
self.state = State.PLAYING
|
||||
else:
|
||||
self.current_presentation.load_previous_slide()
|
||||
self.state = State.PLAYING
|
||||
elif self.config.REVERSE.match(key):
|
||||
self.current_presentation.reverse_current_slide()
|
||||
self.state = State.PLAYING
|
||||
elif self.config.REWIND.match(key):
|
||||
self.current_presentation.cancel_reverse()
|
||||
self.current_presentation.rewind_current_slide()
|
||||
self.state = State.PLAYING
|
||||
|
||||
self.key = -1 # No more key to be handled
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stops current thread, without doing anything after."""
|
||||
self.run_flag = False
|
||||
self.wait()
|
||||
|
||||
|
||||
class Info(QWidget): # type: ignore
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.setWindowTitle(WINDOW_INFO_NAME)
|
||||
|
||||
self.layout = QGridLayout()
|
||||
|
||||
self.setLayout(self.layout)
|
||||
|
||||
self.animationLabel = QLabel()
|
||||
self.stateLabel = QLabel()
|
||||
self.slideLabel = QLabel()
|
||||
self.typeLabel = QLabel()
|
||||
self.sceneLabel = QLabel()
|
||||
|
||||
self.layout.addWidget(self.animationLabel, 0, 0, 1, 2)
|
||||
self.layout.addWidget(self.stateLabel, 1, 0)
|
||||
self.layout.addWidget(self.slideLabel, 1, 1)
|
||||
self.layout.addWidget(self.typeLabel, 2, 0)
|
||||
self.layout.addWidget(self.sceneLabel, 2, 1)
|
||||
|
||||
self.update_info({})
|
||||
|
||||
@Slot(dict)
|
||||
def update_info(self, info: Dict[str, Union[str, int]]) -> None:
|
||||
self.animationLabel.setText("Animation: {}".format(info.get("animation", "na")))
|
||||
self.stateLabel.setText("State: {}".format(info.get("state", "unknown")))
|
||||
self.slideLabel.setText(
|
||||
"Slide: {}/{}".format(
|
||||
info.get("slide_index", "na"), info.get("n_slides", "na")
|
||||
)
|
||||
)
|
||||
self.typeLabel.setText("Slide Type: {}".format(info.get("type", "unknown")))
|
||||
self.sceneLabel.setText(
|
||||
"Scene: {}/{}".format(
|
||||
info.get("scene_index", "na"), info.get("n_scenes", "na")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class InfoThread(QThread): # type: ignore
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.dialog = Info()
|
||||
self.run_flag = True
|
||||
|
||||
def start(self) -> None:
|
||||
super().start()
|
||||
|
||||
self.dialog.show()
|
||||
|
||||
def stop(self) -> None:
|
||||
self.dialog.deleteLater()
|
||||
|
||||
|
||||
class App(QWidget): # type: ignore
|
||||
send_key_signal = Signal(int)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args: Any,
|
||||
config: Config = DEFAULT_CONFIG,
|
||||
fullscreen: bool = False,
|
||||
resolution: Tuple[int, int] = (1980, 1080),
|
||||
hide_mouse: bool = False,
|
||||
aspect_ratio: AspectRatio = AspectRatio.auto,
|
||||
resize_mode: Qt.TransformationMode = Qt.SmoothTransformation,
|
||||
background_color: str = "black",
|
||||
**kwargs: Any,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
self.setWindowTitle(WINDOW_NAME)
|
||||
self.icon = QIcon(":/icon.png")
|
||||
self.setWindowIcon(self.icon)
|
||||
self.display_width, self.display_height = resolution
|
||||
self.aspect_ratio = aspect_ratio
|
||||
self.resize_mode = resize_mode
|
||||
self.hide_mouse = hide_mouse
|
||||
self.config = config
|
||||
if self.hide_mouse:
|
||||
self.setCursor(Qt.BlankCursor)
|
||||
|
||||
self.label = QLabel(self)
|
||||
|
||||
if self.aspect_ratio == AspectRatio.auto:
|
||||
self.label.setScaledContents(True)
|
||||
self.label.setAlignment(Qt.AlignCenter)
|
||||
self.label.resize(self.display_width, self.display_height)
|
||||
self.label.setStyleSheet(f"background-color: {background_color}")
|
||||
|
||||
self.pixmap = QPixmap(self.width(), self.height())
|
||||
self.label.setPixmap(self.pixmap)
|
||||
self.label.setMinimumSize(1, 1)
|
||||
|
||||
# create the video capture thread
|
||||
kwargs["config"] = config
|
||||
self.thread = Display(*args, **kwargs)
|
||||
# create the info dialog
|
||||
self.info = Info()
|
||||
self.info.show()
|
||||
|
||||
# info widget will also listen to key presses
|
||||
self.info.keyPressEvent = self.keyPressEvent
|
||||
|
||||
if fullscreen:
|
||||
self.showFullScreen()
|
||||
|
||||
# connect signals
|
||||
self.thread.change_video_signal.connect(self.update_image)
|
||||
self.thread.change_info_signal.connect(self.info.update_info)
|
||||
self.thread.finished.connect(self.closeAll)
|
||||
self.send_key_signal.connect(self.thread.set_key)
|
||||
|
||||
# start the thread
|
||||
self.thread.start()
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None:
|
||||
|
||||
key = event.key()
|
||||
if self.config.HIDE_MOUSE.match(key):
|
||||
if self.hide_mouse:
|
||||
self.setCursor(Qt.ArrowCursor)
|
||||
self.hide_mouse = False
|
||||
else:
|
||||
self.setCursor(Qt.BlankCursor)
|
||||
self.hide_mouse = True
|
||||
# We send key to be handled by video display
|
||||
self.send_key_signal.emit(key)
|
||||
event.accept()
|
||||
|
||||
def closeAll(self) -> None:
|
||||
logger.debug("Closing all QT windows")
|
||||
self.thread.stop()
|
||||
self.info.deleteLater()
|
||||
self.deleteLater()
|
||||
|
||||
def resizeEvent(self, event: QResizeEvent) -> None:
|
||||
if not self.label.hasScaledContents():
|
||||
self.pixmap = self.pixmap.scaled(
|
||||
self.width(), self.height(), self.aspect_ratio.value, self.resize_mode
|
||||
)
|
||||
self.label.setPixmap(self.pixmap)
|
||||
self.label.resize(self.width(), self.height())
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None:
|
||||
self.closeAll()
|
||||
event.accept()
|
||||
|
||||
@Slot(np.ndarray)
|
||||
def update_image(self, cv_img: np.ndarray) -> None:
|
||||
"""Updates the (image) label with a new opencv image"""
|
||||
h, w, ch = cv_img.shape
|
||||
bytes_per_line = ch * w
|
||||
qt_img = QImage(cv_img.data, w, h, bytes_per_line, QImage.Format_BGR888)
|
||||
|
||||
if not self.label.hasScaledContents() and (
|
||||
w != self.width() or h != self.height()
|
||||
):
|
||||
qt_img = qt_img.scaled(
|
||||
self.width(), self.height(), self.aspect_ratio.value, self.resize_mode
|
||||
)
|
||||
|
||||
self.label.setPixmap(QPixmap.fromImage(qt_img))
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--folder",
|
||||
metavar="DIRECTORY",
|
||||
default=FOLDER_PATH,
|
||||
type=click.Path(exists=True, file_okay=False),
|
||||
help="Set slides folder.",
|
||||
show_default=True,
|
||||
)
|
||||
@click.help_option("-h", "--help")
|
||||
@verbosity_option
|
||||
def list_scenes(folder: str) -> None:
|
||||
"""List available scenes."""
|
||||
|
||||
for i, scene in enumerate(_list_scenes(folder), start=1):
|
||||
click.secho(f"{i}: {scene}", fg="green")
|
||||
|
||||
|
||||
def _list_scenes(folder: str) -> List[str]:
|
||||
"""Lists available scenes in given directory."""
|
||||
scenes = []
|
||||
|
||||
for file in os.listdir(folder):
|
||||
if file.endswith(".json"):
|
||||
filepath = os.path.join(folder, file)
|
||||
try:
|
||||
_ = PresentationConfig.parse_file(filepath)
|
||||
scenes.append(os.path.basename(file)[:-5])
|
||||
except Exception as e: # Could not parse this file as a proper presentation config
|
||||
logger.warn(
|
||||
f"Something went wrong with parsing presentation config `{filepath}`: {e}"
|
||||
)
|
||||
pass
|
||||
|
||||
logger.debug(f"Found {len(scenes)} valid scene configuration files in `{folder}`.")
|
||||
|
||||
return scenes
|
||||
|
||||
|
||||
def prompt_for_scenes(folder: str) -> List[str]:
|
||||
"""Prompts the user to select scenes within a given folder."""
|
||||
|
||||
scene_choices = dict(enumerate(_list_scenes(folder), start=1))
|
||||
|
||||
for i, scene in scene_choices.items():
|
||||
click.secho(f"{i}: {scene}", fg="green")
|
||||
|
||||
click.echo()
|
||||
|
||||
click.echo("Choose number corresponding to desired scene/arguments.")
|
||||
click.echo("(Use comma separated list for multiple entries)")
|
||||
|
||||
def value_proc(value: Optional[str]) -> List[str]:
|
||||
indices = list(map(int, (value or "").strip().replace(" ", "").split(",")))
|
||||
|
||||
if not all(0 < i <= len(scene_choices) for i in indices):
|
||||
raise click.UsageError("Please only enter numbers displayed on the screen.")
|
||||
|
||||
return [scene_choices[i] for i in indices]
|
||||
|
||||
if len(scene_choices) == 0:
|
||||
raise click.UsageError(
|
||||
"No scenes were found, are you in the correct directory?"
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
scenes = click.prompt("Choice(s)", value_proc=value_proc)
|
||||
return scenes
|
||||
except ValueError as e:
|
||||
raise click.UsageError(str(e))
|
||||
|
||||
|
||||
def get_scenes_presentation_config(
|
||||
scenes: List[str], folder: str
|
||||
) -> List[PresentationConfig]:
|
||||
"""Returns a list of presentation configurations based on the user input."""
|
||||
|
||||
if len(scenes) == 0:
|
||||
scenes = prompt_for_scenes(folder)
|
||||
|
||||
presentation_configs = []
|
||||
for scene in scenes:
|
||||
config_file = os.path.join(folder, f"{scene}.json")
|
||||
if not os.path.exists(config_file):
|
||||
raise click.UsageError(
|
||||
f"File {config_file} does not exist, check the scene name and make sure to use Slide as your scene base class"
|
||||
)
|
||||
try:
|
||||
presentation_configs.append(PresentationConfig.parse_file(config_file))
|
||||
except ValidationError as e:
|
||||
raise click.UsageError(str(e))
|
||||
|
||||
return presentation_configs
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("scenes", nargs=-1)
|
||||
@config_path_option
|
||||
@click.option(
|
||||
"--folder",
|
||||
metavar="DIRECTORY",
|
||||
default=FOLDER_PATH,
|
||||
type=click.Path(exists=True, file_okay=False),
|
||||
help="Set slides folder.",
|
||||
show_default=True,
|
||||
)
|
||||
@click.option("--start-paused", is_flag=True, help="Start paused.")
|
||||
@click.option("--fullscreen", is_flag=True, help="Fullscreen mode.")
|
||||
@click.option(
|
||||
"-s",
|
||||
"--skip-all",
|
||||
is_flag=True,
|
||||
help="Skip all slides, useful the test if slides are working. Automatically sets `--skip-after-last-slide` to True.",
|
||||
)
|
||||
@click.option(
|
||||
"-r",
|
||||
"--resolution",
|
||||
metavar="<WIDTH HEIGHT>",
|
||||
type=(int, int),
|
||||
default=(1920, 1080),
|
||||
help="Window resolution WIDTH HEIGHT used if fullscreen is not set. You may manually resize the window afterward.",
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
"--to",
|
||||
"--record-to",
|
||||
"record_to",
|
||||
metavar="FILE",
|
||||
type=click.Path(dir_okay=False),
|
||||
default=None,
|
||||
help="If set, the presentation will be recorded into a AVI video file with given name.",
|
||||
)
|
||||
@click.option(
|
||||
"--exit-after-last-slide",
|
||||
is_flag=True,
|
||||
help="At the end of last slide, the application will be exited.",
|
||||
)
|
||||
@click.option(
|
||||
"--hide-mouse",
|
||||
is_flag=True,
|
||||
help="Hide mouse cursor.",
|
||||
)
|
||||
@click.option(
|
||||
"--aspect-ratio",
|
||||
type=click.Choice(ASPECT_RATIO_MODES.keys(), case_sensitive=False),
|
||||
default="auto",
|
||||
help="Set the aspect ratio mode to be used when rescaling video. `'auto'` option is equivalent to `'ignore'`, but can be much faster due to not calling `scaled()` method on every frame.",
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
"--resize-mode",
|
||||
type=click.Choice(RESIZE_MODES.keys(), case_sensitive=False),
|
||||
default="smooth",
|
||||
help="Set the resize (i.e., transformation) mode to be used when rescaling video.",
|
||||
show_default=True,
|
||||
)
|
||||
@click.option(
|
||||
"--background-color",
|
||||
"--bgcolor",
|
||||
"background_color",
|
||||
metavar="COLOR",
|
||||
type=str,
|
||||
default="black",
|
||||
help='Set the background color for borders when using "keep" resize mode. Can be any valid CSS color, e.g., "green", "#FF6500" or "rgba(255, 255, 0, .5)".',
|
||||
show_default=True,
|
||||
)
|
||||
@click.help_option("-h", "--help")
|
||||
@verbosity_option
|
||||
def present(
|
||||
scenes: List[str],
|
||||
config_path: str,
|
||||
folder: str,
|
||||
start_paused: bool,
|
||||
fullscreen: bool,
|
||||
skip_all: bool,
|
||||
resolution: Tuple[int, int],
|
||||
record_to: Optional[str],
|
||||
exit_after_last_slide: bool,
|
||||
hide_mouse: bool,
|
||||
aspect_ratio: str,
|
||||
resize_mode: str,
|
||||
background_color: str,
|
||||
) -> 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
|
||||
|
||||
presentations = [
|
||||
Presentation(presentation_config)
|
||||
for presentation_config in get_scenes_presentation_config(scenes, folder)
|
||||
]
|
||||
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
config = Config.parse_file(config_path)
|
||||
except ValidationError as e:
|
||||
raise click.UsageError(str(e))
|
||||
else:
|
||||
logger.debug("No configuration file found, using default configuration.")
|
||||
config = Config()
|
||||
|
||||
if record_to is not None:
|
||||
_, ext = os.path.splitext(record_to)
|
||||
if ext.lower() != ".avi":
|
||||
raise click.UsageError(
|
||||
"Recording only support '.avi' extension. For other video formats, please convert the resulting '.avi' file afterwards."
|
||||
)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Manim Slides")
|
||||
a = App(
|
||||
presentations,
|
||||
config=config,
|
||||
start_paused=start_paused,
|
||||
fullscreen=fullscreen,
|
||||
skip_all=skip_all,
|
||||
resolution=resolution,
|
||||
record_to=record_to,
|
||||
exit_after_last_slide=exit_after_last_slide,
|
||||
hide_mouse=hide_mouse,
|
||||
aspect_ratio=ASPECT_RATIO_MODES[aspect_ratio],
|
||||
resize_mode=RESIZE_MODES[resize_mode],
|
||||
background_color=background_color,
|
||||
)
|
||||
a.show()
|
||||
sys.exit(app.exec_())
|
171
manim_slides/resources.py
Normal file
171
manim_slides/resources.py
Normal file
@ -0,0 +1,171 @@
|
||||
# type: ignore
|
||||
# Resource object code (Python 3)
|
||||
# Created by: object code
|
||||
# Created by: The Resource Compiler for Qt version 6.4.0
|
||||
# WARNING! All changes made in this file will be lost!
|
||||
|
||||
from PySide6 import QtCore
|
||||
|
||||
qt_resource_data = b"\
|
||||
\x00\x00\x08\x1c\
|
||||
\x89\
|
||||
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
|
||||
\x00\x01\x00\x00\x00\x01\x00\x08\x06\x00\x00\x00\x5cr\xa8f\
|
||||
\x00\x00\x01\x84iCCPICC prof\
|
||||
ile\x00\x00(\x91}\x91=H\xc3@\x1c\xc5_\
|
||||
S\xa5R+\x0e\xed \xe2\x90\xa1:Y\x10-\xe2\xa8\
|
||||
U(B\x85P+\xb4\xea`r\xfd\x84&\x0dI\x8a\
|
||||
\x8b\xa3\xe0Zp\xf0c\xb1\xea\xe0\xe2\xac\xab\x83\xab \
|
||||
\x08~\x808:9)\xbaH\x89\xffK\x0a-b<\
|
||||
8\xee\xc7\xbb{\x8f\xbbw\x80\xd0\xac2\xd5\xec\x99\x00\
|
||||
T\xcd2\xd2\xc9\x84\x98\xcd\xad\x8a\x81W\x04\x11F?\
|
||||
\xe2\x88\xcb\xcc\xd4\xe7$)\x05\xcf\xf1u\x0f\x1f_\xef\
|
||||
b<\xcb\xfb\xdc\x9fc _0\x19\xe0\x13\x89g\x99\
|
||||
nX\xc4\x1b\xc4\xd3\x9b\x96\xcey\x9f8\xc2\xcar\x9e\
|
||||
\xf8\x9cx\xdc\xa0\x0b\x12?r]q\xf9\x8ds\xc9a\
|
||||
\x81gF\x8cLz\x9e8B,\x96\xbaX\xe9bV\
|
||||
6T\xe28q4\xafj\x94/d]\xces\xde\xe2\
|
||||
\xacV\xeb\xac}O\xfe\xc2PA[Y\xe6:\xcd\x11\
|
||||
$\xb1\x88%H\x10\xa1\xa0\x8e\x0a\xaa\xb0\x10\xa3U#\
|
||||
\xc5D\x9a\xf6\x13\x1e\xfea\xc7/\x91K!W\x05\x8c\
|
||||
\x1c\x0b\xa8A\x85\xec\xf8\xc1\xff\xe0w\xb7fqj\xd2\
|
||||
M\x0a%\x80\xde\x17\xdb\xfe\x18\x05\x02\xbb@\xaba\xdb\
|
||||
\xdf\xc7\xb6\xdd:\x01\xfc\xcf\xc0\x95\xd6\xf1\xd7\x9a\xc0\xcc\
|
||||
'\xe9\x8d\x8e\x16=\x02\x06\xb7\x81\x8b\xeb\x8e\xa6\xec\x01\
|
||||
\x97;\xc0\xd0\x93.\x1b\xb2#\xf9i\x0a\xc5\x22\xf0~\
|
||||
F\xdf\x94\x03\xc2\xb7@p\xcd\xed\xad\xbd\x8f\xd3\x07 \
|
||||
C]\xa5n\x80\x83C`\xacD\xd9\xeb\x1e\xef\xee\xeb\
|
||||
\xee\xed\xdf3\xed\xfe~\x00\xd6\xd3r\xcf+\xa2\xc1_\
|
||||
\x00\x00\x00\x06bKGD\x004\x004\x004\xaf4\
|
||||
\x1c\xc0\x00\x00\x00\x09pHYs\x00\x00.#\x00\x00\
|
||||
.#\x01x\xa5?v\x00\x00\x00\x07tIME\x07\
|
||||
\xe6\x0a\x13\x0c\x0f\x03\x13^\x06\xfe\x00\x00\x00\x19tE\
|
||||
XtComment\x00Create\
|
||||
d with GIMPW\x81\x0e\x17\x00\
|
||||
\x00\x05\xf4IDATx\xda\xed\xddA\x92\x9b:\x18\
|
||||
\x85QK\xe5%\xc1\xfe\x17\x00{rF\x19\xa4\xcb\xee\
|
||||
\x801X\xd2=g\x98z\x956 }\xfe\xd5\xddy\
|
||||
\xbe\xdd\x00\x00\x00\x00\x00\x801\x95\xab\xbe\xd04M\x0f\
|
||||
\xb7\x1b\xb6[\xd7\xb5t\x1d\x00\x9b\x1e\xda\x8eA\xb1\xf1\
|
||||
!7\x04\xc5\xc6\x87\xdc\x10\x14\x1b\x1fr#Pl~\
|
||||
\xc8\x0dA\xb1\xf9!7\x02\xc5\xe6\x87\xdc\x08\x14\x9b\x1f\
|
||||
r#Pl~\xc8\x8d@\xb1\xf9!7\x02\xc5\xe6\x87\
|
||||
\xdc\x08T\x9b\x1frU\xb7\x00\xc6\xb4\xe5\x8d\xbb|\xe2\
|
||||
/\x01\xfa<\x0aT\x9b\x1f\x1c\x01\x80\xc0\xa3@\xf5\xee\
|
||||
\x0f&\x00 p\x0a\xa8\xde\xfd\xc1\x04\x00\x04N\x01\x02\
|
||||
\x00&\x00@\x00\x9c\xff!\xca\xbd\x85\x17\xb1,\x8b'\
|
||||
A\x9cy\x9e\xb3\x03`\xe3\x93\xec\xef\xfa\xfff\x08\xaa\
|
||||
\xcd\x0f\xb9\x13p\xb5\xf9!7\x02~\x0a\x00\xc1.\x0f\
|
||||
\x80w\x7fhgo\x98\x00\xc0\x04\x00\x08\x00 \x00\x80\
|
||||
\x00\x00\x02\x00\x08\x00 \x00\x80\x00\x00\x02\x00\x08\x00 \
|
||||
\x00\x80\x00\x00\x02\x00\x08\x00 \x00\x80\x00\x00\x02\x00\x08\
|
||||
\x00 \x00\x80\x00\x00\x02\x00\x08\x00 \x00\x80\x00\x00\x02\
|
||||
\x00|\xd2\xbd\xb7\x17\xfc\xdbG)\x8f\xfc\xb1c-^\
|
||||
\xb7g\xd1\xffu\x97\x9f\x7f0M\xd3\xe3\xcc/x\xe4\
|
||||
\x06m\xfd\x1c\xf5\x91\x16_\x8b\xd7\xbc\xe7\xf3\xec\x13\x9f\
|
||||
\xc5\x91\xeb\xde\xf35\xde\xb1\xaek\xe9\xf2\x08\xb0\xe7\xc6\
|
||||
\x9c}\x13\x93\xafy\xef\xd7I|\x16=]w\x1d\xf1\
|
||||
\xe6\x8f\xb0\xf0Z\xbc\xe6w\xff\xfe\xc4g\xd1\x8b\xea\xe6\
|
||||
\xb7w\xbdG\xae\xb9\xd5\xfb\x95\xf6\x1c{\xb9\xe6\xea\x01\
|
||||
x\xbd\xae/\xf7\x99\xf81\xa0\x85v\xe9\xeb;:\xe1\
|
||||
\x006\xbfkF\x00l\x04\xd7\x8e\x00t\xbc\xf8m\
|
||||
\x00\x11\x10\x00\x8b\xde\xfd\x10C\x01\xb0\xf9qo\x04\xc0\
|
||||
\x02w\x8f\xdc\x04\x01\xb0\xb0\xdd+\x04\xc0\xf9V\x04\x10\
|
||||
\x00\x8bX<\x11\x00\x9b\xdf\xbdD\x00,X\xf7\x14\x01\
|
||||
\xb0P\xdd[\x04\xc0yU\x04\x10\x00\x8bRl\x11\x00\
|
||||
\x9b\xdf\xbdG\x00,@\xcf\x00\x01\xb0\xf0<\x0b\x04\xc0\
|
||||
\xf9S\x04\x10\x00\x8b\xcc\xf3A\x00,.\x13\x1a\x02`\
|
||||
\xf3{f\x08\x80\x85\xe4\xd9\x09\x00FI\x11\x10\x00,\
|
||||
\x1a1\x17\x00\x8b\x05\xcfV\x00,\x10<c\x01\xb00\
|
||||
\xf0\xac\x05\xc0\xf9\x10\x11\x10\x00\x8b\x00\xf1\x17\x00\x9b\x1f\
|
||||
kA\x00<p\xac\x09\x01\xf0\xa0\xb16\x04\xc0y\x0f\
|
||||
\x11\x10\x00\x0f\x15\xeb\xa5\x1bw\x0f\x13r\xd7\x8d_\x04\
|
||||
\x82`\x02\x00\x02\x00\x08\x00 \x00\x80\x00\x00\x02\x00\x08\
|
||||
\x00 \x00\x80\x00\x00\x02\xd0\xbeeY<e\xac\x9d\xe4\
|
||||
\x09@\x04\xb0f\xc2\x8f\x00\xcb\xb2\x08\x016\x7f\xfa\xf7\
|
||||
\x00D\x00o\x12\xc1\x01\x10\x01\xac\x89\xf0\x00\x88\x00\xd6\
|
||||
Bx\x00D\x00k\xc0\xef\x01\xf8\xe6\xa0\xcd/\x00X\
|
||||
\x0c\x9e\xb7\x00X\x14\x16\x85\x89O\x00,\x10<[\x01\
|
||||
\xb0P\xf0L\x05\xc0\xa8\x88\xcd/\x00\x16\x0f\x9e\x9f\x00\
|
||||
XD\x98\xe0\x04@\x04\xf0\xac\x04\xc0\xc2\xc23\x12\x00\
|
||||
\xa3%6\xbf\x00Xlx\x1e\x02`\xd1a\x22\x13\x00\
|
||||
\x11\xc0\xbd\x17\x00\x0b\x11\xf7\x5c\x00\x8c\xa2\xd8\xfc\x02`\
|
||||
q\xe2\xfe\x0a\x80E\x8a\x09K\x00D\x00\xf7R\x00,\
|
||||
\x5c\xdcC\x010\xbab\xf3\x0b\x80\xc5\x8c\xfb%\x00\x16\
|
||||
\xb5\xfb\x84\x00X\xdc\x8eK\x08\x80\x08\xb8'\x08\x80w\
|
||||
;\x9b\x1f\x01\xb0\xf8]?\x02`\x13\x98\x80\x10\x00\x11\
|
||||
p\xad\x08\xc0\x08\x0b\xad\xe5\xd7\xfb\xa9\xd7f\xf3\x0b\x80\
|
||||
\x05s\xe2h\x9c\x10\x11\xaf9l\x02x\xe7!\xf4\xfe\
|
||||
\xe0Z\xbc\xe6Q\xc3t\xd6k\xef\xe1\x9a\xeb\x88\x0fa\
|
||||
\x94j\xb7x\xcd{\xbfN\xea7\xfbz\xb9\xe6:\xd2\
|
||||
M\x1dq\xb1m\xb9\xa6\xab\xafy\xeb\xd7\x1b\xe9Y\xec\
|
||||
Y[=]w\xf9\xf9\x07\xd34=\x94\x91\xad\xe6y\
|
||||
\x8e|\xc6g]\xf7\xb3\xbf\xf7\x93\xd6u\xfdg\xcf\xdf\
|
||||
-a\x04=\xf7\xba\xfd\x1e\x00\x04\x13\x00\x10\x00@\x00\
|
||||
\x00\x01\x00\x04\x00\x10\x00@\x00\x00\x01\x00\x04\x00\x10\x00\
|
||||
@\x00\x00\x01\x00\x04\x00\x10\x00@\x00\x00\x01\x00\x04\x00\
|
||||
\x10\x00@\x00\x00\x01\x00\x04\x00\x18=\x00g\xff\x7f\xcf\
|
||||
\xa1W\xdf\xd8\x1b5\xe5B\xc1\xe6o\xe8\x08 \x02\xf0\
|
||||
\xfd\xbdPS/\x1c\xd27\xff\xed\xf6\xe4\xb3\x01o\xb7\
|
||||
\xf3?\x1f\xf0\x15\x9f\x1b\x88M\x7f\xae\xa6?\x1b\xd0D\
|
||||
0V<=\xcf\xf6\xf91\xe0@\xef*6\x1c\x02 \
|
||||
\x04n\x02\x9b\xc6\xff\x97\x01x\xf6\x1f\x22\x02\x98\x00p\
|
||||
$ !\x00\xa6\x00\xd3\x00c\x8f\xff&\x00\x11 \xdc\
|
||||
\x7f\xdf\xe5\xbf\xf5;\x01\x9c\xe3\xca\x1f\x17\x0aO\xdb\xef\
|
||||
\xfe\x9b&\x00G\x01\xd3\x00cn~G\x00\x11\xc0\x11\
|
||||
`\x1bG\x01G\x02\xa1\x19\xeb\xdd\x7f\xd7\x04\xe0(`\
|
||||
\x1a`\xac\xcd\xbf\xfb\x08 \x02\x22\xc08\x9b\x7f\xd7\x11\
|
||||
\xc0q\xc0\x91@X\xc6\xda\xfc\xbb'\x00\x93\x80i\x80\
|
||||
q6\xff\xdb\x01\x10\x01\x11\xa0\xff\xcd\xff\xf6\x11\xc0q\
|
||||
\xc0\x91@H\xfa\xdf\xfc\x87&\x00\x93\x80i\x80\xbe7\
|
||||
\xffG&\x00\xd3\x80I@<\xfa\xdb\xf8\xa7\x04@\x08\
|
||||
\x84@\x00\xfa\xd8\xf8\xa7\x06@\x08D@\x00\xda\xde\xf8\
|
||||
\x97\x04@\x08\xb2# \x00\xedn\xfcK\x03 \x08\x99\
|
||||
\x11\x10\x80\xf66|\x13\x01`\xach\xbe\x0a\xc1\xd9\x01\
|
||||
\xf0\x13\xa8\xe3\xfcs`\xef\x0c\x87y\xa7\x17\x00D@\
|
||||
\x08\x04\x80+\x22`\x1a@\x00\x84@\x048\xcc7Q\
|
||||
:\x97\xfc\x13\x15\xdf\x044\x01\x98\x04l\x02L\x00$\
|
||||
N\x03\xe2g\x02\xc0\x86@\x00\x10\x01\x1c\x01\x88:\x12\
|
||||
\x88\x9d\x09\x00\x1b\x04\x01@\x04p\x04 \xeaH n\
|
||||
&\x00l\x18\x04\x00\x11\xc0\x11\x80\xa8#\x81\x98\x99\x00\
|
||||
\xb0\x81\x10\x00D\x00G\x00\xa2\x8e\x04\xe2e\x02\xc0\x86\
|
||||
B\x00\x10\x01\x1c\x01\x88:\x12\x88\x95\x09\x00\x1b\x0c\x01\
|
||||
@\x04p\x04 \xeaH N&\x00l8\x04\x00\x11\
|
||||
\xc0\x11\x80\xa8#\x81\x18\x99\x00\xb0\x01\x11\x00D\x00G\
|
||||
\x00\xa2\x8e\x04\xe2c\x02\xc04\x80\x09\x80\xc4i@p\
|
||||
L\x00\x98\x06\x10\x00D\x00\x01@\x04\xf0=\x002\xbe\
|
||||
/ .&\x00L\x03\x08\x00\x22\x80#\x00QG\x02\
|
||||
11\x01`\x1a@\x00\x10\x01\x1c\x01\x88:\x12\x88\x87\
|
||||
\x00\x10\x1a\x01\x9b_\x00\x08\x8d\x80\xcd\x0fA1\xe8\xfd\
|
||||
S\x8c\x01\x00\xda\xf2\x07\xc0\xb4\x09d\x9d\x1fRw\x00\
|
||||
\x00\x00\x00IEND\xaeB`\x82\
|
||||
"
|
||||
|
||||
qt_resource_name = b"\
|
||||
\x00\x08\
|
||||
\x0aaZ\xa7\
|
||||
\x00i\
|
||||
\x00c\x00o\x00n\x00.\x00p\x00n\x00g\
|
||||
"
|
||||
|
||||
qt_resource_struct = b"\
|
||||
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\
|
||||
\x00\x00\x00\x00\x00\x00\x00\x00\
|
||||
\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
|
||||
\x00\x00\x01\x847\x9eu\x9f\
|
||||
"
|
||||
|
||||
|
||||
def qInitResources():
|
||||
QtCore.qRegisterResourceData(
|
||||
0x03, qt_resource_struct, qt_resource_name, qt_resource_data
|
||||
)
|
||||
|
||||
|
||||
def qCleanupResources():
|
||||
QtCore.qUnregisterResourceData(
|
||||
0x03, qt_resource_struct, qt_resource_name, qt_resource_data
|
||||
)
|
||||
|
||||
|
||||
qInitResources()
|
237
manim_slides/slide.py
Normal file
237
manim_slides/slide.py
Normal file
@ -0,0 +1,237 @@
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
from .config import PresentationConfig, SlideConfig, SlideType
|
||||
from .defaults import FOLDER_PATH
|
||||
from .manim import FFMPEG_BIN, MANIMGL, Scene, ThreeDScene, config, logger
|
||||
|
||||
|
||||
def reverse_video_file(src: str, dst: str) -> None:
|
||||
"""Reverses a video file, writting the result to `dst`."""
|
||||
command = [FFMPEG_BIN, "-i", src, "-vf", "reverse", dst]
|
||||
logger.debug(" ".join(command))
|
||||
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
output, error = process.communicate()
|
||||
|
||||
if output:
|
||||
logger.debug(output.decode())
|
||||
|
||||
if error:
|
||||
logger.debug(error.decode())
|
||||
|
||||
|
||||
class Slide(Scene): # type:ignore
|
||||
"""
|
||||
Inherits from :class:`manim.scene.scene.Scene` or :class:`manimlib.scene.scene.Scene` and provide necessary tools for slides rendering.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, *args: Any, output_folder: str = FOLDER_PATH, **kwargs: Any
|
||||
) -> None:
|
||||
if MANIMGL:
|
||||
if not os.path.isdir("videos"):
|
||||
os.mkdir("videos")
|
||||
kwargs["file_writer_config"] = {
|
||||
"break_into_partial_movies": True,
|
||||
"output_directory": "",
|
||||
"write_to_movie": True,
|
||||
}
|
||||
|
||||
kwargs["preview"] = False
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.output_folder = output_folder
|
||||
self.slides: List[SlideConfig] = []
|
||||
self.current_slide = 1
|
||||
self.current_animation = 0
|
||||
self.loop_start_animation: Optional[int] = None
|
||||
self.pause_start_animation = 0
|
||||
|
||||
@property
|
||||
def partial_movie_files(self) -> List[str]:
|
||||
"""Returns a list of partial movie files, a.k.a animations."""
|
||||
if MANIMGL:
|
||||
from manimlib.utils.file_ops import get_sorted_integer_files
|
||||
|
||||
kwargs = {
|
||||
"remove_non_integer_files": True,
|
||||
"extension": self.file_writer.movie_file_extension,
|
||||
}
|
||||
return get_sorted_integer_files(
|
||||
self.file_writer.partial_movie_directory, **kwargs
|
||||
)
|
||||
else:
|
||||
return self.renderer.file_writer.partial_movie_files
|
||||
|
||||
@property
|
||||
def show_progress_bar(self) -> bool:
|
||||
"""Returns True if progress bar should be displayed."""
|
||||
if MANIMGL:
|
||||
return getattr(super(Scene, self), "show_progress_bar", True)
|
||||
else:
|
||||
return config["progress_bar"] != "none"
|
||||
|
||||
@property
|
||||
def leave_progress_bar(self) -> bool:
|
||||
"""Returns True if progress bar should be left after completed."""
|
||||
if MANIMGL:
|
||||
return getattr(super(Scene, self), "leave_progress_bars", False)
|
||||
else:
|
||||
return config["progress_bar"] == "leave"
|
||||
|
||||
def play(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Overloads `self.play` and increment animation count."""
|
||||
super().play(*args, **kwargs)
|
||||
self.current_animation += 1
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Creates a new slide with previous animations."""
|
||||
self.slides.append(
|
||||
SlideConfig(
|
||||
type=SlideType.slide,
|
||||
start_animation=self.pause_start_animation,
|
||||
end_animation=self.current_animation,
|
||||
number=self.current_slide,
|
||||
)
|
||||
)
|
||||
self.current_slide += 1
|
||||
self.pause_start_animation = self.current_animation
|
||||
|
||||
def add_last_slide(self) -> None:
|
||||
"""Adds a 'last' slide to the end of slides."""
|
||||
|
||||
if self.current_animation == self.slides[-1].end_animation:
|
||||
self.slides[-1].type = SlideType.last
|
||||
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."""
|
||||
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."""
|
||||
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()
|
||||
|
||||
if not os.path.exists(self.output_folder):
|
||||
os.mkdir(self.output_folder)
|
||||
|
||||
files_folder = os.path.join(self.output_folder, "files")
|
||||
if not os.path.exists(files_folder):
|
||||
os.mkdir(files_folder)
|
||||
|
||||
scene_name = type(self).__name__
|
||||
scene_files_folder = os.path.join(files_folder, scene_name)
|
||||
|
||||
old_animation_files = set()
|
||||
|
||||
if not os.path.exists(scene_files_folder):
|
||||
os.mkdir(scene_files_folder)
|
||||
elif not use_cache:
|
||||
shutil.rmtree(scene_files_folder)
|
||||
os.mkdir(scene_files_folder)
|
||||
else:
|
||||
old_animation_files.update(os.listdir(scene_files_folder))
|
||||
|
||||
files = []
|
||||
for src_file in tqdm(
|
||||
self.partial_movie_files,
|
||||
desc=f"Copying animation files to '{scene_files_folder}' and generating reversed animations",
|
||||
leave=self.leave_progress_bar,
|
||||
ascii=True if platform.system() == "Windows" else None,
|
||||
disable=not self.show_progress_bar,
|
||||
):
|
||||
filename = os.path.basename(src_file)
|
||||
rev_filename = "{}_reversed{}".format(*os.path.splitext(filename))
|
||||
|
||||
dst_file = os.path.join(scene_files_folder, filename)
|
||||
# We only copy animation if it was not present
|
||||
if filename in old_animation_files:
|
||||
old_animation_files.remove(filename)
|
||||
else:
|
||||
shutil.copyfile(src_file, dst_file)
|
||||
|
||||
# We only reverse video if it was not present
|
||||
if rev_filename in old_animation_files:
|
||||
old_animation_files.remove(rev_filename)
|
||||
else:
|
||||
rev_file = os.path.join(scene_files_folder, rev_filename)
|
||||
reverse_video_file(src_file, rev_file)
|
||||
|
||||
files.append(dst_file)
|
||||
|
||||
logger.info(
|
||||
f"Copied {len(files)} animations to '{os.path.abspath(scene_files_folder)}' and generated reversed animations"
|
||||
)
|
||||
|
||||
slide_path = os.path.join(self.output_folder, "%s.json" % (scene_name,))
|
||||
|
||||
with open(slide_path, "w") as f:
|
||||
f.write(PresentationConfig(slides=self.slides, files=files).json(indent=2))
|
||||
|
||||
logger.info(
|
||||
f"Slide '{scene_name}' configuration written in '{os.path.abspath(slide_path)}'"
|
||||
)
|
||||
|
||||
def run(self, *args: 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 intermidiate files
|
||||
max_files_cached = config["max_files_cached"]
|
||||
config["max_files_cached"] = float("inf")
|
||||
|
||||
super().render(*args, **kwargs)
|
||||
|
||||
config["max_files_cached"] = max_files_cached
|
||||
|
||||
self.save_slides()
|
||||
|
||||
|
||||
class ThreeDSlide(Slide, ThreeDScene): # type: ignore
|
||||
"""
|
||||
Inherits from :class:`Slide` and :class:`manim.scene.three_d_scene.ThreeDScene` or :class:`manimlib.scene.three_d_scene.ThreeDScene` and provide necessary tools for slides rendering.
|
||||
|
||||
.. note:: ManimGL does not need ThreeDScene for 3D rendering in recent versions, see `example.py`.
|
||||
"""
|
||||
|
||||
pass
|
196
manim_slides/wizard.py
Normal file
196
manim_slides/wizard.py
Normal file
@ -0,0 +1,196 @@
|
||||
import os
|
||||
import sys
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QIcon, QKeyEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QGridLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .commons import config_options, verbosity_option
|
||||
from .config import Config, Key
|
||||
from .defaults import CONFIG_PATH
|
||||
from .manim import logger
|
||||
from .resources import * # noqa: F401, F403
|
||||
|
||||
WINDOW_NAME: str = "Configuration Wizard"
|
||||
|
||||
keymap = {}
|
||||
for key in Qt.Key:
|
||||
keymap[key.value] = key.name.partition("_")[2]
|
||||
|
||||
|
||||
class KeyInput(QDialog): # type: ignore
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.key = None
|
||||
|
||||
self.layout = QVBoxLayout()
|
||||
|
||||
self.setWindowTitle("Keyboard Input")
|
||||
self.label = QLabel("Press any key to register it")
|
||||
self.layout.addWidget(self.label)
|
||||
self.setLayout(self.layout)
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None:
|
||||
self.key = event.key()
|
||||
self.deleteLater()
|
||||
event.accept()
|
||||
|
||||
|
||||
class Wizard(QWidget): # type: ignore
|
||||
def __init__(self, config: Config):
|
||||
|
||||
super().__init__()
|
||||
|
||||
self.setWindowTitle(WINDOW_NAME)
|
||||
self.config = config
|
||||
self.icon = QIcon(":/icon.png")
|
||||
self.setWindowIcon(self.icon)
|
||||
|
||||
QBtn = QDialogButtonBox.Save | QDialogButtonBox.Cancel
|
||||
|
||||
self.buttonBox = QDialogButtonBox(QBtn)
|
||||
self.buttonBox.accepted.connect(self.saveConfig)
|
||||
self.buttonBox.rejected.connect(self.closeWithoutSaving)
|
||||
|
||||
self.buttons = []
|
||||
|
||||
self.layout = QGridLayout()
|
||||
|
||||
for i, (key, value) in enumerate(self.config.dict().items()):
|
||||
# Create label for key name information
|
||||
label = QLabel()
|
||||
key_info = value["name"] or key
|
||||
label.setText(key_info)
|
||||
self.layout.addWidget(label, i, 0)
|
||||
|
||||
# Create button that will pop-up a dialog and ask to input a new key
|
||||
value = value["ids"].pop()
|
||||
button = QPushButton(keymap[value])
|
||||
button.setToolTip(
|
||||
f"Click to modify the key associated to action {key_info}"
|
||||
)
|
||||
self.buttons.append(button)
|
||||
button.clicked.connect(
|
||||
partial(self.openDialog, i, getattr(self.config, key))
|
||||
)
|
||||
self.layout.addWidget(button, i, 1)
|
||||
|
||||
self.layout.addWidget(self.buttonBox, len(self.buttons), 1)
|
||||
|
||||
self.setLayout(self.layout)
|
||||
|
||||
def closeWithoutSaving(self) -> None:
|
||||
logger.debug("Closing configuration wizard without saving")
|
||||
self.deleteLater()
|
||||
sys.exit(0)
|
||||
|
||||
def closeEvent(self, event: Any) -> None:
|
||||
self.closeWithoutSaving()
|
||||
event.accept()
|
||||
|
||||
def saveConfig(self) -> None:
|
||||
try:
|
||||
Config.parse_obj(self.config.dict())
|
||||
except ValueError:
|
||||
msg = QMessageBox()
|
||||
msg.setIcon(QMessageBox.Critical)
|
||||
msg.setText("Error")
|
||||
msg.setInformativeText(
|
||||
"Two or more actions share a common key: make sure actions have distinct key codes."
|
||||
)
|
||||
msg.setWindowTitle("Error: duplicated keys")
|
||||
msg.exec_()
|
||||
return
|
||||
|
||||
self.deleteLater()
|
||||
|
||||
def openDialog(self, button_number: int, key: Key) -> None:
|
||||
button = self.buttons[button_number]
|
||||
dialog = KeyInput()
|
||||
dialog.exec_()
|
||||
if dialog.key is not None:
|
||||
key_name = keymap[dialog.key]
|
||||
key.set_ids(dialog.key)
|
||||
button.setText(key_name)
|
||||
|
||||
|
||||
@click.command()
|
||||
@config_options
|
||||
@click.help_option("-h", "--help")
|
||||
@verbosity_option
|
||||
def wizard(config_path: str, force: bool, merge: bool) -> None:
|
||||
"""Launch configuration wizard."""
|
||||
return _init(config_path, force, merge, skip_interactive=False)
|
||||
|
||||
|
||||
@click.command()
|
||||
@config_options
|
||||
@click.help_option("-h", "--help")
|
||||
@verbosity_option
|
||||
def init(
|
||||
config_path: str, force: bool, merge: bool, skip_interactive: bool = False
|
||||
) -> None:
|
||||
"""Initialize a new default configuration file."""
|
||||
return _init(config_path, force, merge, skip_interactive=True)
|
||||
|
||||
|
||||
def _init(
|
||||
config_path: str, force: bool, merge: bool, skip_interactive: bool = False
|
||||
) -> None:
|
||||
"""Actual initialization code for configuration file, with optional interactive mode."""
|
||||
|
||||
if os.path.exists(config_path):
|
||||
click.secho(f"The `{CONFIG_PATH}` configuration file exists")
|
||||
|
||||
if not force and not merge:
|
||||
choice = click.prompt(
|
||||
"Do you want to continue and (o)verwrite / (m)erge it, or (q)uit?",
|
||||
type=click.Choice(["o", "m", "q"], case_sensitive=False),
|
||||
)
|
||||
|
||||
force = choice == "o"
|
||||
merge = choice == "m"
|
||||
|
||||
if not force and not merge:
|
||||
logger.debug("Exiting without doing anything")
|
||||
sys.exit(0)
|
||||
|
||||
config = Config()
|
||||
|
||||
if force:
|
||||
logger.debug(f"Overwriting `{config_path}` if exists")
|
||||
elif merge:
|
||||
logger.debug("Merging new config into `{config_path}`")
|
||||
|
||||
if not skip_interactive:
|
||||
if os.path.exists(config_path):
|
||||
config = Config.parse_file(config_path)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Manim Slides Wizard")
|
||||
window = Wizard(config)
|
||||
window.show()
|
||||
app.exec()
|
||||
|
||||
config = window.config
|
||||
|
||||
if merge:
|
||||
config = Config.parse_file(config_path).merge_with(config)
|
||||
|
||||
with open(config_path, "w") as config_file:
|
||||
config_file.write(config.json(indent=2))
|
||||
|
||||
click.secho(f"Configuration file successfully saved to `{config_path}`")
|
2847
poetry.lock
generated
Normal file
2847
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
106
pyproject.toml
Normal file
106
pyproject.toml
Normal file
@ -0,0 +1,106 @@
|
||||
[build-system]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
requires = ["setuptools", "poetry-core>=1.0.0"]
|
||||
|
||||
[tool.black]
|
||||
target-version = ["py38"]
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
py_version = 38
|
||||
|
||||
[tool.mypy]
|
||||
check-untyped-defs = true
|
||||
# Disallow dynamic typing
|
||||
disallow-any-generics = true
|
||||
disallow-incomplete-defs = true
|
||||
disallow-subclassing-any = true
|
||||
# Disallow untyped definitions and calls
|
||||
disallow-untyped-defs = true
|
||||
ignore-missing-imports = true
|
||||
install-types = true
|
||||
# None and optional handling
|
||||
no-implicit-optional = true
|
||||
no-warn-return-any = true
|
||||
non-interactive = true
|
||||
python_version = "3.8"
|
||||
# Strict equality
|
||||
strict-equality = true
|
||||
warn-no-return = true
|
||||
warn-redundant-casts = true
|
||||
# Config file
|
||||
warn-unused-configs = true
|
||||
# Configuring warnings
|
||||
warn-unused-ignores = true
|
||||
|
||||
[tool.poetry]
|
||||
authors = [
|
||||
"Jérome Eertmans <jeertmans@icloud.com>"
|
||||
]
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
||||
"Operating System :: OS Independent",
|
||||
"Topic :: Multimedia :: Video",
|
||||
"Topic :: Multimedia :: Graphics",
|
||||
"Topic :: Scientific/Engineering"
|
||||
]
|
||||
description = "Tool for live presentations using manim"
|
||||
documentation = "https://eertmans.be/manim-slides"
|
||||
exclude = ["docs/", "static/"]
|
||||
homepage = "https://github.com/jeertmans/manim-slides"
|
||||
keywords = ["manim", "slides", "plugin", "manimgl"]
|
||||
license = "GPL-3.0-only"
|
||||
name = "manim-slides"
|
||||
packages = [
|
||||
{include = "manim_slides"}
|
||||
]
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/jeertmans/manim-slides"
|
||||
version = "4.8.2"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
click = "^8.1.3"
|
||||
click-default-group = "^1.2.2"
|
||||
numpy = "^1.19"
|
||||
opencv-python = "^4.6.0.66"
|
||||
pydantic = "^1.10.2"
|
||||
pyside6 = "^6.4.1"
|
||||
python = ">=3.8.1,<3.12"
|
||||
requests = "^2.28.1"
|
||||
tqdm = "^4.64.1"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = "^22.10.0"
|
||||
isort = "^5.12.0"
|
||||
mypy = "^0.991"
|
||||
pre-commit = "^3.0.2"
|
||||
ruff = "^0.0.219"
|
||||
|
||||
[tool.poetry.group.docs.dependencies]
|
||||
furo = "^2022.9.29"
|
||||
manim = "^0.17.0"
|
||||
myst-parser = "^0.18.1"
|
||||
sphinx = "^5.3.0"
|
||||
sphinx-click = "^4.4.0"
|
||||
sphinx-copybutton = "^0.5.1"
|
||||
sphinxext-opengraph = "^0.7.5"
|
||||
|
||||
[tool.poetry.group.test.dependencies]
|
||||
manim = "^0.17.0"
|
||||
manimgl = "^1.6.1"
|
||||
|
||||
[tool.poetry.plugins]
|
||||
|
||||
[tool.poetry.plugins."console_scripts"]
|
||||
manim-slides = "manim_slides.__main__:cli"
|
||||
|
||||
[tool.ruff]
|
||||
ignore = [
|
||||
"E501"
|
||||
]
|
||||
target-version = "py38"
|
28
setup.py
28
setup.py
@ -1,28 +0,0 @@
|
||||
import setuptools
|
||||
import os
|
||||
|
||||
with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), "README.md"), "r") as fh:
|
||||
long_description = fh.read()
|
||||
|
||||
setuptools.setup(
|
||||
name="manim_presentation",
|
||||
version="0.2.0",
|
||||
author="Federico A. Galatolo",
|
||||
author_email="federico.galatolo@ing.unipi.it",
|
||||
description="Tool for live presentations using manim",
|
||||
url="https://github.com/galatolofederico/manim-presentation",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
packages=setuptools.find_packages(),
|
||||
entry_points = {
|
||||
"console_scripts": ["manim_presentation=manim_presentation.present:main"],
|
||||
},
|
||||
install_requires=[
|
||||
],
|
||||
classifiers=[
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
||||
"Operating System :: OS Independent",
|
||||
"Development Status :: 4 - Beta"
|
||||
],
|
||||
)
|
BIN
static/docs.png
Normal file
BIN
static/docs.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 209 KiB |
BIN
static/example.gif
Normal file
BIN
static/example.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 670 KiB |
BIN
static/icon.png
Normal file
BIN
static/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.0 KiB |
BIN
static/logo.png
Normal file
BIN
static/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 110 KiB |
41
static/logo.py
Normal file
41
static/logo.py
Normal file
@ -0,0 +1,41 @@
|
||||
# flake8: noqa: F403, F405
|
||||
# type: ignore
|
||||
from manim import *
|
||||
|
||||
|
||||
class ManimSlidesLogo(Scene):
|
||||
def construct(self):
|
||||
tex_template = TexTemplate()
|
||||
tex_template.add_to_preamble(r"\usepackage{graphicx}\usepackage{fontawesome5}")
|
||||
self.camera.background_color = "#ffffff"
|
||||
logo_green = "#87c2a5"
|
||||
logo_blue = "#525893"
|
||||
logo_red = "#e07a5f"
|
||||
logo_black = "#343434"
|
||||
ds_m = MathTex(r"\mathbb{M}", fill_color=logo_black).scale(7)
|
||||
ds_m.shift(2.25 * LEFT + 1.5 * UP)
|
||||
slides = MathTex(r"\mathbb{S}\text{lides}", fill_color=logo_black).scale(4)
|
||||
slides.next_to(ds_m, DOWN)
|
||||
slides.shift(DOWN)
|
||||
play = Tex(
|
||||
r"\faStepBackward\faStepForward",
|
||||
fill_color=logo_black,
|
||||
tex_template=tex_template,
|
||||
).scale(4)
|
||||
play.next_to(ds_m, LEFT)
|
||||
play.shift(LEFT + 0.5 * DOWN)
|
||||
comment = Tex(
|
||||
r"\reflectbox{\faComment*[regular]}",
|
||||
fill_color=logo_black,
|
||||
tex_template=tex_template,
|
||||
).scale(9)
|
||||
comment.move_to(play)
|
||||
comment.shift(0.4 * DOWN)
|
||||
circle = Circle(color=logo_green, fill_opacity=1).shift(LEFT)
|
||||
square = Square(color=logo_blue, fill_opacity=1).shift(UP)
|
||||
triangle = Triangle(color=logo_red, fill_opacity=1).shift(RIGHT)
|
||||
logo = VGroup(
|
||||
triangle, square, circle, ds_m, slides, comment, play
|
||||
) # order matters
|
||||
logo.move_to(ORIGIN)
|
||||
self.add(logo)
|
BIN
static/windows_quality_fix.png
Normal file
BIN
static/windows_quality_fix.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 35 KiB |
Reference in New Issue
Block a user