Compare commits

..

56 Commits

Author SHA1 Message Date
8dfe600656 chore(version): release 4.7.0 2022-12-03 16:09:09 +01:00
03107867ab chore(deps): drop support for Python 3.7 (#84)
* chore(deps): drop support for Python 3.7

In link with the recent release of [Manim v0.17.0](https://github.com/ManimCommunity/manim/releases/tag/v0.17.0), Manim Slides drops support for Python 3.7.

* feat(cli): check for newer Manim Slides version

Uses same logic as manim to check for a new version

* fix(lib): reset correct version

* chore(setup): remove 3.7 classifier
2022-12-03 14:51:41 +01:00
bf64962c46 feat(cli): check for newer Manim Slides version (#85)
* chore(deps): drop support for Python 3.7

In link with the recent release of [Manim v0.17.0](https://github.com/ManimCommunity/manim/releases/tag/v0.17.0), Manim Slides drops support for Python 3.7.

* feat(cli): check for newer Manim Slides version

Uses same logic as manim to check for a new version

* fix(lib): reset correct version

* fix(ci): add type stubs for requests
2022-12-03 14:51:00 +01:00
97e7bf8cb0 chore(speed): avoid unnecessary color conversion (#83)
* chore(speed): avoid unnecessary color conversion

This speeds up a bit the presentation by avoiding color conversion from BGR (OpenCV) to RGB.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-12-02 18:02:54 +01:00
1bca2683e1 refactor(lib): rename main to __main__ (#82)
* refactor(lib): rename `main` to `__main__`

This allows to call the module using `python -m manim-slides`, useful for profiling, etc.

* fix(setup): update name
2022-12-02 17:24:57 +01:00
d6bb82261c chore(setup): move setup config to pyproject.toml (#78)
* chore(setup): move setup config to pyproject.toml

* fix(ci): modify build steps

* fix(ci): remove / exclude useless stuff

* fix(ci): exclude does not work
2022-11-29 10:57:25 +01:00
0c682e4ec9 [pre-commit.ci] pre-commit autoupdate (#77)
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.3.0 → v4.4.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.3.0...v4.4.0)
- [github.com/PyCQA/flake8: 5.0.4 → 6.0.0](https://github.com/PyCQA/flake8/compare/5.0.4...6.0.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-11-29 08:54:41 +01:00
2f0453c9a6 chore(lib): add missing type hints and mypy is happy! (#75)
* chore(lib): add missing type hints and mypy is happy!

Closes #34

* fix(ci): add missing dep for mypy
2022-11-28 14:28:42 +01:00
85ea9f3096 Fix flake8 check errors: unused imports, tab warning (#68)
* fix: unused imports, hide tab warning for the str

* refactor(defaults.py): move revealjs_template to separate file

* fix(lib): move data files and use pkg_resources

* fix(lib): remove unused and unexisting import

* fix(ci): only test conversion with Manim

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix(ci): test ManimGL on Python 3.10, not 3.11

* fix(lib): include package data in setup.py

* fix(ci): no fail-fast

* fix(ci): typo

Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-11-28 11:39:34 +01:00
1ae8db7966 chore(python): support and tests for Python 3.11 (#74)
Closes #71
2022-11-28 10:26:13 +01:00
82cccc3fc2 [pre-commit.ci] pre-commit autoupdate (#73)
updates:
- [github.com/pre-commit/mirrors-mypy: v0.990 → v0.991](https://github.com/pre-commit/mirrors-mypy/compare/v0.990...v0.991)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-11-23 12:43:35 +01:00
726b0abf5a docs: correct virtual environment information (#72)
* docs: correct virtual environment information

* fix: format

Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
2022-11-18 07:29:24 +01:00
80f4f4e3f7 Remove redundant black hook & specify minimal supported python version for hooks (#70)
* fix(ci): remove duplicated hook

* feat(ci): specify minimal supported python version - hooks
2022-11-16 23:48:12 +01:00
82eebae686 fix(ci): setup minimal python version for flake8 (#69) 2022-11-16 10:07:37 +01:00
7367cc2cb5 [pre-commit.ci] pre-commit autoupdate (#67)
updates:
- [github.com/pre-commit/mirrors-mypy: v0.982 → v0.990](https://github.com/pre-commit/mirrors-mypy/compare/v0.982...v0.990)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-11-15 08:07:14 +01:00
f26541eb32 chore(version): update version 2022-11-09 16:14:34 +01:00
06890ceacd fix(ci): typo in keyword args 2022-11-09 16:06:50 +01:00
9aa715a0e4 feat(cli): add convert option to generate html presentations (#66)
* wip(cli): convert slides to html using RevealJS

* wip: convert - almost fully working

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: remove unused file

* fix: add last slides in now performed during rendering

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* chore(ci): testing ConvertExample too

* fix: ManimGL does not consider wait as an animation

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-11-09 15:59:19 +01:00
a373bdb460 feat(cli): add app icon (#64)
* feat: add icon

* feat: add app icon

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix(lint): ignore resources file with mypy

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-11-02 10:47:37 +01:00
e3e79617c0 feat(cli): read environ variable for verbosity (#63)
* feat(cli): read environ variable for verbosity

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-10-31 21:48:17 +01:00
668de2c023 fix(ci): try fix 2022-10-31 16:22:58 +01:00
929caec018 fix(ci): another try 2022-10-31 15:59:39 +01:00
48cc3343bd fix(ci): try installing opengl 2022-10-31 15:54:06 +01:00
144e7dac5b fix(ci): trying another solution 2022-10-31 15:37:06 +01:00
8b56f42183 fix(ci): trying to fix build problem 2022-10-31 15:33:34 +01:00
534bc21672 fix(ci): remove impossible caching 2022-10-31 15:26:39 +01:00
b1a8768963 fix(ci): fixing workflows and caching 2022-10-31 15:21:58 +01:00
422e355758 chore(version): update version and change pyside version dep 2022-10-31 15:07:33 +01:00
3eb9fa0b74 refactor(lib): change PyQT5 to PySide6 (#62)
* refactor(lib): change PyQT5 to PySide6

This, hopefully, should now add support for M1 chips

* chore: update README and change imports
2022-10-31 14:55:03 +01:00
8f519ed134 Create FUNDING.yml 2022-10-31 10:11:32 +01:00
916e2aa2ab chore(version): update version 2022-10-31 09:15:01 +01:00
4d5f664348 chore(cli): change metavar for some options (#61)
This changes some metavars such that `--help` output is closer to the Sphinx documentation.
2022-10-31 09:13:32 +01:00
cb6a5bb35f feat: add option for background color (#60)
* feat: add option for background color

This allows to define the background color used for border when resize mode is set to "keep".

Closes #52

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-10-31 09:01:14 +01:00
bba05cce16 chore(version): update version 2022-10-24 09:27:14 +02:00
ad02c8296b feat(cli): add resize mode option (#59)
This adds a new option that allows to control how the video is going to be scaled. By default, we now use a smooth rescaling, but fast mode with no interpolation can be used (previously default).
2022-10-24 09:26:48 +02:00
0778cebef7 chore(README): update key bindings documentation 2022-10-24 09:20:14 +02:00
163260415b Mouse Show/Hide feature (#56)
* Mouse Show/Hide feature

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Mouse Hide/Show Fix

Applied requested changes for PR

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
2022-10-21 12:57:53 +02:00
241419a781 Support template (#57)
* PATCH: Docs template enhacement

* FEAT: Template drafted for Support

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update .github/ISSUE_TEMPLATE/documentation.yml

Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>

* Update .github/ISSUE_TEMPLATE/support.yml

Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>

Co-authored-by: Harisai <Harisaipravin_SV@intuit.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
2022-10-21 10:55:39 +02:00
bac21815b2 chore(README): add note for M1 chips and doc. feat 2022-10-20 08:55:00 +02:00
2f8f7561a6 Issue more templates (#54)
* FEAT: Git template for documentation

* PATCH: Space keys updated

* PATCH: Spaccing update

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* UPDATE: Code of conduct removed

* PATCH: Retry CI

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

Co-authored-by: Harisai <Harisaipravin_SV@intuit.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-10-19 18:27:41 +02:00
a489dfd0e8 feat: set application name 2022-10-19 14:32:16 +02:00
76ef16d98b fix(docs): typo 2022-10-19 11:51:43 +02:00
88125bf1ae Update __version__.py 2022-10-19 11:09:23 +02:00
cffc4ebbc5 Update __version__.py 2022-10-19 11:09:14 +02:00
d717bc651d refactor: use PyQT5 for window display (#49)
* wip: use PyQT5 for window display

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* wip: first slide is shown

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* wip: pushing non-working code

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* wip: some logging

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* feat: new configuration wizard working

* fix: prevent key error

* wip: making action work

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* wip: soon done! info + video

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: bugs in sleep and exiting

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* try: offscreen

* fix: pop default value if not present

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* feat: add aspect ratio option

* chore: typing wip

* fix: now() function returns seconds, not milliseconds anymore

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-10-19 11:08:41 +02:00
bc3d55fce2 feat(cli): add basic logging facilities (#50)
* feat(cli): add basic logging facilities

This PR adds basic logging support, to be extended in the future.

* fix: typo in decorator name
2022-10-17 10:52:42 +02:00
5b9cb1523c chore(ci): pre-commit mypy (#41)
* chore(ci): pre-commit mypy

* chore: ignore non-lib files

* chore: ignore setup.py

* [pre-commit.ci] pre-commit autoupdate (#47)

updates:
- [github.com/psf/black: 22.8.0 → 22.10.0](https://github.com/psf/black/compare/22.8.0...22.10.0)
- [github.com/psf/black: 22.8.0 → 22.10.0](https://github.com/psf/black/compare/22.8.0...22.10.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* chore(ci): pre-commit mypy

* chore: ignore non-lib files

* chore: ignore setup.py

* fix: update pre-commit config for mypy

* feat: add some missing type hints

Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-10-11 11:05:58 +02:00
51a87840ce [pre-commit.ci] pre-commit autoupdate (#47)
updates:
- [github.com/psf/black: 22.8.0 → 22.10.0](https://github.com/psf/black/compare/22.8.0...22.10.0)
- [github.com/psf/black: 22.8.0 → 22.10.0](https://github.com/psf/black/compare/22.8.0...22.10.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-10-11 10:10:30 +02:00
42550e8b29 Update defaults.py (#46)
fix the key id for macos
2022-10-10 07:31:52 +02:00
501813483c Added Pull Request Template (#43)
* Create feature_request.yml

* Issue template

* Create bug.yml

* Update bug.yml

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update bug.yml

* Update bug.yml

* Created Pull Request Template

* Changed directory

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update .github/pull_request_template.md

Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
2022-10-06 18:07:12 +02:00
0ae99c0f4d fix: typo in issue template 2022-10-06 09:17:07 +02:00
c2315928bd Created Bug Template (#40)
* Create feature_request.yml

* Issue template

* Create bug.yml

* Update bug.yml

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update bug.yml

* Update bug.yml

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-10-05 19:31:31 +02:00
f3c8f3cc24 Add Feature Request Template (#39)
* Create ISSUE_TEMPLATE

* Update feature_request.yml

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-10-05 17:21:48 +02:00
14a266b139 Update README.md (#33)
Fixed small spelling mistake.
2022-10-05 10:53:34 +02:00
b697442fc0 chore(ci): add more style checks (#32)
* chore(ci): add more style checks

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* chore(ci): remove vulture as flake8 replaces it

* chore(lib): add __all__ list

* chore(ci): make flake8 happy and remove mypy

* fix: config_path is actually used in commons

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-10-05 10:41:08 +02:00
4f8fae75cf [pre-commit.ci] pre-commit autoupdate (#31)
updates:
- [github.com/jendrikseipp/vulture: v2.3 → v2.6](https://github.com/jendrikseipp/vulture/compare/v2.3...v2.6)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-09-28 07:55:24 +02:00
35 changed files with 2041 additions and 497 deletions

5
.flake8 Normal file
View File

@ -0,0 +1,5 @@
[flake8]
min_python_version = 3.7
extend-ignore =
# E501: line too long
E501,

3
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,3 @@
# These are supported funding model platforms
github: [jeertmans]

40
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View 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

View 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

View 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
View 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
View 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 -->

View File

@ -30,14 +30,22 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Setup Pages
uses: actions/configure-pages@v2
- name: Install Linux Dependencies
run: sudo apt install libcairo2-dev libpango1.0-dev ffmpeg
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: pip install -e .
- name: Build animation and convert it into HTML slides
run: |
manim example.py ConvertExample
manim-slides convert ConvertExample docs/source/_static/slides.html -cembedded=true -ccontrols=true
- name: Build docs
run: cd docs && make html
- name: Upload artifact

View File

@ -1,4 +1,4 @@
# From: https://github.com/pypa/cibuildwheel
# Modified from: https://github.com/pypa/cibuildwheel
name: Upload Python Package
on:
@ -18,20 +18,13 @@ jobs:
steps:
- uses: actions/checkout@v2
# Used to host cibuildwheel
- uses: actions/setup-python@v2
- name: Install deps
run: sudo apt-get install libsdl-pango-dev
- name: Install packages
run: python -m pip install -U manim tqdm
- name: Install cibuildwheel
run: python -m pip install -U setuptools wheel pip
- name: Install build package
run: python -m pip install -U build
- name: Build wheels
run: python setup.py sdist
run: python -m build --sdist
- uses: actions/upload-artifact@v2
with:

View File

@ -6,36 +6,42 @@ on:
name: Test Examples
env:
QT_QPA_PLATFORM: offscreen
MANIM_SLIDES_VERBOSITY: debug
jobs:
build-examples:
strategy:
fail-fast: false
matrix:
manim: [manim, manimgl]
os: [macos-latest, ubuntu-latest, windows-latest]
pyversion: ['3.7', '3.8', '3.9', '3.10']
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
# manimgl actually requires Python >= 3.8, see:
# https://github.com/3b1b/manim/issues/1808
# manimgl seems to have problems with Python 3.11
- manim: manimgl
pyversion: '3.7'
# We only test Python 3.10 on Windows and MacOS
- os: windows-latest
pyversion: '3.7'
pyversion: '3.11'
# We only test Python 3.11 on Windows and MacOS
- os: windows-latest
pyversion: '3.8'
- os: windows-latest
pyversion: '3.9'
- os: macos-latest
pyversion: '3.7'
- 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
@ -85,7 +91,7 @@ jobs:
run: pip3 install -e .
- name: Build slides with manim
if: matrix.manim == 'manim'
run: python -m manim -ql example.py Example ThreeDExample
run: python -m manim -ql example.py Example ThreeDExample ConvertExample
- name: Build slides with manimgl on Ubuntu
if: matrix.manim == 'manimgl' && matrix.os == 'ubuntu-latest'
run: xvfb-run -a -s "-screen 0 1400x900x24" manim-render -l example.py Example ThreeDExample
@ -98,3 +104,6 @@ jobs:
- name: Test slides on MacOS or Windows
if: matrix.os == 'macos-latest' || matrix.os == 'windows-latest'
run: manim-slides Example ThreeDExample --skip-all
- name: Test convert on Ubuntu
if: matrix.os == 'ubuntu-latest' && matrix.manim == 'manim'
run: manim-slides convert --to=html ConvertExample index.html

4
.gitignore vendored
View File

@ -19,3 +19,7 @@ videos/
images/
docs/build/
docs/source/_static/slides_assets/
docs/source/_static/slides.html

View File

@ -1,25 +1,59 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
rev: v4.4.0
hooks:
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/psf/black
rev: 22.8.0
hooks:
- id: black
- repo: https://github.com/pycqa/isort
rev: 5.10.1
hooks:
- id: isort
name: isort (python)
args: ["--profile", "black"]
args: ["--python-version", "37", "--profile", "black"]
- repo: https://github.com/psf/black
rev: 22.8.0
rev: 22.10.0
hooks:
- id: black
- repo: https://github.com/jendrikseipp/vulture
rev: v2.3
args: ["--target-version", "py37"]
- repo: https://github.com/PyCQA/flake8
rev: 6.0.0
hooks:
- id: vulture
- id: flake8
additional_dependencies:
- flake8-bugbear
- flake8-comprehensions
- flake8-tidy-imports
- flake8-typing-imports
- repo: https://github.com/pre-commit/mirrors-mypy
rev: 'v0.991'
hooks:
- id: mypy
additional_dependencies: [types-requests, types-setuptools]
args:
- --install-types
- --non-interactive
- --ignore-missing-imports
# Disallow dynamic typing
- --disallow-any-generics
- --disallow-subclassing-any
# Disallow untyped definitions and calls
- --disallow-untyped-defs
- --disallow-incomplete-defs
- --check-untyped-defs
# None and optional handling
- --no-implicit-optional
# Configuring warnings
- --warn-unused-ignores
- --warn-no-return
- --no-warn-return-any
- --warn-redundant-casts
# Strict equality
- --strict-equality
# Config file
- --warn-unused-configs

View File

@ -24,13 +24,13 @@ Tool for live presentations using either [Manim (community edition)](https://www
## Installation
While installing Manim Slides and its dependencies on your global Python is fine, I recommend using a [virtualenv](https://docs.python.org/3/tutorial/venv.html) for a local installation.
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
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 specifc installation guidelines:
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)
@ -88,7 +88,7 @@ class Example(Slide):
self.play(dot.animate.move_to(ORIGIN))
self.pause() # Waits user to press continue to go to the next slide
self.wait() # The presentation directly exits after last animation
self.wait()
```
You **must** end your `Slide` with a `self.play(...)` or a `self.wait(...)`.
@ -117,14 +117,8 @@ manim-slides Example
The default key bindings to control the presentation are:
| Keybinding | Action |
|:-----------:|:------------------------:|
| Right Arrow | Continue/Next Slide |
| Left Arrow | Previous Slide |
| R | Replay Current Slide |
| V | Reverse Current Slide |
| Spacebar | Play/Pause |
| Q | Quit |
![manim-wizard](https://user-images.githubusercontent.com/27275099/197468787-19c83a81-d757-47b9-8f68-218427d30298.png)
You can run the **configuration wizard** to change those key bindings:
@ -132,15 +126,13 @@ You can run the **configuration wizard** to change those key bindings:
manim-slides wizard
```
Alternatively you can specify different key bindings creating a file named `.manim-slides.json` with the keys: `QUIT` `CONTINUE` `BACK` `REVERSE` `REWIND` and `PLAY_PAUSE`.
A default file can be created with:
```bash
manim-slides init
```
> **_NOTE:_** `manim-slides` uses `cv2.waitKeyEx()` to wait for keypresses, and directly registers the key code.
> **_NOTE:_** `manim-slides` uses key codes, which are platform dependent. Using the configuration wizard is therefore highly recommended.
## Other Examples
@ -171,6 +163,7 @@ Below is a non-exhaustive list of features:
| Support for 3D Scenes | :heavy_check_mark: | :heavy_multiplication_x: |
| Documented code | :heavy_check_mark: | :heavy_multiplication_x: |
| Tested on Unix, macOS, and Windows | :heavy_check_mark: | :heavy_multiplication_x: |
| Hide mouse cursor | :heavy_check_mark: | :heavy_multiplication_x: |
## F.A.Q

View File

@ -1,3 +1,4 @@
# type: ignore
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:

View File

@ -4,17 +4,25 @@
contain the root `toctree` directive.
.. image:: _static/logo.png
:width: 600
:width: 600px
:align: center
:alt: Manim Slide logo
Welcome to Manim Slide's CLI documentation!
===========================================
.. raw:: html
<!-- From: https://faq.dailymotion.com/hc/en-us/articles/360022841393-How-to-preserve-the-player-aspect-ratio-on-a-responsive-page -->
<div style="position:relative;padding-bottom:56.25%;"> <iframe 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>
This page contains an exhaustive list of all the commands available with `manim-slides`.
If you need help installing or using Manim Slide, please refer to the `GitHub README <https://github.com/jeertmans/manim-slides>`_.
.. click:: manim_slides.main:cli
.. click:: manim_slides.__main__:cli
:prog: manim-slides
:nested: full

View File

@ -1,3 +1,5 @@
# flake8: noqa: F403, F405
# type: ignore
import sys
if "manim" in sys.modules:
@ -40,6 +42,221 @@ class Example(Slide):
self.play(dot.animate.move_to(ORIGIN))
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()
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(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",
)
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:

6
manim-slides.qrc Normal file
View File

@ -0,0 +1,6 @@
<!DOCTYPE RCC>
<RCC version="1.0">
<qresource>
<file alias="icon.png">static/icon.png</file>
</qresource>
</RCC>

View File

@ -1,2 +1,3 @@
# flake8: noqa: F401
from .__version__ import __version__
from .slide import Slide, ThreeDSlide

73
manim_slides/__main__.py Normal file
View 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()

View File

@ -1 +1 @@
__version__ = "4.1.1"
__version__ = "4.7.0"

View File

@ -1,24 +1,31 @@
from typing import Callable
from typing import Any, Callable
import click
from click import Context, Parameter
from .defaults import CONFIG_PATH
from .defaults import CONFIG_PATH, FOLDER_PATH
from .manim import logger
F = Callable[..., Any]
Wrapper = Callable[[F], F]
def config_path_option(function) -> Callable:
def config_path_option(function: F) -> F:
"""Wraps a function to add configuration path option."""
return click.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,
)(function)
)
return wrapper(function)
def config_options(function) -> Callable:
def config_options(function: F) -> F:
"""Wraps a function to add configuration options."""
function = config_path_option(function)
function = click.option(
@ -31,3 +38,45 @@ def config_options(function) -> Callable:
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)

View File

@ -1,46 +1,72 @@
import os
import shutil
import subprocess
import tempfile
from enum import Enum
from typing import List, Optional, Set
from typing import Callable, Dict, List, Optional, Set, Union
from pydantic import BaseModel, root_validator, validator
from PySide6.QtCore import Qt
from .defaults import LEFT_ARROW_KEY_CODE, RIGHT_ARROW_KEY_CODE
from .manim import FFMPEG_BIN, logger
class Key(BaseModel):
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):
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):
return key_id in self.ids
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):
class Config(BaseModel): # type: ignore
"""General Manim Slides config"""
QUIT: Key = Key(ids=[ord("q")], name="QUIT")
CONTINUE: Key = Key(ids=[RIGHT_ARROW_KEY_CODE], name="CONTINUE / NEXT")
BACK: Key = Key(ids=[LEFT_ARROW_KEY_CODE], name="BACK")
REVERSE: Key = Key(ids=[ord("v")], name="REVERSE")
REWIND: Key = Key(ids=[ord("r")], name="REWIND")
PLAY_PAUSE: Key = Key(ids=[32], name="PLAY / PAUSE")
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):
ids = set()
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 distinc key codes"
"Two or more keys share a common key code: please make sure each key has distinct key codes"
)
ids.update(key.ids)
@ -61,7 +87,7 @@ class SlideType(str, Enum):
last = "last"
class SlideConfig(BaseModel):
class SlideConfig(BaseModel): # type: ignore
type: SlideType
start_animation: int
end_animation: int
@ -69,24 +95,26 @@ class SlideConfig(BaseModel):
terminated: bool = False
@validator("start_animation", "end_animation")
def index_is_posint(cls, v: int):
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):
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):
if values["start_animation"] >= values["end_animation"]:
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."
"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(
@ -95,22 +123,26 @@ class SlideConfig(BaseModel):
return values
def is_slide(self):
def is_slide(self) -> bool:
return self.type == SlideType.slide
def is_loop(self):
def is_loop(self) -> bool:
return self.type == SlideType.loop
def is_last(self):
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):
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):
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?"
@ -122,7 +154,9 @@ class PresentationConfig(BaseModel):
return v
@root_validator
def animation_indices_match_files(cls, values):
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")
@ -132,9 +166,88 @@ class PresentationConfig(BaseModel):
n_files = len(files)
for slide in slides:
if slide.end_animation > n_files:
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()

216
manim_slides/convert.py Normal file
View File

@ -0,0 +1,216 @@
import os
import webbrowser
from enum import Enum
from typing import Any, Callable, Dict, Generator, List, Type
import click
import pkg_resources
from click import Context, Parameter
from pydantic import BaseModel
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"
def convert_to(self, dest: str) -> None:
"""Converts self, i.e., a list of presentations, into a given format."""
raise NotImplementedError
def open(self, file: str) -> bool:
"""Opens a file, generated with converter, using appropriate application."""
return webbrowser.open(file)
@classmethod
def from_string(cls, s: str) -> Type["Converter"]:
"""Returns the appropriate converter from a string name."""
return {
"html": RevealJS,
}[s]
class JSBool(str, Enum):
true = "true"
false = "false"
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):
background_color: str = "black"
controls: JSBool = JSBool.false
embedded: JSBool = JSBool.false
fragments: JSBool = JSBool.false
height: str = "100%"
loop: JSBool = JSBool.false
progress: JSBool = JSBool.false
reveal_version: str = "3.7.0"
reveal_theme: RevealTheme = RevealTheme.black
shuffle: JSBool = JSBool.false
title: str = "Manim Slides"
width: str = "100%"
class Config:
use_enum_values = True
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))
if slide_config.is_loop():
yield f'<section data-background-video="{file}" data-background-video-loop></section>'
else:
yield f'<section data-background-video="{file}"></section>'
def load_template(self) -> str:
"""Returns the RevealJS HTML template as a string."""
return pkg_resources.resource_string(
__name__, "data/revealjs_template.html"
).decode()
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")
if to:
converter = Converter.from_string(to)(scenes=[])
for key, value in converter.dict().items():
click.echo(f"{key}: {repr(value)}")
ctx.exit()
else:
raise click.UsageError(
"Using --show-config option requires to first specify --to option."
)
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)
@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 `-cbackground_color=red` to set the background color to red (if supported).",
)
@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],
) -> None:
"""
Convert SCENE(s) into a given format and writes the result in DEST.
"""
presentation_configs = get_scenes_presentation_config(scenes, folder)
converter = Converter.from_string(to)(
presentation_configs=presentation_configs, **config_options
)
converter.convert_to(dest)
if open_result:
converter.open(dest)

View File

@ -0,0 +1,185 @@
<!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://cdn.jsdelivr.net/npm/reveal.js@{reveal_version}/css/reveal.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@{reveal_version}/css/theme/{reveal_theme}.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">
<!-- Printing and PDF exports -->
<script>
var link = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = window.location.search.match(/print-pdf/gi) ? 'https://cdn.jsdelivr.net/npm/reveal.js@{reveal_version}/css/print/pdf.css' : 'https://cdn.jsdelivr.net/npm/reveal.js@{reveal_version}/css/print/paper.css';
document.getElementsByTagName('head')[0].appendChild(link);
</script>
<!-- <link rel="stylesheet" href="index.css"> -->
</head>
<body>
<div class="reveal">
<div class="slides">
{sections}
</div>
</div>
<!--<script src="lib/js/head.min.js"></script>-->
<script src="https://cdn.jsdelivr.net/npm/headjs@1.0.3/dist/1.0.0/head.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/reveal.js@{reveal_version}/js/reveal.min.js"></script>
<!-- <script src="index.js"></script> -->
<script>
// More info about config & dependencies:
// - https://github.com/hakimel/reveal.js#configuration
// - https://github.com/hakimel/reveal.js#dependencies
Reveal.initialize({{
// Display controls in the bottom right corner
controls: {controls},
width: '{width}',
height: '{height}',
// Display a presentation progress bar
progress: {progress},
// Set default timing of 2 minutes per slide
defaultTiming: 120,
// Display the page number of the current slide
slideNumber: true,
// Push each slide change to the browser history
history: false,
// Enable keyboard shortcuts for navigation
keyboard: true,
// Enable the slide overview mode
overview: true,
// Vertical centering of slides
center: true,
// Enables touch navigation on devices with touch input
touch: true,
// Loop the presentation
loop: {loop},
// Change the presentation direction to be RTL
rtl: false,
// Randomizes the order of slides each time the presentation loads
shuffle: {shuffle},
// Turns fragments on and off globally
fragments: {fragments},
// 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 questionmark
// key is pressed
help: true,
// Flags if speaker notes should be visible to all viewers
showNotes: false,
// 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: null,
// Number of milliseconds between automatically proceeding to the
// next slide, disabled when set to 0, this value can be overwritten
// by using a data-autoslide attribute on your slides
autoSlide: 0,
// Stop auto-sliding after user input
autoSlideStoppable: true,
// Use this method for navigation when auto-sliding
autoSlideMethod: Reveal.navigateNext,
// Enable slide navigation via mouse wheel
mouseWheel: false,
// Hides the address bar on mobile devices
hideAddressBar: true,
// Opens links in an iframe preview overlay
previewLinks: true,
// Transition style
transition: 'none', // none/fade/slide/convex/concave/zoom
// Transition speed
transitionSpeed: 'default', // default/fast/slow
// Transition style for full page slide backgrounds
backgroundTransition: 'none', // none/fade/slide/convex/concave/zoom
// Number of slides away from the current that are visible
viewDistance: 3,
// Parallax background image
parallaxBackgroundImage: '', // e.g. "'https://s3.amazonaws.com/hakim-static/reveal-js/reveal-parallax-1.jpg'"
// Parallax background size
parallaxBackgroundSize: '', // CSS syntax, e.g. "2100px 900px"
// Number of pixels to move the parallax background per slide
// - Calculated automatically unless specified
// - Set to 0 to disable movement along an axis
parallaxBackgroundHorizontal: null,
parallaxBackgroundVertical: null,
// The display mode that will be used to show slides
display: 'block',
/*
multiplex: {{
// Example values. To generate your own, see the socket.io server instructions.
secret: '13652805320794272084', // Obtained from the socket.io server. Gives this (the master) control of the presentation
id: '1ea875674b17ca76', // Obtained from socket.io server
url: 'https://reveal-js-multiplex-ccjbegmaii.now.sh' // Location of socket.io server
}},
*/
dependencies: [
{{ src: 'https://cdn.jsdelivr.net/npm/reveal.js@{reveal_version}/plugin/markdown/marked.js' }},
{{ src: 'https://cdn.jsdelivr.net/npm/reveal.js@{reveal_version}/plugin/markdown/markdown.js' }},
{{ src: 'https://cdn.jsdelivr.net/npm/reveal.js@{reveal_version}/plugin/notes/notes.js', async: true }},
{{ src: 'https://cdn.jsdelivr.net/npm/reveal.js@{reveal_version}/plugin/highlight/highlight.js', async: true, callback: function () {{ hljs.initHighlightingOnLoad(); }} }},
//{{ src: '//cdn.socket.io/socket.io-1.3.5.js', async: true }},
//{{ src: 'plugin/multiplex/master.js', async: true }},
// and if you want speaker notes
{{ src: 'https://cdn.jsdelivr.net/npm/reveal.js@{reveal_version}/plugin/notes-server/client.js', async: true }}
],
markdown: {{
// renderer: myrenderer,
smartypants: true
}}
}});
Reveal.configure({{
// PDF Configurations
pdfMaxPagesPerSlide: 1
}});
</script>
</body>
</html>

View File

@ -1,14 +1,2 @@
import platform
import cv2
FONT_ARGS = (cv2.FONT_HERSHEY_SIMPLEX, 1, 255, 1, cv2.LINE_AA)
FOLDER_PATH: str = "./slides"
CONFIG_PATH: str = ".manim-slides.json"
if platform.system() == "Windows":
RIGHT_ARROW_KEY_CODE = 2555904
LEFT_ARROW_KEY_CODE = 2424832
else:
RIGHT_ARROW_KEY_CODE = 65363
LEFT_ARROW_KEY_CODE = 65361

View File

@ -1,27 +0,0 @@
import click
from click_default_group import DefaultGroup
from . import __version__
from .present import list_scenes, present
from .wizard import init, wizard
@click.group(cls=DefaultGroup, default="present", default_if_no_args=True)
@click.version_option(__version__, "-v", "--version")
@click.help_option("-h", "--help")
def cli():
"""
Manim Slides command-line utilities.
If not command is specified, defaults to `present`.
"""
pass
cli.add_command(list_scenes)
cli.add_command(present)
cli.add_command(wizard)
cli.add_command(init)
if __name__ == "__main__":
cli()

View File

@ -2,10 +2,27 @@ 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():
def suppress_stdout() -> Iterator[None]:
with open(os.devnull, "w") as devnull:
old_stdout = sys.stdout
sys.stdout = devnull
@ -49,8 +66,6 @@ else:
)
FFMPEG_BIN = None
if MANIMGL:
from manimlib import Scene, ThreeDScene, config
from manimlib.constants import FFMPEG_BIN
@ -61,6 +76,6 @@ else:
from manim import Scene, ThreeDScene, config, logger
try: # For manim<v0.16.0.post0
from manim.constants import FFMPEG_BIN as FFMPEG_BIN
from manim.constants import FFMPEG_BIN
except ImportError:
FFMPEG_BIN = config.ffmpeg_executable

View File

@ -1,36 +1,43 @@
import json
import math
import os
import platform
import sys
import time
from enum import IntEnum, auto, unique
from typing import List, Tuple
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
from .config import Config, PresentationConfig, SlideConfig, SlideType
from .defaults import CONFIG_PATH, FOLDER_PATH, FONT_ARGS
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
INTERPOLATION_FLAGS = {
"nearest": cv2.INTER_NEAREST,
"linear": cv2.INTER_LINEAR,
"cubic": cv2.INTER_CUBIC,
"area": cv2.INTER_AREA,
"lanczos4": cv2.INTER_LANCZOS4,
"linear-exact": cv2.INTER_LINEAR_EXACT,
"nearest-exact": cv2.INTER_NEAREST_EXACT,
}
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"
ASPECT_RATIO_MODES = {
"ignore": Qt.IgnoreAspectRatio,
"keep": Qt.KeepAspectRatio,
}
RESIZE_MODES = {
"fast": Qt.FastTransformation,
"smooth": Qt.SmoothTransformation,
}
@unique
class State(IntEnum):
@ -45,37 +52,31 @@ class State(IntEnum):
return self.name.capitalize()
def now() -> int:
"""Returns time.time() in milliseconds."""
return round(time.time() * 1000)
def fix_time(t: float) -> float:
"""Clips time t such that it is always positive."""
return t if t > 0 else 1
def now() -> float:
"""Returns time.time() in seconds."""
return time.time()
class Presentation:
"""Creates presentation from a configuration object."""
def __init__(self, config: PresentationConfig):
def __init__(self, config: PresentationConfig) -> None:
self.slides: List[SlideConfig] = config.slides
self.files: List[str] = config.files
self.current_slide_index = 0
self.current_animation = self.current_slide.start_animation
self.current_file = None
self.current_slide_index: int = 0
self.current_animation: int = self.current_slide.start_animation
self.current_file: str = ""
self.loaded_animation_cap = -1
self.loaded_animation_cap: int = -1
self.cap = None # cap = cv2.VideoCapture
self.reverse = False
self.reversed_animation = -1
self.reverse: bool = False
self.reversed_animation: int = -1
self.lastframe = None
self.lastframe: Optional[np.ndarray] = None
self.reset()
self.add_last_slide()
@property
def current_slide(self) -> SlideConfig:
@ -92,23 +93,25 @@ class Presentation:
"""Returns last slide."""
return self.slides[-1]
def release_cap(self):
def release_cap(self) -> None:
"""Releases current Video Capture, if existing."""
if not self.cap is None:
if self.cap is not None:
self.cap.release()
self.loaded_animation_cap = -1
def load_animation_cap(self, animation: int):
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 = self.files[animation]
file: str = self.files[animation]
if self.reverse:
file = "{}_reversed{}".format(*os.path.splitext(file))
@ -125,29 +128,33 @@ class Presentation:
self.load_animation_cap(self.current_animation)
return self.cap
def rewind_current_slide(self):
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
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
cap = self.current_cap
cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
def cancel_reverse(self):
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):
def reverse_current_slide(self) -> None:
"""Reverses current slide."""
self.reverse = True
self.rewind_current_slide()
def load_next_slide(self):
def load_next_slide(self) -> None:
"""Loads next slide."""
logger.debug("Loading next slide")
if self.reverse:
self.cancel_reverse()
self.rewind_current_slide()
@ -159,8 +166,9 @@ class Presentation:
)
self.rewind_current_slide()
def load_previous_slide(self):
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()
@ -168,27 +176,21 @@ class Presentation:
@property
def fps(self) -> int:
"""Returns the number of frames per second of the current video."""
return self.current_cap.get(cv2.CAP_PROP_FPS)
def add_last_slide(self):
"""Add a 'last' slide to the end of slides."""
self.slides.append(
SlideConfig(
start_animation=self.last_slide.end_animation,
end_animation=self.last_slide.end_animation + 1,
type=SlideType.last,
number=self.last_slide.number + 1,
)
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):
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):
def load_last_slide(self) -> None:
"""Loads last slide."""
self.current_slide_index = len(self.slides) - 2
assert (
@ -219,7 +221,7 @@ class Presentation:
"""Returns current frame number."""
return int(self.current_cap.get(cv2.CAP_PROP_POS_FRAMES))
def update_state(self, state) -> Tuple[np.ndarray, State]:
def update_state(self, state: State) -> Tuple[np.ndarray, State]:
"""
Updates the current state given the previous one.
@ -233,7 +235,7 @@ class Presentation:
still_playing, frame = self.current_cap.read()
if still_playing:
self.lastframe = frame
elif state in [state.WAIT, state.PAUSED]:
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
@ -265,65 +267,47 @@ class Presentation:
return self.lastframe, state
class Display:
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,
config,
start_paused=False,
fullscreen=False,
skip_all=False,
resolution=(1980, 1080),
interpolation_flag=cv2.INTER_LINEAR,
record_to=None,
):
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.fullscreen = fullscreen
self.resolution = resolution
self.interpolation_flag = interpolation_flag
self.record_to = record_to
self.recordings = []
self.window_flags = (
cv2.WINDOW_GUI_NORMAL | cv2.WINDOW_FREERATIO | cv2.WINDOW_NORMAL
)
self.recordings: List[Tuple[str, int, int]] = []
self.state = State.PLAYING
self.lastframe = None
self.lastframe: Optional[np.ndarray] = None
self.current_presentation_index = 0
self.exit = False
self.run_flag = True
self.lag = 0
self.last_time = now()
cv2.namedWindow(
WINDOW_INFO_NAME,
cv2.WINDOW_GUI_NORMAL | cv2.WINDOW_FREERATIO | cv2.WINDOW_AUTOSIZE,
)
if self.fullscreen:
cv2.namedWindow(
WINDOW_NAME, cv2.WINDOW_GUI_NORMAL | cv2.WND_PROP_FULLSCREEN
)
cv2.setWindowProperty(
WINDOW_NAME, cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN
)
else:
cv2.namedWindow(WINDOW_NAME, self.window_flags)
cv2.resizeWindow(WINDOW_NAME, *self.resolution)
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):
def run(self) -> None:
"""Runs a series of presentations until end or exit."""
while not self.exit:
while self.run_flag:
last_time = now()
self.lastframe, self.state = self.current_presentation.update_state(
self.state
)
@ -333,88 +317,98 @@ class Display:
self.start_paused = False
if self.state == State.END:
if self.current_presentation_index == len(self.presentations) - 1:
self.quit()
if self.exit_after_last_slide:
self.run_flag = False
continue
else:
self.current_presentation_index += 1
self.state = State.PLAYING
self.handle_key()
if self.exit:
continue
self.show_video()
self.show_info()
def show_video(self):
"""Shows updated video."""
self.lag = now() - self.last_time
self.last_time = now()
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 not self.record_to is None:
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 = self.lastframe
frame: np.ndarray = self.lastframe
self.change_video_signal.emit(frame)
# If Window was manually closed (impossible in fullscreen),
# we reopen it
if cv2.getWindowProperty(WINDOW_NAME, cv2.WND_PROP_VISIBLE) < 1:
cv2.namedWindow(WINDOW_NAME, self.window_flags)
cv2.resizeWindow(WINDOW_NAME, *self.resolution)
if WINDOWS: # Only resize on Windows
_, _, w, h = cv2.getWindowImageRect(WINDOW_NAME)
if (h, w) != frame.shape[:2]: # Only if shape is different
frame = cv2.resize(frame, (w, h), self.interpolation_flag)
cv2.imshow(WINDOW_NAME, frame)
def show_info(self):
def show_info(self) -> None:
"""Shows updated information about presentations."""
info = np.zeros((130, 420), np.uint8)
font_args = (FONT_ARGS[0], 0.7, *FONT_ARGS[2:])
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,
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),
}
)
cv2.putText(
info,
f"Scene {self.current_presentation_index + 1}/{len(self.presentations)}",
((grid_x[0] + grid_x[1]) // 2, grid_y[2]),
*font_args,
)
@Slot(int)
def set_key(self, key: int) -> None:
"""Sets the next key to be handled."""
self.key = key
cv2.imshow(WINDOW_INFO_NAME, info)
def handle_key(self):
def handle_key(self) -> None:
"""Handles key strokes."""
sleep_time = math.ceil(1000 / self.current_presentation.fps)
key = cv2.waitKeyEx(fix_time(sleep_time - self.lag))
key = self.key
if self.config.QUIT.match(key):
self.quit()
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):
@ -447,138 +441,217 @@ class Display:
self.current_presentation.rewind_current_slide()
self.state = State.PLAYING
def quit(self):
"""Destroys all windows created by presentations and exits gracefully."""
cv2.destroyAllWindows()
self.key = -1 # No more key to be handled
if not self.record_to is None and len(self.recordings) > 0:
file, frame_number, fps = self.recordings[0]
def stop(self) -> None:
"""Stops current thread, without doing anything after."""
self.run_flag = False
self.wait()
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))
class Info(QWidget): # type: ignore
def __init__(self) -> None:
super().__init__()
self.setWindowTitle(WINDOW_INFO_NAME)
out.write(frame)
self.layout = QGridLayout()
for _file, frame_number, _ in tqdm(
self.recordings[1:], desc="Creating recording file", leave=False
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: Qt.AspectRatioMode = Qt.IgnoreAspectRatio,
resize_mode: Qt.TransformationMode = Qt.SmoothTransformation,
background_color: str = "black",
**kwargs: Any,
):
if file != _file:
cap.release()
file = _file
cap = cv2.VideoCapture(_file)
super().__init__()
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number - 1)
_, frame = cap.read()
out.write(frame)
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)
cap.release()
out.release()
self.label = QLabel(self)
self.label.setAlignment(Qt.AlignCenter)
self.label.resize(self.display_width, self.display_height)
self.label.setStyleSheet(f"background-color: {background_color}")
self.exit = True
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:
self.pixmap = self.pixmap.scaled(
self.width(), self.height(), self.aspect_ratio, 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 w != self.width() or h != self.height():
qt_img = qt_img.scaled(
self.width(), self.height(), self.aspect_ratio, 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")
def list_scenes(folder):
@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) -> List[str]:
def _list_scenes(folder: str) -> List[str]:
"""Lists available scenes in given directory."""
scenes = []
for file in os.listdir(folder):
if file.endswith(".json"):
try:
filepath = os.path.join(folder, file)
try:
_ = PresentationConfig.parse_file(filepath)
scenes.append(os.path.basename(file)[:-5])
except Exception: # Could not parse this file as a proper presentation config
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
@click.command()
@click.argument("scenes", nargs=-1)
@config_path_option
@click.option(
"--folder",
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(
"--skip-all",
is_flag=True,
help="Skip all slides, useful the test if slides are working.",
)
@click.option(
"-r",
"--resolution",
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(
"-i",
"--interpolation-flag",
type=click.Choice(INTERPOLATION_FLAGS.keys(), case_sensitive=False),
default="linear",
help="Set the interpolation flag to be used when resizing image. See OpenCV cv::InterpolationFlags.",
show_default=True,
)
@click.option(
"--record-to",
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.help_option("-h", "--help")
def present(
scenes,
config_path,
folder,
start_paused,
fullscreen,
skip_all,
resolution,
interpolation_flag,
record_to,
):
"""
Present SCENE(s), one at a time, in order.
def prompt_for_scenes(folder: str) -> List[str]:
"""Prompts the user to select scenes within a given folder."""
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 len(scenes) == 0:
scene_choices = _list_scenes(folder)
scene_choices = dict(enumerate(scene_choices, start=1))
scene_choices = dict(enumerate(_list_scenes(folder), start=1))
for i, scene in scene_choices.items():
click.secho(f"{i}: {scene}", fg="green")
@ -588,13 +661,11 @@ def present(
click.echo("Choose number corresponding to desired scene/arguments.")
click.echo("(Use comma separated list for multiple entries)")
def value_proc(value: str):
indices = list(map(int, value.strip().replace(" ", "").split(",")))
def value_proc(value: Optional[str]) -> List[str]:
indices = list(map(int, (value or "").strip().replace(" ", "").split(",")))
if not all(map(lambda i: 0 < i <= len(scene_choices), indices)):
raise click.UsageError(
"Please only enter numbers displayed on the screen."
)
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]
@ -606,11 +677,20 @@ def present(
while True:
try:
scenes = click.prompt("Choice(s)", value_proc=value_proc)
break
return scenes
except ValueError as e:
raise click.UsageError(e)
raise click.UsageError(str(e))
presentations = list()
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):
@ -618,34 +698,151 @@ def present(
f"File {config_file} does not exist, check the scene name and make sure to use Slide as your scene base class"
)
try:
config = PresentationConfig.parse_file(config_file)
presentations.append(Presentation(config))
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="ignore",
help="Set the aspect ratio mode to be used when rescaling video.",
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 not record_to is None:
if record_to is not None:
_, ext = os.path.splitext(record_to)
if ext.lower() != ".avi":
raise click.UsageError(
f"Recording only support '.avi' extension. For other video formats, please convert the resulting '.avi' file afterwards."
"Recording only support '.avi' extension. For other video formats, please convert the resulting '.avi' file afterwards."
)
display = Display(
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,
interpolation_flag=INTERPOLATION_FLAGS[interpolation_flag],
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,
)
display.run()
a.show()
sys.exit(app.exec_())
logger.debug("After")

171
manim_slides/resources.py Normal file
View 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()

View File

@ -1,9 +1,8 @@
import json
import os
import platform
import shutil
import subprocess
from typing import List
from typing import Any, List, Optional
from tqdm import tqdm
@ -12,19 +11,28 @@ from .defaults import FOLDER_PATH
from .manim import FFMPEG_BIN, MANIMGL, Scene, ThreeDScene, config, logger
def reverse_video_file(src: str, dst: str):
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)
process.communicate()
output, error = process.communicate()
if output:
logger.debug(output.decode())
if error:
logger.debug(error.decode())
class Slide(Scene):
class Slide(Scene): # type:ignore
"""
Inherits from `manim.Scene` or `manimlib.Scene` and provide necessary tools for slides rendering.
"""
def __init__(self, *args, output_folder=FOLDER_PATH, **kwargs):
def __init__(
self, *args: Any, output_folder: str = FOLDER_PATH, **kwargs: Any
) -> None:
if MANIMGL:
if not os.path.isdir("videos"):
os.mkdir("videos")
@ -39,10 +47,10 @@ class Slide(Scene):
super().__init__(*args, **kwargs)
self.output_folder = output_folder
self.slides = list()
self.slides: List[SlideConfig] = []
self.current_slide = 1
self.current_animation = 0
self.loop_start_animation = None
self.loop_start_animation: Optional[int] = None
self.pause_start_animation = 0
@property
@ -70,19 +78,19 @@ class Slide(Scene):
return config["progress_bar"] != "none"
@property
def leave_progress_bar(self):
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, **kwargs):
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):
def pause(self) -> None:
"""Creates a new slide with previous animations."""
self.slides.append(
SlideConfig(
@ -95,12 +103,23 @@ class Slide(Scene):
self.current_slide += 1
self.pause_start_animation = self.current_animation
def start_loop(self):
def add_last_slide(self) -> None:
"""Adds a 'last' slide to the end of slides."""
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):
def end_loop(self) -> None:
"""Ends an existing loop."""
assert (
self.loop_start_animation is not None
@ -117,12 +136,14 @@ class Slide(Scene):
self.loop_start_animation = None
self.pause_start_animation = self.current_animation
def save_slides(self, use_cache=True):
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)
@ -143,7 +164,7 @@ class Slide(Scene):
else:
old_animation_files.update(os.listdir(scene_files_folder))
files = list()
files = []
for src_file in tqdm(
self.partial_movie_files,
desc=f"Copying animation files to '{scene_files_folder}' and generating reversed animations",
@ -183,12 +204,12 @@ class Slide(Scene):
f"Slide '{scene_name}' configuration written in '{os.path.abspath(slide_path)}'"
)
def run(self, *args, **kwargs):
def run(self, *args: Any, **kwargs: Any) -> None:
"""MANIMGL renderer"""
super().run(*args, **kwargs)
self.save_slides(use_cache=False)
def render(self, *args, **kwargs):
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"]
@ -201,7 +222,7 @@ class Slide(Scene):
self.save_slides()
class ThreeDSlide(Slide, ThreeDScene):
class ThreeDSlide(Slide, ThreeDScene): # type: ignore
"""
Inherits from `manim.ThreeDScene` or `manimlib.ThreeDScene` and provide necessary tools for slides rendering.

View File

@ -1,45 +1,137 @@
import os
import sys
from functools import partial
from typing import Any
import click
import cv2
import numpy as np
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
from .config import Config
from .defaults import CONFIG_PATH, FONT_ARGS
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 = "Manim Slides Configuration Wizard"
WINDOW_SIZE = (120, 620)
WINDOW_NAME: str = "Configuration Wizard"
keymap = {}
for key in Qt.Key:
keymap[key.value] = key.name.partition("_")[2]
def center_text_horizontally(text, window_size, font_args) -> int:
"""Returns centered position for text to be displayed in current window."""
_, width = window_size
font, scale, _, thickness, _ = font_args
(size_in_pixels, _), _ = cv2.getTextSize(text, font, scale, thickness)
return (width - size_in_pixels) // 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()
def prompt(question: str) -> int:
"""Diplays some question in current window and waits for key press."""
display = np.zeros(WINDOW_SIZE, np.uint8)
class Wizard(QWidget): # type: ignore
def __init__(self, config: Config):
text = "* Manim Slides Wizard *"
text_org = center_text_horizontally(text, WINDOW_SIZE, FONT_ARGS), 33
question_org = center_text_horizontally(question, WINDOW_SIZE, FONT_ARGS), 85
super().__init__()
cv2.putText(display, "* Manim Slides Wizard *", text_org, *FONT_ARGS)
cv2.putText(display, question, question_org, *FONT_ARGS)
self.setWindowTitle(WINDOW_NAME)
self.config = config
self.icon = QIcon(":/icon.png")
self.setWindowIcon(self.icon)
cv2.imshow(WINDOW_NAME, display)
return cv2.waitKeyEx(-1)
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")
def wizard(config_path, force, merge):
@verbosity_option
def wizard(config_path: str, force: bool, merge: bool) -> None:
"""Launch configuration wizard."""
return _init(config_path, force, merge, skip_interactive=False)
@ -47,12 +139,17 @@ def wizard(config_path, force, merge):
@click.command()
@config_options
@click.help_option("-h", "--help")
def init(config_path, force, merge, skip_interactive=False):
@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, force, merge, skip_interactive=False):
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):
@ -67,27 +164,28 @@ def _init(config_path, force, merge, skip_interactive=False):
force = choice == "o"
merge = choice == "m"
if force:
click.secho("Overwriting.")
elif merge:
click.secho("Merging.")
else:
click.secho("Exiting.")
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)
cv2.namedWindow(
WINDOW_NAME,
cv2.WINDOW_GUI_NORMAL | cv2.WINDOW_FREERATIO | cv2.WINDOW_AUTOSIZE,
)
app = QApplication(sys.argv)
app.setApplicationName("Manim Slides Wizard")
window = Wizard(config)
window.show()
app.exec()
prompt("Press any key to continue")
for _, key in config:
key.ids = [prompt(f"Press the {key.name} key")]
config = window.config
if merge:
config = Config.parse_file(config_path).merge_with(config)
@ -95,4 +193,4 @@ def _init(config_path, force, merge, skip_interactive=False):
with open(config_path, "w") as config_file:
config_file.write(config.json(indent=2))
click.echo(f"Configuration file successfully save to `{config_path}`")
click.secho(f"Configuration file successfully saved to `{config_path}`")

View File

@ -1,2 +1,51 @@
[tool.vulture]
paths = ["manim_slides"]
[build-system]
requires = ["setuptools", "setuptools-scm"]
build-backend = "setuptools.build_meta"
[project]
name = "manim-slides"
description = "Tool for live presentations using manim"
authors = [
{ name = "Jérome Eertmans", email = "jeertmans@icloud.com" }
]
version = "4.7.0"
license = {file = "LICENSE.md"}
readme = "README.md"
requires-python = ">=3.8"
keywords = ["manim", "slides", "plugin", "manimgl"]
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",
]
dependencies = [
"click>=8.0",
"click-default-group>=1.2",
"numpy>=1.19.3",
"opencv-python>=4.6",
"pydantic>=1.9.1",
"pyside6>=6",
"requests>=2.26.0",
"tqdm>=4.62.3",
]
[tool.setuptools]
packages = ["manim_slides"]
[project.urls]
homepage = "https://github.com/jeertmans/manim-slides"
documentation = "https://eertmans.be/manim-slides"
repository = "https://github.com/jeertmans/manim-slides"
[project.scripts]
manim-slides = "manim_slides.__main__:cli"

View File

@ -1,53 +0,0 @@
import importlib.util
import os
import sys
import setuptools
if sys.version_info < (3, 7):
raise RuntimeError("This package requires Python 3.7+")
spec = importlib.util.spec_from_file_location(
"__version__", os.path.join("manim_slides", "__version__.py")
)
version = importlib.util.module_from_spec(spec)
spec.loader.exec_module(version)
with open("README.md", "r") as f:
long_description = f.read()
setuptools.setup(
name="manim-slides",
version=version.__version__,
author="Jérome Eertmans (previously, Federico A. Galatolo)",
author_email="jeertmans@icloud.com (resp., federico.galatolo@ing.unipi.it)",
description="Tool for live presentations using manim",
url="https://github.com/jeertmans/manim-slides",
long_description=long_description,
long_description_content_type="text/markdown",
packages=setuptools.find_packages(),
entry_points={
"console_scripts": [
"manim-slides=manim_slides.main:cli",
],
},
python_requires=">=3.7",
install_requires=[
"click>=8.0",
"click-default-group>=1.2",
"numpy>=1.19.3",
"pydantic>=1.9.1",
"opencv-python>=4.6",
"tqdm>=4.62.3",
],
classifiers=[
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Operating System :: OS Independent",
],
)

BIN
static/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -1,3 +1,5 @@
# flake8: noqa: F403, F405
# type: ignore
from manim import *