mirror of
https://github.com/jeertmans/manim-slides.git
synced 2025-05-19 03:26:17 +08:00
Compare commits
93 Commits
v5.0.0-rc3
...
v5.1.4
Author | SHA1 | Date | |
---|---|---|---|
49e2c31d9a | |||
5920a843f5 | |||
59dd365291 | |||
3e2e64b09f | |||
8a3bf87db8 | |||
498e9af2bf | |||
24ee23af11 | |||
a775c4989b | |||
04f6ee7f9b | |||
bbc539b461 | |||
2d9d263c9c | |||
cfa9c082ab | |||
67533c460e | |||
a85f1c4036 | |||
b3fe6f17b9 | |||
e7182a445d | |||
1dbd2fdde5 | |||
07fd2bdcf1 | |||
d586dab102 | |||
0ff1f37475 | |||
92c569950c | |||
648d7ff921 | |||
b47068ede5 | |||
973522a2ac | |||
2f82ca3409 | |||
e208cced03 | |||
abbe577aae | |||
38ef91d30c | |||
b17fd5409f | |||
186badba03 | |||
39816d4994 | |||
9cb1dae990 | |||
9e4c1d76ee | |||
0316adf69e | |||
fd9b06b955 | |||
16f740d2ad | |||
f260d0d310 | |||
39ff80ed1e | |||
b84a22d0f9 | |||
7ba47728ff | |||
02a8173ed1 | |||
07dff32be3 | |||
e60edcc960 | |||
c1d2dc26b2 | |||
0db4a8c260 | |||
cb4f6f552c | |||
9cd50e73b2 | |||
cea3d0b0c3 | |||
75126d8bab | |||
be227bbdea | |||
62560ea41f | |||
4b8f90c1fa | |||
8b1c45b84e | |||
34cc66d8be | |||
57e11a0ea7 | |||
de342b32bb | |||
6bd431d748 | |||
2e996c03a7 | |||
d9715ccb96 | |||
e3b3dd677f | |||
db91de1412 | |||
bf67a7b695 | |||
ea2d352fc1 | |||
74ddefe519 | |||
20b7ef4110 | |||
19a31a9136 | |||
d2925340aa | |||
afeaa0d793 | |||
af3c4971ae | |||
b3ed127e31 | |||
fc200f22f5 | |||
050ee0ae78 | |||
a9b8081167 | |||
dc58d498a8 | |||
f898dd3054 | |||
b09a000c17 | |||
eb8efa8e3d | |||
1f0c94dc5c | |||
2771aac4d0 | |||
ce799aeded | |||
891273b2fc | |||
2b25b6a89d | |||
fc594533e9 | |||
422102524f | |||
739cbcee0a | |||
3b3e3109a3 | |||
8921c3b8f9 | |||
5eb23dc5c1 | |||
a890832a4d | |||
106c7d4c06 | |||
6c52906037 | |||
2853ed08e1 | |||
760ceb8ce1 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 5.0.0-rc3
|
||||
current_version = 5.1.4
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(-rc(?P<release>\d+))?
|
||||
serialize =
|
||||
{major}.{minor}.{patch}-rc{release}
|
||||
@ -11,10 +11,6 @@ message = chore(version): bump {current_version} to {new_version}
|
||||
search = __version__ = "{current_version}"
|
||||
replace = __version__ = "{new_version}"
|
||||
|
||||
[bumpversion:file:pyproject.toml]
|
||||
search = version = "{current_version}"
|
||||
replace = version = "{new_version}"
|
||||
|
||||
[bumpversion:file:CITATION.cff]
|
||||
search = version: v{current_version}
|
||||
replace = version: v{new_version}
|
||||
|
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@ -42,7 +42,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@ -56,7 +56,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@ -69,4 +69,4 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
uses: github/codeql-action/analyze@v3
|
||||
|
2
.github/workflows/draft-pdf.yml
vendored
2
.github/workflows/draft-pdf.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
# This should be the path to the paper within your repo.
|
||||
paper-path: paper/paper.md
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: paper
|
||||
# This is the output path where Pandoc will write the compiled
|
||||
|
13
.github/workflows/latest_tag.yml
vendored
Normal file
13
.github/workflows/latest_tag.yml
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
name: Keep the versions up-to-date
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published, edited]
|
||||
|
||||
jobs:
|
||||
actions-tagger:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: Actions-R-Us/actions-tagger@latest
|
||||
with:
|
||||
publish_latest_tag: true
|
86
.github/workflows/pages.yml
vendored
86
.github/workflows/pages.yml
vendored
@ -1,86 +0,0 @@
|
||||
# Simple workflow for deploying static content to GitHub Pages
|
||||
name: Deploy static content to Pages
|
||||
|
||||
on:
|
||||
# Runs on pushes targeting the default branch
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
pull_request:
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# Allow one concurrent deployment
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Single deploy job since we're just deploying
|
||||
deploy:
|
||||
permissions: write-all
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Poetry
|
||||
run: pipx install poetry
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.9'
|
||||
cache: poetry
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v3
|
||||
- name: Install Linux Dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev
|
||||
- name: Setup Pandoc
|
||||
uses: nikeee/setup-pandoc@v1
|
||||
- name: Install local Python package
|
||||
run: poetry install --with docs
|
||||
- name: Install IPython kernel
|
||||
run: poetry run ipython kernel install --name "manim-slides" --user
|
||||
- name: Restore cached media
|
||||
id: cache-media-restore
|
||||
uses: actions/cache/restore@v3
|
||||
with:
|
||||
path: docs/media
|
||||
key: ${{ runner.os }}-docs-media
|
||||
- name: Clear cache
|
||||
run: |
|
||||
gh extension install actions/gh-actions-cache
|
||||
gh actions-cache delete ${{ steps.cache-media-restore.outputs.cache-primary-key }} --confirm || true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Save media to cache
|
||||
id: cache-media-save
|
||||
uses: actions/cache/save@v3
|
||||
with:
|
||||
path: docs/media
|
||||
key: ${{ steps.cache-media-restore.outputs.cache-primary-key }}
|
||||
- name: Build docs
|
||||
run: cd docs && poetry run make html
|
||||
- name: Upload artifact
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-pages-artifact@v2
|
||||
with:
|
||||
# Upload docs/build/html dir
|
||||
path: docs/build/html/
|
||||
- name: Show docs/build/html/_static/ dir content (video only)
|
||||
run: tree -L 3 docs/build/html/_static/ -P '*.mp4'
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/deploy-pages@v2
|
71
.github/workflows/publish.yml
vendored
Normal file
71
.github/workflows/publish.yml
vendored
Normal file
@ -0,0 +1,71 @@
|
||||
name: Upload Packages
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
publish-python:
|
||||
name: Publish Python package
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
# IMPORTANT: this permission is mandatory for trusted publishing
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install PDM
|
||||
uses: pdm-project/setup-pdm@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: true
|
||||
|
||||
- name: Publish to PyPI
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
run: pdm publish
|
||||
|
||||
publish-docker:
|
||||
name: Publish Docker image
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: create_release
|
||||
shell: python
|
||||
env:
|
||||
tag_act: ${{ github.ref }}
|
||||
run: |
|
||||
import os
|
||||
ref_tag = os.getenv('tag_act').split('/')[-1]
|
||||
with open(os.getenv('GITHUB_OUTPUT'), 'w') as f:
|
||||
print(f"tag_name={ref_tag}", file=f)
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
platforms: linux/arm64,linux/amd64
|
||||
file: docker/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/jeertmans/manim-slides:latest
|
||||
ghcr.io/jeertmans/manim-slides:${{ steps.create_release.outputs.tag_name }}
|
34
.github/workflows/python-publish.yml
vendored
34
.github/workflows/python-publish.yml
vendored
@ -1,34 +0,0 @@
|
||||
name: Upload Python Package
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build_and_release:
|
||||
name: Build and release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Poetry
|
||||
run: pipx install poetry
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: poetry
|
||||
|
||||
- name: Build wheels
|
||||
run: poetry build
|
||||
|
||||
- name: Publish to PyPI
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
env:
|
||||
POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }}
|
||||
run: poetry publish
|
26
.github/workflows/tests.yml
vendored
26
.github/workflows/tests.yml
vendored
@ -1,4 +1,7 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@ -10,11 +13,11 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-latest, windows-latest]
|
||||
pyversion: ['3.8', '3.9', '3.10', '3.11']
|
||||
pyversion: ['3.9', '3.10', '3.11', '3.12']
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
QT_QPA_PLATFORM: offscreen
|
||||
MANIM_SLIDES_VERBOSITY: debug
|
||||
MANIM_SLIDES_VERBOSITY: error
|
||||
PYTHONFAULTHANDLER: 1
|
||||
DISPLAY: :99
|
||||
GITHUB_WORKFLOWS: 1
|
||||
@ -22,14 +25,11 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Poetry
|
||||
run: pipx install poetry
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v4
|
||||
- name: Install PDM
|
||||
uses: pdm-project/setup-pdm@v4
|
||||
with:
|
||||
python-version: ${{ matrix.pyversion }}
|
||||
cache: poetry
|
||||
cache: true
|
||||
|
||||
# Path related stuff
|
||||
- name: Append to Path on MacOS
|
||||
@ -65,23 +65,23 @@ jobs:
|
||||
|
||||
- name: Install Mesa
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: ssciwr/setup-mesa-dist-win@v1
|
||||
uses: ssciwr/setup-mesa-dist-win@v2
|
||||
|
||||
- name: Install Manim Slides
|
||||
run: |
|
||||
poetry install --with test --all-extras
|
||||
pdm sync -Ggithub-action -Gtest
|
||||
|
||||
- name: Run pytest
|
||||
if: matrix.os != 'ubuntu-latest' || matrix.pyversion != '3.11'
|
||||
run: poetry run pytest
|
||||
run: pdm run pytest
|
||||
|
||||
- name: Run pytest and coverage
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.pyversion == '3.11'
|
||||
run: poetry run pytest --cov-report xml --cov=manim_slides tests/
|
||||
run: pdm run pytest --cov-report xml --cov=manim_slides tests/
|
||||
|
||||
- name: Upload to codecov.io
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.pyversion == '3.11'
|
||||
uses: codecov/codecov-action@v3
|
||||
uses: codecov/codecov-action@v4
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -4,6 +4,7 @@ __pycache__/
|
||||
/build
|
||||
/dist
|
||||
*.egg-info/
|
||||
.pdm-python
|
||||
|
||||
# Manim files
|
||||
images/
|
||||
|
@ -1,36 +1,31 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
- id: check-yaml
|
||||
- id: check-toml
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
|
||||
rev: v2.11.0
|
||||
rev: v2.13.0
|
||||
hooks:
|
||||
- id: pretty-format-yaml
|
||||
args: [--autofix]
|
||||
- id: pretty-format-toml
|
||||
exclude: poetry.lock
|
||||
args: [--autofix]
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.9.1
|
||||
args: [--autofix, --trailing-commas]
|
||||
- repo: https://github.com/keewis/blackdoc
|
||||
rev: v0.3.9
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/adamchainz/blacken-docs
|
||||
rev: 1.16.0
|
||||
hooks:
|
||||
- id: blacken-docs
|
||||
additional_dependencies:
|
||||
- black==23.9.1
|
||||
- id: blackdoc
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.0.292
|
||||
rev: v0.3.7
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.6.0
|
||||
rev: v1.9.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies: [types-requests, types-setuptools]
|
||||
|
23
.readthedocs.yaml
Normal file
23
.readthedocs.yaml
Normal file
@ -0,0 +1,23 @@
|
||||
version: 2
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
python: '3.10'
|
||||
apt_packages:
|
||||
- libpango1.0-dev
|
||||
- ffmpeg
|
||||
jobs:
|
||||
post_install:
|
||||
- ipython kernel install --name "manim-slides" --user
|
||||
sphinx:
|
||||
builder: html
|
||||
configuration: docs/source/conf.py
|
||||
fail_on_warning: true
|
||||
python:
|
||||
install:
|
||||
- method: pip
|
||||
path: .
|
||||
extra_requirements:
|
||||
- docs
|
||||
- magic
|
||||
- sphinx-directive
|
134
CHANGELOG.md
134
CHANGELOG.md
@ -7,7 +7,129 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
<!-- start changelog -->
|
||||
|
||||
## [v5 (Unreleased)](https://github.com/jeertmans/manim-slides/compare/v4.16.0...HEAD)
|
||||
(unreleased)=
|
||||
## [Unreleased](https://github.com/jeertmans/manim-slides/compare/v5.1.4...HEAD)
|
||||
|
||||
|
||||
(v5.1.4)=
|
||||
## [v5.1.4](https://github.com/jeertmans/manim-slides/compare/v5.1.3...v5.1.4)
|
||||
|
||||
(v5.1.4-added)=
|
||||
### Added
|
||||
|
||||
- Added audio output to `manim-slides present`.
|
||||
[#382](https://github.com/jeertmans/manim-slides/pull/382)
|
||||
|
||||
(v5.1.4-changed)=
|
||||
### Changed
|
||||
|
||||
- Added `--info-window-screen` option and change `--screen-number`
|
||||
to not move the info window.
|
||||
[#389](https://github.com/jeertmans/manim-slides/pull/389)
|
||||
|
||||
(v5.1.4-chore)=
|
||||
### Chore
|
||||
|
||||
- Created a favicon for the website/documentation.
|
||||
[#399](https://github.com/jeertmans/manim-slides/pull/399)
|
||||
- Documented the Nixpkg installation.
|
||||
[#404](https://github.com/jeertmans/manim-slides/pull/404 )
|
||||
- Updated the default RevealJS version to 5.1.0.
|
||||
[#412](https://github.com/jeertmans/manim-slides/pull/412)
|
||||
- Removed the `opencv-python` dependency.
|
||||
[#415](https://github.com/jeertmans/manim-slides/pull/415)
|
||||
|
||||
(v5.1.4-fixed)=
|
||||
### Fixed
|
||||
|
||||
- Fixed the retrieval of `background_color` with ManimCE.
|
||||
[#414](https://github.com/jeertmans/manim-slides/pull/414)
|
||||
- Fixed #390 issue caused by empty media created by ManimCE.
|
||||
[#416](https://github.com/jeertmans/manim-slides/pull/416)
|
||||
|
||||
(v5.1.3)=
|
||||
## [v5.1.3](https://github.com/jeertmans/manim-slides/compare/v5.1.2...v5.1.3)
|
||||
|
||||
(v5.1.3-chore)=
|
||||
### Chore
|
||||
|
||||
- Fix link in documentation.
|
||||
[#368](https://github.com/jeertmans/manim-slides/pull/368)
|
||||
|
||||
- Warn users if not using recommended Qt bindings.
|
||||
[#373](https://github.com/jeertmans/manim-slides/pull/373)
|
||||
|
||||
(v5.1.2)=
|
||||
## [v5.1.2](https://github.com/jeertmans/manim-slides/compare/v5.1.1...v5.1.2)
|
||||
|
||||
(v5.1.2-chore)=
|
||||
### Chore
|
||||
|
||||
- Fix ReadTheDocs version flyout in iframes.
|
||||
[#367](https://github.com/jeertmans/manim-slides/pull/367)
|
||||
|
||||
(v5.1.1)=
|
||||
## [v5.1.1](https://github.com/jeertmans/manim-slides/compare/v5.1.0...v5.1.1)
|
||||
|
||||
(v5.1.1-chore)=
|
||||
### Chore
|
||||
|
||||
- Move documentation to ReadTheDocs for better versioning.
|
||||
[#365](https://github.com/jeertmans/manim-slides/pull/365)
|
||||
|
||||
(v5.1)=
|
||||
## [v5.1](https://github.com/jeertmans/manim-slides/compare/v5.0.0...v5.1.0)
|
||||
|
||||
(v5.1-added)=
|
||||
### Added
|
||||
|
||||
- Added the `--hide-info-window` option to `manim-slides present`.
|
||||
[#313](https://github.com/jeertmans/manim-slides/pull/313)
|
||||
- Added the `manim-slides render` command
|
||||
to render slides using correct Manim installation.
|
||||
[#317](https://github.com/jeertmans/manim-slides/pull/317)
|
||||
- Added the `playback-rate` and `reversed-playback-rate` options
|
||||
to slide config.
|
||||
[#320](https://github.com/jeertmans/manim-slides/pull/320)
|
||||
- Added the speaker notes option.
|
||||
[#322](https://github.com/jeertmans/manim-slides/pull/322)
|
||||
- Added `auto` option for conversion format, which is the default.
|
||||
This is somewhat a **breaking change**, but changes to the CLI
|
||||
API are not considered to be very important.
|
||||
[#325](https://github.com/jeertmans/manim-slides/pull/325)
|
||||
- Added `return_animation` option to slide animations `self.wipe`
|
||||
and `self.zoom`.
|
||||
[#331](https://github.com/jeertmans/manim-slides/pull/331)
|
||||
- Created a Docker image, published on GitHub.
|
||||
[#355](https://github.com/jeertmans/manim-slides/pull/355)
|
||||
- Added `:template:` and `:config_options` options to
|
||||
the Sphinx directive.
|
||||
[#357](https://github.com/jeertmans/manim-slides/pull/357)
|
||||
|
||||
(v5.1-modified)=
|
||||
### Modified
|
||||
|
||||
- Modified the internal logic to simplify adding configuration options.
|
||||
[#321](https://github.com/jeertmans/manim-slides/pull/321)
|
||||
- Remove `reversed` file assets when exporting to HTML, as it was not used.
|
||||
[#336](https://github.com/jeertmans/manim-slides/pull/336)
|
||||
|
||||
(v5.1-chore)=
|
||||
### Chore
|
||||
|
||||
- Removed subrocess calls to FFmpeg with direct `libav` bindings using
|
||||
the `av` Python module. This should enhance rendering speed and security.
|
||||
[#335](https://github.com/jeertmans/manim-slides/pull/335)
|
||||
- Changed build backend to PDM and reflected on docs.
|
||||
[#354](https://github.com/jeertmans/manim-slides/pull/354)
|
||||
- Dropped Python 3.8 support.
|
||||
[#350](https://github.com/jeertmans/manim-slides/pull/350)
|
||||
- Made Qt backend optional and support PyQt6 too.
|
||||
[#350](https://github.com/jeertmans/manim-slides/pull/350)
|
||||
- Documentated how to create and use a custom HTML template.
|
||||
[#357](https://github.com/jeertmans/manim-slides/pull/357)
|
||||
|
||||
## [v5](https://github.com/jeertmans/manim-slides/compare/v4.16.0...v5.0.0)
|
||||
|
||||
Prior to v5, there was no real CHANGELOG other than the GitHub releases,
|
||||
with most of the content automatically generated by GitHub from merged
|
||||
@ -46,6 +168,16 @@ In an effort to better document changes, this CHANGELOG document is now created.
|
||||
- Added `Slide.next_section` for compatibility with `manim`'s
|
||||
`Scene.next_section` method.
|
||||
[#295](https://github.com/jeertmans/manim-slides/pull/295)
|
||||
- Added `--next-terminates-loop` option to `manim-slides present` for turn a
|
||||
looping slide into a normal one, so that it ends nicely. This is useful to
|
||||
have a smooth transition with the next slide.
|
||||
[#299](https://github.com/jeertmans/manim-slides/pull/299)
|
||||
- Added `--playback-rate` option to `manim-slides present` for testing purposes.
|
||||
[#300](https://github.com/jeertmans/manim-slides/pull/300)
|
||||
- Added `auto_next` option to `Slide`'s `next_slide` method to automatically
|
||||
play the next slide upon terminating. Supported by `present` and
|
||||
`convert --to=html` commands.
|
||||
[#304](https://github.com/jeertmans/manim-slides/pull/304)
|
||||
|
||||
(v5-changed)=
|
||||
### Changed
|
||||
|
28
CITATION.cff
28
CITATION.cff
@ -3,25 +3,22 @@
|
||||
|
||||
cff-version: 1.2.0
|
||||
title: Manim Slides
|
||||
message: A Python package for presenting Manim content anywhere
|
||||
message: >-
|
||||
If you use this software, please cite it using the
|
||||
metadata from this file.
|
||||
type: software
|
||||
authors:
|
||||
- name: Jérome Eertmans
|
||||
orcid: 'https://orcid.org/0000-0002-5579-5360'
|
||||
website: 'https://eertmans.be'
|
||||
identifiers:
|
||||
- type: doi
|
||||
value: 10.21105/jose.00206
|
||||
description: The paper presenting the software.
|
||||
repository-code: 'https://github.com/jeertmans/manim-slides'
|
||||
url: 'https://eertmans.be/manim-slides'
|
||||
abstract: >-
|
||||
Manim Slides is a Python package that makes presenting
|
||||
Manim animations straightforward. With minimal changes
|
||||
required to pre-existing code, one can slide through
|
||||
|
||||
animations in a PowerPoint-like manner, or share its
|
||||
slides online using ReavealJS’ power.
|
||||
slides online using ReavealJS' power.
|
||||
keywords:
|
||||
- Education
|
||||
- Math Animations
|
||||
@ -29,4 +26,19 @@ keywords:
|
||||
- PowerPoint
|
||||
- Python
|
||||
license: MIT
|
||||
version: v5.0.0-rc3
|
||||
version: v5.1.4
|
||||
preferred-citation:
|
||||
publisher:
|
||||
name: The Open Journal
|
||||
type: article
|
||||
authors:
|
||||
- name: Jérome Eertmans
|
||||
orcid: 'https://orcid.org/0000-0002-5579-5360'
|
||||
doi: 10.21105/jose.00206
|
||||
journal: Journal of Open Source Education
|
||||
month: 8
|
||||
year: 2023
|
||||
title: 'Manim Slides: A Python package for presenting Manim content anywhere'
|
||||
volume: 6
|
||||
number: 66
|
||||
pages: 206
|
||||
|
@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Jérome Eertmans
|
||||
Copyright (c) 2022-2024 Jérome Eertmans
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
158
README.md
158
README.md
@ -11,22 +11,22 @@
|
||||
[![DOI][doi-badge]][doi-url]
|
||||
[![JOSE Paper][jose-badge]][jose-url]
|
||||
[![codecov][codecov-badge]][codecov-url]
|
||||
[![Binder][binder-badge]][binder-url]
|
||||
|
||||
# Manim Slides
|
||||
|
||||
Tool for live presentations using either [Manim (community edition)](https://www.manim.community/) or [ManimGL](https://3b1b.github.io/manim/). Manim Slides will *automatically* detect the one you are using!
|
||||
Tool for live presentations using either
|
||||
[Manim (community edition)](https://www.manim.community/)
|
||||
or [ManimGL](https://3b1b.github.io/manim/).
|
||||
Manim Slides will *automatically* detect the one you are using!
|
||||
|
||||
> **NOTE:** this project extends the work of [`manim-presentation`](https://github.com/galatolofederico/manim-presentation), with a lot more features!
|
||||
> [!NOTE]
|
||||
> This project extends the work of
|
||||
> [`manim-presentation`](https://github.com/galatolofederico/manim-presentation),
|
||||
> with a lot more features!
|
||||
|
||||
- [Installation](#installation)
|
||||
* [Dependencies](#dependencies)
|
||||
* [Pip install](#pip-install)
|
||||
* [Install From Repository](#install-from-repository)
|
||||
- [Usage](#usage)
|
||||
* [Basic Example](#basic-example)
|
||||
* [Key Bindings](#key-bindings)
|
||||
* [Interactive Tutorial](#interactive-tutorial)
|
||||
* [Other Examples](#other-examples)
|
||||
- [Comparison with Similar Tools](#comparison-with-similar-tools)
|
||||
- [F.A.Q](#faq)
|
||||
* [How to increase quality on Windows](#how-to-increase-quality-on-windows)
|
||||
@ -37,53 +37,21 @@ Tool for live presentations using either [Manim (community edition)](https://www
|
||||
|
||||
## Installation
|
||||
|
||||
<!-- start install -->
|
||||
|
||||
While installing Manim Slides and its dependencies on your global Python is fine, I recommend using a virtual environment (e.g., [venv](https://docs.python.org/3/tutorial/venv.html)) for a local installation.
|
||||
|
||||
### Dependencies
|
||||
|
||||
<!-- start deps -->
|
||||
|
||||
Manim Slides requires either Manim or ManimGL to be installed. Having both packages installed is fine too.
|
||||
|
||||
If none of those packages are installed, please refer to their specific installation guidelines:
|
||||
- [Manim](https://docs.manim.community/en/stable/installation.html)
|
||||
- [ManimGL](https://3b1b.github.io/manim/getting_started/installation.html)
|
||||
|
||||
<!-- end deps -->
|
||||
|
||||
### Pip Install
|
||||
|
||||
The recommended way to install the latest release is to use pip:
|
||||
|
||||
```bash
|
||||
pip install manim-slides
|
||||
```
|
||||
|
||||
Optionally, you can also install Manim or ManimGL using extras[^1]:
|
||||
|
||||
```bash
|
||||
pip install manim-slides[manim] # For Manim
|
||||
# or
|
||||
pip install manim-slides[manimgl] # For ManimGL
|
||||
```
|
||||
|
||||
[^1]: NOTE: you still need to have Manim or ManimGL platform-specific dependencies installed on your computer.
|
||||
|
||||
### Install From Repository
|
||||
|
||||
An alternative way to install Manim Slides is to clone the git repository, and install from there: read the [contributing guide](https://eertmans.be/manim-slides/contributing/workflow.html) to know how.
|
||||
|
||||
<!-- end install -->
|
||||
Manim Slides requires either Manim or ManimGL to be installed, along
|
||||
with their dependencies. Please checkout the
|
||||
[documentation](https://eertmans.be/manim-slides/latest/installation.html)
|
||||
for detailed install instructions.
|
||||
|
||||
## Usage
|
||||
|
||||
<!-- start usage -->
|
||||
|
||||
Using Manim Slides is a two-step process:
|
||||
1. Render animations using `Slide` (resp. `ThreeDSlide`) as a base class instead of `Scene` (resp. `ThreeDScene`), and add calls to `self.next_slide()` everytime you want to create a new slide.
|
||||
2. Run `manim-slides` on rendered animations and display them like a *Power Point* presentation.
|
||||
1. Render animations using `Slide` (resp. `ThreeDSlide`) as a base class instead
|
||||
of `Scene` (resp. `ThreeDScene`), and add calls to `self.next_slide()`
|
||||
everytime you want to create a new slide.
|
||||
2. Run `manim-slides` on rendered animations and display them like a
|
||||
*PowerPoint* presentation.
|
||||
|
||||
The documentation is available [online](https://eertmans.be/manim-slides/).
|
||||
|
||||
@ -94,8 +62,6 @@ animations, and `self.next_slide(loop=True)` if you want the next slide to loop
|
||||
over animations until the user presses continue:
|
||||
|
||||
```python
|
||||
# example.py
|
||||
|
||||
from manim import * # or: from manimlib import *
|
||||
|
||||
from manim_slides import Slide
|
||||
@ -119,68 +85,58 @@ class BasicExample(Slide):
|
||||
First, render the animation files:
|
||||
|
||||
```bash
|
||||
manim example.py BasicExample
|
||||
# or
|
||||
manimgl example.py BasicExample
|
||||
manim-slides render example.py BasicExample
|
||||
# or use ManimGL
|
||||
manim-slides render --GL example.py BasicExample
|
||||
```
|
||||
<!-- end usage -->
|
||||
|
||||
To start the presentation using `Scene1`, `Scene2` and so on simply run:
|
||||
> [!NOTE]
|
||||
> Using `manim-slides render` makes sure to use the `manim`
|
||||
> (or `manimlib`) library that was installed in the same Python environment.
|
||||
> Put simply, this is a wrapper around
|
||||
> `manim render [ARGS]...` (or `manimgl [ARGS]...`).
|
||||
|
||||
<!-- start more-usage -->
|
||||
|
||||
To start the presentation using `Scene1`, `Scene2` and so on, run:
|
||||
|
||||
```bash
|
||||
manim-slides [OPTIONS] Scene1 Scene2...
|
||||
```
|
||||
|
||||
Or in this example:
|
||||
In our example:
|
||||
|
||||
```bash
|
||||
manim-slides BasicExample
|
||||
```
|
||||
|
||||
<!-- end usage -->
|
||||
<!-- end more-usage -->
|
||||
|
||||
## Key Bindings
|
||||
<p align="center">
|
||||
<img alt="Example GIF" src="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/example.gif">
|
||||
</p>
|
||||
|
||||
The default key bindings to control the presentation are:
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/wizard_dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/wizard_light.png">
|
||||
<img alt="Manim Slides Wizard" src="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/wizard_light.png">
|
||||
</picture>
|
||||
|
||||
|
||||
You can run the **configuration wizard** to change those key bindings:
|
||||
|
||||
```bash
|
||||
manim-slides wizard
|
||||
```
|
||||
|
||||
A default file can be created with:
|
||||
|
||||
```bash
|
||||
manim-slides init
|
||||
```
|
||||
|
||||
> **_NOTE:_** `manim-slides` uses key codes, which are platform dependent. Using the configuration wizard is therefore highly recommended.
|
||||
For detailed usage documentation, run `manim-slides --help`, or go to the
|
||||
[documentation](https://eertmans.be/manim-slides/reference/cli.html).
|
||||
|
||||
## Interactive Tutorial
|
||||
|
||||
Click on the image to watch a slides presentation that explains you how to use Manim Slides.
|
||||
Click on the image to watch a slides presentation that explains to you how
|
||||
to use Manim Slides.
|
||||
|
||||
[](https://eertmans.be/manim-slides/)
|
||||
|
||||
## Other Examples
|
||||
|
||||
Other examples are available in the [`example.py`](https://github.com/jeertmans/manim-slides/blob/main/example.py) file, if you downloaded the git repository.
|
||||
|
||||
Below is a small recording of me playing with the slides back and forth.
|
||||
|
||||

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

|
||||
<p align="center">
|
||||
<img alt="Windows Fix Scaling" src="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/windows_quality_fix.png">
|
||||
</p>
|
||||
|
||||
in *Settings*->*Display*.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are more than welcome! Please read through [our contributing section](https://eertmans.be/manim-slides/contributing/index.html).
|
||||
Contributions are more than welcome! Please read through
|
||||
[our contributing section](https://eertmans.be/manim-slides/contributing/index.html).
|
||||
|
||||
### Reporting an Issue
|
||||
|
||||
@ -262,12 +225,13 @@ you can do so at: [jeertmans@icloud.com](mailto:jeertmans@icloud.com).
|
||||
[pypi-version-url]: https://pypi.org/project/manim-slides/
|
||||
[pypi-python-version-badge]: https://img.shields.io/pypi/pyversions/manim-slides
|
||||
[pypi-download-badge]: https://img.shields.io/pypi/dm/manim-slides
|
||||
[documentation-badge]: https://img.shields.io/website?down_color=lightgrey&down_message=offline&label=documentation&up_color=green&up_message=online&url=https%3A%2F%2Feertmans.be%2Fmanim-slides%2F
|
||||
[documentation-url]: https://eertmans.be/manim-slides/
|
||||
[documentation-badge]: https://readthedocs.org/projects/manim-slides/badge/?version=latest
|
||||
[documentation-url]: https://manim-slides.readthedocs.io/
|
||||
[doi-badge]: https://zenodo.org/badge/DOI/10.5281/zenodo.8215167.svg
|
||||
[doi-url]: https://doi.org/10.5281/zenodo.8215167
|
||||
[jose-badge]: https://jose.theoj.org/papers/10.21105/jose.00206/status.svg
|
||||
[jose-url]: https://doi.org/10.21105/jose.00206
|
||||
|
||||
[codecov-badge]: https://codecov.io/gh/jeertmans/manim-slides/branch/main/graph/badge.svg?token=8P4DY9JCE4
|
||||
[codecov-url]: https://codecov.io/gh/jeertmans/manim-slides
|
||||
[binder-badge]: https://mybinder.org/badge_logo.svg
|
||||
[binder-url]: https://mybinder.org/v2/gh/jeertmans/manim-slides-binder/HEAD?filepath=getting_started.ipynb
|
||||
|
55
docker/Dockerfile
Normal file
55
docker/Dockerfile
Normal file
@ -0,0 +1,55 @@
|
||||
# Mostly a copy from https://github.com/ManimCommunity/manim/blob/68bd79093e1ebc1ed9f8051942ffe6e72a9e66a7/docker/Dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
RUN apt-get update -qq \
|
||||
&& apt-get install --no-install-recommends -y \
|
||||
ffmpeg \
|
||||
build-essential \
|
||||
gcc \
|
||||
cmake \
|
||||
libcairo2-dev \
|
||||
libffi-dev \
|
||||
libpango1.0-dev \
|
||||
freeglut3-dev \
|
||||
pkg-config \
|
||||
make \
|
||||
wget \
|
||||
ghostscript
|
||||
|
||||
# setup a minimal texlive installation
|
||||
COPY docker/texlive-profile.txt /tmp/
|
||||
ENV PATH=/usr/local/texlive/bin/armhf-linux:/usr/local/texlive/bin/aarch64-linux:/usr/local/texlive/bin/x86_64-linux:$PATH
|
||||
RUN wget -O /tmp/install-tl-unx.tar.gz http://mirror.ctan.org/systems/texlive/tlnet/install-tl-unx.tar.gz && \
|
||||
mkdir /tmp/install-tl && \
|
||||
tar -xzf /tmp/install-tl-unx.tar.gz -C /tmp/install-tl --strip-components=1 && \
|
||||
/tmp/install-tl/install-tl --profile=/tmp/texlive-profile.txt \
|
||||
&& tlmgr install \
|
||||
amsmath babel-english cbfonts-fd cm-super ctex doublestroke dvisvgm everysel \
|
||||
fontspec frcursive fundus-calligra gnu-freefont jknapltx latex-bin \
|
||||
mathastext microtype ms physics preview ragged2e relsize rsfs \
|
||||
setspace standalone tipa wasy wasysym xcolor xetex xkeyval
|
||||
|
||||
# clone and build manim-slides
|
||||
COPY . /opt/manim-slides
|
||||
WORKDIR /opt/manim-slides
|
||||
RUN pip install --no-cache manim[jupyterlab] .[sphinx-directive]
|
||||
|
||||
ARG NB_USER=manimslidesuser
|
||||
ARG NB_UID=1000
|
||||
ENV USER ${NB_USER}
|
||||
ENV NB_UID ${NB_UID}
|
||||
ENV HOME /manim-slides
|
||||
|
||||
RUN adduser --disabled-password \
|
||||
--gecos "Default user" \
|
||||
--uid ${NB_UID} \
|
||||
${NB_USER}
|
||||
|
||||
# create working directory for user to mount local directory into
|
||||
WORKDIR ${HOME}
|
||||
USER root
|
||||
RUN chown -R ${NB_USER}:${NB_USER} ${HOME}
|
||||
RUN chmod 777 ${HOME}
|
||||
USER ${NB_USER}
|
||||
|
||||
CMD [ "/bin/bash" ]
|
15
docker/README.md
Normal file
15
docker/README.md
Normal file
@ -0,0 +1,15 @@
|
||||
# Docker Image
|
||||
|
||||
Manim Slides Docker image, highly inspired from the Manim Community Docker image.
|
||||
|
||||
Building the image can be done with:
|
||||
|
||||
```bash
|
||||
docker build -t manim-slide/manin-slide:TAG -f docker/Dockerfile .
|
||||
```
|
||||
|
||||
from the root directory of the repository.
|
||||
|
||||
> [!WARNING]
|
||||
> If you run the command above from another place,
|
||||
> Docker will not be able to find expected files.
|
10
docker/texlive-profile.txt
Normal file
10
docker/texlive-profile.txt
Normal file
@ -0,0 +1,10 @@
|
||||
selected_scheme scheme-minimal
|
||||
TEXDIR /usr/local/texlive
|
||||
TEXMFCONFIG ~/.texlive/texmf-config
|
||||
TEXMFHOME ~/texmf
|
||||
TEXMFLOCAL /usr/local/texlive/texmf-local
|
||||
TEXMFSYSCONFIG /usr/local/texlive/texmf-config
|
||||
TEXMFSYSVAR /usr/local/texlive/texmf-var
|
||||
TEXMFVAR ~/.texlive/texmf-var
|
||||
option_doc 0
|
||||
option_src 0
|
1
docs/source/_static/favicon.png
Symbolic link
1
docs/source/_static/favicon.png
Symbolic link
@ -0,0 +1 @@
|
||||
../../../static/favicon.png
|
101
docs/source/_static/template.diff
Normal file
101
docs/source/_static/template.diff
Normal file
@ -0,0 +1,101 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<!-- Head stuff -->
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Slides stuff -->
|
||||
|
||||
<script>
|
||||
<!-- RevealJS stuff -->
|
||||
</script>
|
||||
|
||||
<!-- Add a clock to each section dynamically using JavaScript -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var revealContainer = document.querySelector('.reveal');
|
||||
|
||||
// Append dynamic content to each section
|
||||
var sections = revealContainer.querySelectorAll('.slides > section');
|
||||
sections.forEach(function (section) {
|
||||
// Create a new clock container
|
||||
var clockContainer = document.createElement('div');
|
||||
clockContainer.className = 'clock';
|
||||
|
||||
// Append the new clock container to the section
|
||||
section.appendChild(clockContainer);
|
||||
});
|
||||
|
||||
// Function to update the clock content
|
||||
function updateClock() {
|
||||
var now = new Date();
|
||||
var hours = now.getHours();
|
||||
var minutes = now.getMinutes();
|
||||
var seconds = now.getSeconds();
|
||||
|
||||
// Format the time as HH:MM:SS
|
||||
var timeString = pad(hours) + ":" + pad(minutes) + ":" + pad(seconds);
|
||||
|
||||
// Update the content of all clock containers
|
||||
var clockContainers = document.querySelectorAll('.clock');
|
||||
clockContainers.forEach(function (container) {
|
||||
container.innerText = timeString;
|
||||
});
|
||||
}
|
||||
|
||||
// Function to pad zero for single-digit numbers
|
||||
function pad(number) {
|
||||
return String(number).padStart(2, "0");
|
||||
}
|
||||
|
||||
// Update the clock every second
|
||||
setInterval(updateClock, 1000);
|
||||
|
||||
// Register a reveal.js event to update the clock on each slide change
|
||||
Reveal.addEventListener('slidechanged', function (event) {
|
||||
updateClock();
|
||||
});
|
||||
|
||||
// Initial update
|
||||
updateClock();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- define the style of the clock -->
|
||||
<style>
|
||||
.clock {
|
||||
position: fixed;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
font-size: 24px;
|
||||
font-family: "Arial", sans-serif;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* control the relative position of the clock to the slides */
|
||||
.reveal .slides > section.present, .reveal .slides > section > section.present {
|
||||
min-height: 100% !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
justify-content: center !important;
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
}
|
||||
section > h1 {
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
}
|
||||
|
||||
.print-pdf .reveal .slides > section.present, .print-pdf .reveal .slides > section > section.present {
|
||||
min-height: 770px !important;
|
||||
position: relative !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
</body>
|
||||
</html>
|
429
docs/source/_static/template.html
Normal file
429
docs/source/_static/template.html
Normal file
@ -0,0 +1,429 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
|
||||
<title>{{ title }}</title>
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/reveal.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/theme/{{ reveal_theme }}.min.css">
|
||||
|
||||
<!-- Theme used for syntax highlighting of code -->
|
||||
<!-- <link rel="stylesheet" href="lib/css/zenburn.css"> -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.13.1/styles/zenburn.min.css">
|
||||
<!-- <link rel="stylesheet" href="index.css"> -->
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="reveal">
|
||||
<div class="slides">
|
||||
{%- for presentation_config in presentation_configs -%}
|
||||
{% set outer_loop = loop %}
|
||||
{%- for slide_config in presentation_config.slides -%}
|
||||
{%- if data_uri -%}
|
||||
{% set file = file_to_data_uri(slide_config.file) %}
|
||||
{%- else -%}
|
||||
{% set file = assets_dir / slide_config.file.name %}
|
||||
{%- endif -%}
|
||||
<section
|
||||
data-background-size={{ background_size }}
|
||||
data-background-color="{{ presentation_config.background_color }}"
|
||||
data-background-video="{{ file }}"
|
||||
{% if loop.index == 1 and outer_loop.index == 1 -%}
|
||||
data-background-video-muted
|
||||
{%- endif -%}
|
||||
{% if slide_config.loop -%}
|
||||
data-background-video-loop
|
||||
{%- endif -%}
|
||||
{% if slide_config.auto_next -%}
|
||||
data-autoslide="{{ get_duration_ms(slide_config.file) }}"
|
||||
{%- endif -%}>
|
||||
{% if slide_config.notes != "" -%}
|
||||
<aside class="notes" data-markdown>{{ slide_config.notes }}</aside>
|
||||
{%- endif %}
|
||||
</section>
|
||||
{%- endfor -%}
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/reveal.min.js"></script>
|
||||
|
||||
<!-- To include plugins, see: https://revealjs.com/plugins/ -->
|
||||
|
||||
{% if has_notes -%}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/plugin/markdown/markdown.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/plugin/notes/notes.min.js"></script>
|
||||
{%- endif -%}
|
||||
|
||||
<!-- <script src="index.js"></script> -->
|
||||
<script>
|
||||
Reveal.initialize({
|
||||
{% if has_notes -%}
|
||||
plugins: [ RevealMarkdown, RevealNotes ],
|
||||
{%- endif %}
|
||||
// The "normal" size of the presentation, aspect ratio will
|
||||
// be preserved when the presentation is scaled to fit different
|
||||
// resolutions. Can be specified using percentage units.
|
||||
width: {{ width }},
|
||||
height: {{ height }},
|
||||
|
||||
// Factor of the display size that should remain empty around
|
||||
// the content
|
||||
margin: {{ margin }},
|
||||
|
||||
// Bounds for smallest/largest possible scale to apply to content
|
||||
minScale: {{ min_scale }},
|
||||
maxScale: {{ max_scale }},
|
||||
|
||||
// Display presentation control arrows
|
||||
controls: {{ controls }},
|
||||
|
||||
// Help the user learn the controls by providing hints, for example by
|
||||
// bouncing the down arrow when they first encounter a vertical slide
|
||||
controlsTutorial: {{ controls_tutorial }},
|
||||
|
||||
// Determines where controls appear, "edges" or "bottom-right"
|
||||
controlsLayout: {{ controls_layout }},
|
||||
|
||||
// Visibility rule for backwards navigation arrows; "faded", "hidden"
|
||||
// or "visible"
|
||||
controlsBackArrows: {{ controls_back_arrows }},
|
||||
|
||||
// Display a presentation progress bar
|
||||
progress: {{ progress }},
|
||||
|
||||
// Display the page number of the current slide
|
||||
// - true: Show slide number
|
||||
// - false: Hide slide number
|
||||
//
|
||||
// Can optionally be set as a string that specifies the number formatting:
|
||||
// - "h.v": Horizontal . vertical slide number (default)
|
||||
// - "h/v": Horizontal / vertical slide number
|
||||
// - "c": Flattened slide number
|
||||
// - "c/t": Flattened slide number / total slides
|
||||
//
|
||||
// Alternatively, you can provide a function that returns the slide
|
||||
// number for the current slide. The function should take in a slide
|
||||
// object and return an array with one string [slideNumber] or
|
||||
// three strings [n1,delimiter,n2]. See #formatSlideNumber().
|
||||
slideNumber: {{ slide_number }},
|
||||
|
||||
// Can be used to limit the contexts in which the slide number appears
|
||||
// - "all": Always show the slide number
|
||||
// - "print": Only when printing to PDF
|
||||
// - "speaker": Only in the speaker view
|
||||
showSlideNumber: {{ show_slide_number }},
|
||||
|
||||
// Use 1 based indexing for # links to match slide number (default is zero
|
||||
// based)
|
||||
hashOneBasedIndex: {{ hash_one_based_index }},
|
||||
|
||||
// Add the current slide number to the URL hash so that reloading the
|
||||
// page/copying the URL will return you to the same slide
|
||||
hash: {{ hash }},
|
||||
|
||||
// Flags if we should monitor the hash and change slides accordingly
|
||||
respondToHashChanges: {{ respond_to_hash_changes }},
|
||||
|
||||
// Push each slide change to the browser history. Implies `hash: true`
|
||||
history: {{ history }},
|
||||
|
||||
// Enable keyboard shortcuts for navigation
|
||||
keyboard: {{ keyboard }},
|
||||
|
||||
// Optional function that blocks keyboard events when retuning false
|
||||
//
|
||||
// If you set this to 'focused', we will only capture keyboard events
|
||||
// for embedded decks when they are in focus
|
||||
keyboardCondition: {{ keyboard_condition }},
|
||||
|
||||
// Disables the default reveal.js slide layout (scaling and centering)
|
||||
// so that you can use custom CSS layout
|
||||
disableLayout: {{ disable_layout }},
|
||||
|
||||
// Enable the slide overview mode
|
||||
overview: {{ overview }},
|
||||
|
||||
// Vertical centering of slides
|
||||
center: {{ center }},
|
||||
|
||||
// Enables touch navigation on devices with touch input
|
||||
touch: {{ touch }},
|
||||
|
||||
// Loop the presentation
|
||||
loop: {{ loop }},
|
||||
|
||||
// Change the presentation direction to be RTL
|
||||
rtl: {{ rtl }},
|
||||
|
||||
// Changes the behavior of our navigation directions.
|
||||
//
|
||||
// "default"
|
||||
// Left/right arrow keys step between horizontal slides, up/down
|
||||
// arrow keys step between vertical slides. Space key steps through
|
||||
// all slides (both horizontal and vertical).
|
||||
//
|
||||
// "linear"
|
||||
// Removes the up/down arrows. Left/right arrows step through all
|
||||
// slides (both horizontal and vertical).
|
||||
//
|
||||
// "grid"
|
||||
// When this is enabled, stepping left/right from a vertical stack
|
||||
// to an adjacent vertical stack will land you at the same vertical
|
||||
// index.
|
||||
//
|
||||
// Consider a deck with six slides ordered in two vertical stacks:
|
||||
// 1.1 2.1
|
||||
// 1.2 2.2
|
||||
// 1.3 2.3
|
||||
//
|
||||
// If you're on slide 1.3 and navigate right, you will normally move
|
||||
// from 1.3 -> 2.1. If "grid" is used, the same navigation takes you
|
||||
// from 1.3 -> 2.3.
|
||||
navigationMode: {{ navigation_mode }},
|
||||
|
||||
// Randomizes the order of slides each time the presentation loads
|
||||
shuffle: {{ shuffle }},
|
||||
|
||||
// Turns fragments on and off globally
|
||||
fragments: {{ fragments }},
|
||||
|
||||
// Flags whether to include the current fragment in the URL,
|
||||
// so that reloading brings you to the same fragment position
|
||||
fragmentInURL: {{ fragment_in_url }},
|
||||
|
||||
// Flags if the presentation is running in an embedded mode,
|
||||
// i.e. contained within a limited portion of the screen
|
||||
embedded: {{ embedded }},
|
||||
|
||||
// Flags if we should show a help overlay when the question-mark
|
||||
// key is pressed
|
||||
help: {{ help }},
|
||||
|
||||
// Flags if it should be possible to pause the presentation (blackout)
|
||||
pause: {{ pause }},
|
||||
|
||||
// Flags if speaker notes should be visible to all viewers
|
||||
showNotes: {{ show_notes }},
|
||||
|
||||
// Global override for autolaying embedded media (video/audio/iframe)
|
||||
// - null: Media will only autoplay if data-autoplay is present
|
||||
// - true: All media will autoplay, regardless of individual setting
|
||||
// - false: No media will autoplay, regardless of individual setting
|
||||
autoPlayMedia: {{ auto_play_media }},
|
||||
|
||||
// Global override for preloading lazy-loaded iframes
|
||||
// - null: Iframes with data-src AND data-preload will be loaded when within
|
||||
// the viewDistance, iframes with only data-src will be loaded when visible
|
||||
// - true: All iframes with data-src will be loaded when within the viewDistance
|
||||
// - false: All iframes with data-src will be loaded only when visible
|
||||
preloadIframes: {{ preload_iframes }},
|
||||
|
||||
// Can be used to globally disable auto-animation
|
||||
autoAnimate: {{ auto_animate }},
|
||||
|
||||
// Optionally provide a custom element matcher that will be
|
||||
// used to dictate which elements we can animate between.
|
||||
autoAnimateMatcher: {{ auto_animate_matcher }},
|
||||
|
||||
// Default settings for our auto-animate transitions, can be
|
||||
// overridden per-slide or per-element via data arguments
|
||||
autoAnimateEasing: {{ auto_animate_easing }},
|
||||
autoAnimateDuration: {{ auto_animate_duration }},
|
||||
autoAnimateUnmatched: {{ auto_animate_unmatched }},
|
||||
|
||||
// CSS properties that can be auto-animated. Position & scale
|
||||
// is matched separately so there's no need to include styles
|
||||
// like top/right/bottom/left, width/height or margin.
|
||||
autoAnimateStyles: {{ auto_animate_styles }},
|
||||
|
||||
// Controls automatic progression to the next slide
|
||||
// - 0: Auto-sliding only happens if the data-autoslide HTML attribute
|
||||
// is present on the current slide or fragment
|
||||
// - 1+: All slides will progress automatically at the given interval
|
||||
// - false: No auto-sliding, even if data-autoslide is present
|
||||
autoSlide: {{ auto_slide }},
|
||||
|
||||
// Stop auto-sliding after user input
|
||||
autoSlideStoppable: {{ auto_slide_stoppable }},
|
||||
|
||||
// Use this method for navigation when auto-sliding (defaults to navigateNext)
|
||||
autoSlideMethod: {{ auto_slide_method }},
|
||||
|
||||
// Specify the average time in seconds that you think you will spend
|
||||
// presenting each slide. This is used to show a pacing timer in the
|
||||
// speaker view
|
||||
defaultTiming: {{ default_timing }},
|
||||
|
||||
// Enable slide navigation via mouse wheel
|
||||
mouseWheel: {{ mouse_wheel }},
|
||||
|
||||
// Opens links in an iframe preview overlay
|
||||
// Add `data-preview-link` and `data-preview-link="false"` to customise each link
|
||||
// individually
|
||||
previewLinks: {{ preview_links }},
|
||||
|
||||
// Exposes the reveal.js API through window.postMessage
|
||||
postMessage: {{ post_message }},
|
||||
|
||||
// Dispatches all reveal.js events to the parent window through postMessage
|
||||
postMessageEvents: {{ post_message_events }},
|
||||
|
||||
// Focuses body when page changes visibility to ensure keyboard shortcuts work
|
||||
focusBodyOnPageVisibilityChange: {{ focus_body_on_page_visibility_change }},
|
||||
|
||||
// Transition style
|
||||
transition: {{ transition }}, // none/fade/slide/convex/concave/zoom
|
||||
|
||||
// Transition speed
|
||||
transitionSpeed: {{ transition_speed }}, // default/fast/slow
|
||||
|
||||
// Transition style for full page slide backgrounds
|
||||
backgroundTransition: {{ background_transition }}, // none/fade/slide/convex/concave/zoom
|
||||
|
||||
// The maximum number of pages a single slide can expand onto when printing
|
||||
// to PDF, unlimited by default
|
||||
pdfMaxPagesPerSlide: {{ pdf_max_pages_per_slide }},
|
||||
|
||||
// Prints each fragment on a separate slide
|
||||
pdfSeparateFragments: {{ pdf_separate_fragments }},
|
||||
|
||||
// Offset used to reduce the height of content within exported PDF pages.
|
||||
// This exists to account for environment differences based on how you
|
||||
// print to PDF. CLI printing options, like phantomjs and wkpdf, can end
|
||||
// on precisely the total height of the document whereas in-browser
|
||||
// printing has to end one pixel before.
|
||||
pdfPageHeightOffset: {{ pdf_page_height_offset }},
|
||||
|
||||
// Number of slides away from the current that are visible
|
||||
viewDistance: {{ view_distance }},
|
||||
|
||||
// Number of slides away from the current that are visible on mobile
|
||||
// devices. It is advisable to set this to a lower number than
|
||||
// viewDistance in order to save resources.
|
||||
mobileViewDistance: {{ mobile_view_distance }},
|
||||
|
||||
// The display mode that will be used to show slides
|
||||
display: {{ display }},
|
||||
|
||||
// Hide cursor if inactive
|
||||
hideInactiveCursor: {{ hide_inactive_cursor }},
|
||||
|
||||
// Time before the cursor is hidden (in ms)
|
||||
hideCursorTime: {{ hide_cursor_time }}
|
||||
});
|
||||
|
||||
{% if data_uri %}
|
||||
// Fix found by @t-fritsch on GitHub
|
||||
// see: https://github.com/hakimel/reveal.js/discussions/3362#discussioncomment-6651475.
|
||||
function fixBase64VideoBackground(event) {
|
||||
// event.previousSlide, event.currentSlide, event.indexh, event.indexv
|
||||
if (event.currentSlide.getAttribute('data-background-video')) {
|
||||
const background = Reveal.getSlideBackground(event.indexh, event.indexv),
|
||||
video = background.querySelector('video'),
|
||||
sources = video.querySelectorAll('source');
|
||||
|
||||
sources.forEach((source, i) => {
|
||||
const src = source.getAttribute('src');
|
||||
if(src.match(/^data:video.*;base64$/)) {
|
||||
const nextSrc = sources[i+1]?.getAttribute('src');
|
||||
video.setAttribute('src', `${src},${nextSrc}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reveal.on( 'ready', fixBase64VideoBackground );
|
||||
Reveal.on( 'slidechanged', fixBase64VideoBackground );
|
||||
{% endif %}
|
||||
</script>
|
||||
|
||||
<!-- Add a clock to each section dynamically using JavaScript -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var revealContainer = document.querySelector('.reveal');
|
||||
|
||||
// Append dynamic content to each section
|
||||
var sections = revealContainer.querySelectorAll('.slides > section');
|
||||
sections.forEach(function (section) {
|
||||
// Create a new clock container
|
||||
var clockContainer = document.createElement('div');
|
||||
clockContainer.className = 'clock';
|
||||
|
||||
// Append the new clock container to the section
|
||||
section.appendChild(clockContainer);
|
||||
});
|
||||
|
||||
// Function to update the clock content
|
||||
function updateClock() {
|
||||
var now = new Date();
|
||||
var hours = now.getHours();
|
||||
var minutes = now.getMinutes();
|
||||
var seconds = now.getSeconds();
|
||||
|
||||
// Format the time as HH:MM:SS
|
||||
var timeString = pad(hours) + ":" + pad(minutes) + ":" + pad(seconds);
|
||||
|
||||
// Update the content of all clock containers
|
||||
var clockContainers = document.querySelectorAll('.clock');
|
||||
clockContainers.forEach(function (container) {
|
||||
container.innerText = timeString;
|
||||
});
|
||||
}
|
||||
|
||||
// Function to pad zero for single-digit numbers
|
||||
function pad(number) {
|
||||
return String(number).padStart(2, "0");
|
||||
}
|
||||
|
||||
// Update the clock every second
|
||||
setInterval(updateClock, 1000);
|
||||
|
||||
// Register a reveal.js event to update the clock on each slide change
|
||||
Reveal.addEventListener('slidechanged', function (event) {
|
||||
updateClock();
|
||||
});
|
||||
|
||||
// Initial update
|
||||
updateClock();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- define the style of the clock -->
|
||||
<style>
|
||||
.clock {
|
||||
position: fixed;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
font-size: 24px;
|
||||
font-family: "Arial", sans-serif;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* control the relative position of the clock to the slides */
|
||||
.reveal .slides > section.present, .reveal .slides > section > section.present {
|
||||
min-height: 100% !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
justify-content: center !important;
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
}
|
||||
section > h1 {
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
}
|
||||
|
||||
.print-pdf .reveal .slides > section.present, .print-pdf .reveal .slides > section > section.present {
|
||||
min-height: 770px !important;
|
||||
position: relative !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
</body>
|
||||
</html>
|
@ -4,12 +4,20 @@
|
||||
# For the full list of built-in configuration values, see the documentation:
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
|
||||
import sys
|
||||
from datetime import date
|
||||
|
||||
from manim_slides import __version__
|
||||
|
||||
assert sys.version_info >= (3, 10), "Building docs requires Python 3.10"
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
||||
|
||||
project = "Manim Slides"
|
||||
copyright = "2023, Jérome Eertmans"
|
||||
copyright = f"2024-{date.today().year}, Jérome Eertmans"
|
||||
author = "Jérome Eertmans"
|
||||
version = __version__
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||
@ -47,6 +55,7 @@ add_module_names = False
|
||||
|
||||
html_theme = "furo"
|
||||
html_static_path = ["_static"]
|
||||
html_favicon = "_static/favicon.png"
|
||||
|
||||
html_theme_options = {
|
||||
"light_logo": "logo_light_transparent.png",
|
||||
|
@ -4,7 +4,7 @@ This document is there to help you recreate a working environment for Manim Slid
|
||||
|
||||
## Dependencies
|
||||
|
||||
```{include} ../../../README.md
|
||||
```{include} ../installation.md
|
||||
:start-after: <!-- start deps -->
|
||||
:end-before: <!-- end deps -->
|
||||
```
|
||||
@ -20,64 +20,110 @@ Useful links:
|
||||
|
||||
Once you feel comfortable with git and GitHub, [fork](https://github.com/jeertmans/manim-slides/fork) the repository, and clone it locally.
|
||||
|
||||
As for every Python project, using virtual environment is recommended to avoid conflicts between modules. For Manim Slides, we use [Poetry](https://python-poetry.org/docs/#installing-with-the-official-installer). If not already, please install it.
|
||||
As for every Python project, using virtual environment is recommended to avoid
|
||||
conflicts between modules.
|
||||
For this project, we use [PDM](https://pdm-project.org/) to easily manage project
|
||||
and development dependencies. If not already, please install this tool.
|
||||
|
||||
## Installing Python modules
|
||||
|
||||
With Poetry, installation becomes straightforward:
|
||||
With PDM, installation becomes straightforward:
|
||||
|
||||
```bash
|
||||
poetry install
|
||||
pdm install
|
||||
```
|
||||
|
||||
This, however, only installs the minimal set of dependencies to run the package.
|
||||
|
||||
If you would like to install Manim or ManimGL, as documented in the [quickstart](../quickstart),
|
||||
you can use the `--extras` option:
|
||||
If you would like to install Manim or ManimGL,
|
||||
as documented in the [quickstart](../quickstart),
|
||||
you can use the `-G|--group` option:
|
||||
|
||||
```bash
|
||||
poetry install --extras manim # For Manim
|
||||
pdm install -Gmanim # For Manim
|
||||
# or
|
||||
poetry install --extras manimgl # For ManimGL
|
||||
pdm install -Gmanimgl # For ManimGL
|
||||
```
|
||||
|
||||
Additionnally, Manim Slides comes with group dependencies for development purposes:
|
||||
Additionnally, Manim Slides comes with groups of dependencies for development purposes:
|
||||
|
||||
```bash
|
||||
poetry install --with dev # For linters and formatters
|
||||
pdm install -Gdev # For linters and formatters
|
||||
# or
|
||||
poetry install --with docs # To build the documentation locally
|
||||
pdm install -Gdocs # To build the documentation locally
|
||||
# or
|
||||
pdm install -Gtest # To run tests
|
||||
```
|
||||
|
||||
Another group is `test`, but it is only used for
|
||||
[GitHub actions](https://github.com/jeertmans/manim-slides/blob/main/.github/workflows/test_examples.yml).
|
||||
|
||||
:::{note}
|
||||
You can combine any number of groups or extras when installing the package locally.
|
||||
|
||||
You can also install everything with `pdm install -G:all`.
|
||||
:::
|
||||
|
||||
## Running commands
|
||||
|
||||
As modules were installed in a new Python environment, you cannot use them directly in the shell.
|
||||
Instead, you either need to prepend `poetry run` to any command, e.g.:
|
||||
Because modules are installed in a new Python environment,
|
||||
you cannot use them directly in the shell.
|
||||
Instead, you either need to prepend `pdm run` to any command, e.g.:
|
||||
|
||||
```bash
|
||||
poetry run manim-slides wizard
|
||||
pdm run manim-slides wizard
|
||||
```
|
||||
|
||||
or enter a new shell that uses this new Python environment:
|
||||
or [enter a new shell](https://pdm-project.org/latest/usage/venv/#activate-a-virtualenv)
|
||||
that uses this new Python environment:
|
||||
|
||||
```
|
||||
poetry shell
|
||||
```bash
|
||||
eval $(pdm venv activate) # Click on the link above to see shell-specific command
|
||||
manim-slides wizard
|
||||
```
|
||||
|
||||
## Testing your code
|
||||
|
||||
Most of the tests are done with GitHub actions, thus not on your computer. The only command you should run locally is `pre-commit run --all-files`: this runs a few linter and formatter to make sure the code quality and style stay constant across time. If a warning or an error is displayed, please fix it before going to next step.
|
||||
Most of the tests are done with GitHub actions, thus not on your computer.
|
||||
The only command you should run locally is:
|
||||
|
||||
```bash
|
||||
pdm run pre-commit run --all-files
|
||||
```
|
||||
|
||||
This runs a few linter and formatter to make sure the code quality and style stay
|
||||
constant across time.
|
||||
If a warning or an error is displayed, please fix it before going to next step.
|
||||
|
||||
For testing your code, simply run:
|
||||
|
||||
```bash
|
||||
pdm run pytest
|
||||
```
|
||||
|
||||
## Building the documentation
|
||||
|
||||
The documentation is generated using Sphinx, based on the content
|
||||
in `docs/source` and in the `manim_slides` Python package.
|
||||
|
||||
To generate the documentation, run the following:
|
||||
|
||||
```bash
|
||||
cd docs
|
||||
pdm run make html
|
||||
```
|
||||
|
||||
Then, the output index file is located at `docs/build/html/index.html` and
|
||||
can be opened with any modern browser.
|
||||
|
||||
:::{warning}
|
||||
Building the documentation can take quite some time, especially
|
||||
the first time as it needs to render all the animations.
|
||||
|
||||
Further builds should run faster.
|
||||
:::
|
||||
|
||||
## Proposing changes
|
||||
|
||||
Once you feel ready and think your contribution is ready to be reviewed, create a [pull request](https://github.com/jeertmans/manim-slides/pulls) and wait for a reviewer to check your work!
|
||||
Once you feel ready and think your contribution is ready to be reviewed,
|
||||
create a [pull request](https://github.com/jeertmans/manim-slides/pulls)
|
||||
and wait for a reviewer to check your work!
|
||||
|
||||
Many thanks to you!
|
||||
|
@ -26,7 +26,6 @@ Manim Slides makes creating slides with Manim super easy!
|
||||
In a [very few steps](./quickstart),
|
||||
you can create slides and present them either using the GUI, or your browser.
|
||||
|
||||
|
||||
Slide through the demo below to get a quick glimpse on what you can do with
|
||||
Manim Slides.
|
||||
|
||||
@ -40,6 +39,7 @@ Manim Slides.
|
||||
:hidden:
|
||||
|
||||
quickstart
|
||||
installation
|
||||
reference/index
|
||||
features_table
|
||||
manim_or_manimgl
|
||||
|
186
docs/source/installation.md
Normal file
186
docs/source/installation.md
Normal file
@ -0,0 +1,186 @@
|
||||
# Installation
|
||||
|
||||
While installing Manim Slides and its dependencies on your global Python is fine,
|
||||
we recommend using a virtual environment
|
||||
(e.g., [venv](https://docs.python.org/3/tutorial/venv.html)) for a local installation.
|
||||
|
||||
Therefore, the following documentation will install Manim Slides using
|
||||
[pipx](https://pipx.pypa.io/). This tool is a drop-in replacement
|
||||
for installing Python packages that ship with one or more executable.
|
||||
|
||||
The benefit of using pipx is that it will automatically create a new virtual
|
||||
environment for every package you install.
|
||||
|
||||
:::{note}
|
||||
Everytime you read `pipx install`, you can use `pip install` instead,
|
||||
if you are working in a virtual environment or else.
|
||||
:::
|
||||
|
||||
## Dependencies
|
||||
|
||||
<!-- start deps -->
|
||||
|
||||
Manim Slides requires either Manim or ManimGL to be installed, along
|
||||
with their dependencies.
|
||||
Having both packages installed is fine too.
|
||||
|
||||
If none of those packages are installed,
|
||||
please refer to their specific installation guidelines:
|
||||
- [Manim](https://docs.manim.community/en/stable/installation.html)
|
||||
- [ManimGL](https://3b1b.github.io/manim/getting_started/installation.html)
|
||||
|
||||
:::{warning}
|
||||
If you install Manim from its git repository, as suggested by ManimGL,
|
||||
make sure to first check out a supported version (e.g., `git checkout tags/v1.6.1`
|
||||
for ManimGL), otherwise it might install an unsupported version of Manim!
|
||||
See [#314](https://github.com/jeertmans/manim-slides/issues/314).
|
||||
|
||||
Also, note that ManimGL uses outdated dependencies, and may
|
||||
not work out-of-the-box. One example is NumPy: ManimGL
|
||||
does not specify any restriction on this package, but
|
||||
only `numpy<1.25` will work, see
|
||||
[#2053](https://github.com/3b1b/manim/issues/2053).
|
||||
:::
|
||||
|
||||
<!-- end deps -->
|
||||
|
||||
## Pip Install
|
||||
|
||||
The recommended way to install the latest release
|
||||
with all features is to use pipx:
|
||||
|
||||
```bash
|
||||
pipx install -U "manim-slides[pyside6-full]"
|
||||
```
|
||||
|
||||
:::{tip}
|
||||
While not necessary, the `-U` indicates that we would
|
||||
like to upgrade to the latest version available,
|
||||
if Manim Slides is already installed.
|
||||
:::
|
||||
|
||||
:::{note}
|
||||
The quotes `"` are added because not all shell support unquoted
|
||||
brackets (e.g., zsh) or commas (e.g., Windows).
|
||||
:::
|
||||
|
||||
You can check that Manim Slides was correctly installed with:
|
||||
|
||||
```bash
|
||||
manim-slides --version
|
||||
```
|
||||
|
||||
## Custom install
|
||||
|
||||
If you want more control on what dependencies are installed,
|
||||
you can always install the bare minimal dependencies with:
|
||||
|
||||
```bash
|
||||
pipx install -U manim-slides
|
||||
```
|
||||
|
||||
And install additional dependencies later.
|
||||
|
||||
Optionally, you can also install Manim or ManimGL using extras[^1]:
|
||||
|
||||
```bash
|
||||
pipx install -U "manim-slides[manim]" # For Manim
|
||||
# or
|
||||
pipx install -U "manim-slides[manimgl]" # For ManimGL
|
||||
```
|
||||
|
||||
For optional dependencies documentation, see
|
||||
[next section](#optional-dependencies).
|
||||
|
||||
:::{warning}
|
||||
If you are installing with pipx, this is mandatory to at least include
|
||||
either `manim` or `manimgl`.
|
||||
:::
|
||||
|
||||
[^1]: You still need to have Manim or ManimGL platform-specific dependencies
|
||||
installed on your computer.
|
||||
|
||||
## Optional dependencies
|
||||
|
||||
Along with the optional dependencies for Manim and ManimGL,
|
||||
Manim Slides offers additional *extras*, that can be activated
|
||||
using optional dependencies:
|
||||
|
||||
- `full`, to include `magic`, `manim`, `manimgl`, and
|
||||
`sphinx-directive` extras (see below);
|
||||
- `magic`, to include a Jupyter magic to render
|
||||
animations inside notebooks. This automatically installs `manim`,
|
||||
and does not work with ManimGL;
|
||||
- `manim` and `manimgl`, for installing the corresponding
|
||||
dependencies;
|
||||
- `pyqt6` to include PyQt6 Qt bindings. Those bindings are available
|
||||
on most platforms and Python version, but produce a weird black
|
||||
screen between slide with `manim-slides present`,
|
||||
see [#QTBUG-118501](https://bugreports.qt.io/browse/QTBUG-118501);
|
||||
- `pyqt6-full` to include `full` and `pyqt6`;
|
||||
- `pyside6` to include PySide6 Qt bindings. Those bindings are available
|
||||
on most platforms and Python version, except on Python 3.12[^2];
|
||||
- `pyside6-full` to include `full` and `pyside6`;
|
||||
- `sphinx-directive`, to generate presentation inside your Sphinx
|
||||
documentation. This automatically installs `manim`,
|
||||
and does not work with ManimGL.
|
||||
|
||||
Installing those extras can be done with the following syntax:
|
||||
|
||||
```bash
|
||||
pipx install -U "manim-slides[extra1,extra2]"
|
||||
```
|
||||
|
||||
[^2]: Actually, PySide6 can be installed on Python 3.12, but you will then
|
||||
observe the same visual bug as with PyQt6.
|
||||
|
||||
## Nixpkgs installation
|
||||
|
||||
Manim Slides is distributed under Nixpkgs >=24.05.
|
||||
If you are using Nix or NixOS, you can find Manim Slides under:
|
||||
|
||||
- `nixpkgs.manim-slides`, which is meant to be a stand alone application and
|
||||
includes pyqt6 (see above);
|
||||
- `nixpkgs.python3Packages.manim-slides`, which is meant to be used as a
|
||||
module (for notebook magics), and includes IPython but not does not include
|
||||
any Qt bindings.
|
||||
|
||||
You can try out the Manim Slides package with
|
||||
```sh
|
||||
nix-shell -p manim ffmpeg manim-slides
|
||||
```
|
||||
or by adding it to your
|
||||
[configuration file](https://nixos.org/manual/nixos/stable/#sec-package-management).
|
||||
|
||||
Alternatively, you can try Manim Slides in a Python environment with:
|
||||
```sh
|
||||
nix-shell -p manim ffmpeg "python3.withPackages(ps: with ps; [ manim-slides, ...])"
|
||||
```
|
||||
or bundle this into [your Nix environment](https://wiki.nixos.org/wiki/Python).
|
||||
|
||||
:::{note}
|
||||
Nix current does not support `manimgl`.
|
||||
:::
|
||||
|
||||
|
||||
## When you need a Qt backend
|
||||
|
||||
Before `v5.1`, Manim Slides automatically included PySide6 as
|
||||
a Qt backend. As only `manim-slides present` and `manim-slides wizard`
|
||||
command need a graphical library, and installing PySide6 on all platforms
|
||||
and Python version can be sometimes complicated, Manim Slides chooses
|
||||
**not to include** any Qt backend.
|
||||
|
||||
The use can choose between PySide6 (best) and PyQt6, depending on their
|
||||
availability and licensing rules.
|
||||
|
||||
As of `v5.1`, you **need** to have Qt bindings installed to use
|
||||
`manim-slides present` or `manim-slides wizard`. The recommended way to
|
||||
install those are via optional dependencies, as explained above.
|
||||
|
||||
## Install from source
|
||||
|
||||
An alternative way to install Manim Slides is to clone the git repository,
|
||||
and build the package from source. Read the
|
||||
[contributing guide](./contributing/workflow)
|
||||
to know how to process.
|
@ -1,11 +1,7 @@
|
||||
# Quickstart
|
||||
|
||||
## Installation
|
||||
|
||||
```{include} ../../README.md
|
||||
:start-after: <!-- start install -->
|
||||
:end-before: <!-- end install -->
|
||||
```
|
||||
If not already, install Manim Slides, along with either Manim or ManimGL,
|
||||
see [installation](./installation).
|
||||
|
||||
## Creating your first slides
|
||||
|
||||
@ -14,6 +10,19 @@
|
||||
:end-before: <!-- end usage -->
|
||||
```
|
||||
|
||||
:::{note}
|
||||
Using `manim-slides render` makes sure to use the `manim`
|
||||
(or `manimlib`) library that was installed in the same Python environment.
|
||||
Put simply, this is a wrapper around
|
||||
`manim render [ARGS]...` (or `manimgl [ARGS]...`).
|
||||
:::
|
||||
|
||||
|
||||
```{include} ../../README.md
|
||||
:start-after: <!-- start more-usage -->
|
||||
:end-before: <!-- end more-usage -->
|
||||
```
|
||||
|
||||
The output slides should look this this:
|
||||
|
||||
```{eval-rst}
|
||||
|
71
docs/source/reference/customize_html.md
Normal file
71
docs/source/reference/customize_html.md
Normal file
@ -0,0 +1,71 @@
|
||||
# Customize your RevealJS slides
|
||||
|
||||
One of the benefits of the `convert` command is the use of template files.
|
||||
|
||||
Currently, only the HTML export uses one. If not specified, the template
|
||||
will be the one shipped with Manim Slides, see
|
||||
[`manim_slides/templates/revealjs.html`](https://github.com/jeertmans/manim-slides/blob/main/manim_slides/templates/revealjs.html).
|
||||
|
||||
Because you can actually use your own template with the `--use-template`
|
||||
option, possibilities are infinite!
|
||||
|
||||
:::{warning}
|
||||
Currently, the `PresentationConfig` class and its components
|
||||
are not part of the public API. You can still use them, e.g.,
|
||||
in the templates, but you may expect breaking changes between
|
||||
releases.
|
||||
|
||||
Eventually, this will become part of the public API too,
|
||||
and we will document its usage.
|
||||
:::
|
||||
|
||||
## Adding a clock to each slide
|
||||
|
||||
In this example, we show how to add a self-updating clock
|
||||
to the bottom left corner of every slide.
|
||||
|
||||
:::{note}
|
||||
This example is inspired from
|
||||
[@gsong-math's comment](https://github.com/jeertmans/manim-slides/issues/356#issuecomment-1902626943)
|
||||
on Manim Slides' repository.
|
||||
:::
|
||||
|
||||
### What to add
|
||||
|
||||
Whenever you want to create a template, it is best practice
|
||||
to start from the default one (see link above).
|
||||
|
||||
Modifying it needs very basic HTML/JavaScript/CSS skills.
|
||||
To add a clock, you can simply add the following to the
|
||||
default template file:
|
||||
|
||||
```{eval-rst}
|
||||
.. literalinclude:: ../_static/template.diff
|
||||
:language: html
|
||||
```
|
||||
|
||||
:::{tip}
|
||||
Because we use RevealJS to generate HTML slides,
|
||||
we recommend you to take a look at
|
||||
[RevealJS' documentation](https://revealjs.com/).
|
||||
:::
|
||||
|
||||
### How it renders
|
||||
|
||||
Then, using the `:template: <path/to/custom_template.html>`
|
||||
option, the basic example renders as follows:
|
||||
|
||||
```{eval-rst}
|
||||
.. manim-slides:: ../../../example.py:BasicExample
|
||||
:hide_source:
|
||||
:template: ../_static/template.html
|
||||
```
|
||||
|
||||
### Full code
|
||||
|
||||
Below, you can read the full content of the template file.
|
||||
|
||||
```{eval-rst}
|
||||
.. literalinclude:: ../_static/template.html
|
||||
:language: html+jinja
|
||||
```
|
@ -7,6 +7,7 @@ Automatically generated reference for Manim Slides.
|
||||
|
||||
api
|
||||
cli
|
||||
customize_html
|
||||
examples
|
||||
gui
|
||||
html
|
||||
|
@ -55,11 +55,11 @@
|
||||
" Text(\"Press\"),\n",
|
||||
" Text(\"and\"),\n",
|
||||
" Text(\"loop\"),\n",
|
||||
" ).arrange(DOWN, buff=1.)\n",
|
||||
" \n",
|
||||
" ).arrange(DOWN, buff=1.0)\n",
|
||||
"\n",
|
||||
" self.play(Write(text))\n",
|
||||
" self.next_slide(loop=True)\n",
|
||||
" self.play(Indicate(text[-1], scale_factor=2., run_time=.5))\n",
|
||||
" self.play(Indicate(text[-1], scale_factor=2.0, run_time=0.5))\n",
|
||||
" self.next_slide()\n",
|
||||
" self.play(FadeOut(text))"
|
||||
]
|
||||
|
@ -9,8 +9,10 @@ We will go from the *most restrictive* method, to the least restrictive one.
|
||||
If you need to present on a computer without prior knowledge on what will be
|
||||
installed on it, please directly refer to the last sections.
|
||||
|
||||
> **NOTES:** in the next sections, we will assume your animations are described
|
||||
:::{note}
|
||||
In the next sections, we will assume your animations are described
|
||||
in `example.py`, and you have one presentation called `BasicExample`.
|
||||
:::
|
||||
|
||||
## With Manim Slides installed on the target machine
|
||||
|
||||
@ -32,8 +34,8 @@ the key bindings might not be the same.
|
||||
Example:
|
||||
|
||||
```bash
|
||||
# If you use ManimGl, replace `manim` with `manimgl`
|
||||
manim example.py BasicExample
|
||||
# If you use ManimGl, add `--GL` after `render`
|
||||
manim-slides render example.py BasicExample
|
||||
|
||||
# This or `manim-slides BasicExample` works since
|
||||
# `present` is implied by default
|
||||
@ -128,7 +130,6 @@ The additional code comes from
|
||||
[this article](https://faq.dailymotion.com/hc/en-us/articles/360022841393-How-to-preserve-the-player-aspect-ratio-on-a-responsive-page)
|
||||
and it there to preserve the original aspect ratio (16:9).
|
||||
|
||||
|
||||
### Sharing ONE HTML file
|
||||
|
||||
If you set the `data_uri` option to `true` (with `-cdata_uri=true`),
|
||||
@ -144,10 +145,11 @@ This is how your are able to watch all the examples on this website. If you want
|
||||
to know how to share your slide with GitHub pages, see the
|
||||
[workflow file](https://github.com/jeertmans/manim-slides/blob/main/.github/workflows/pages.yml).
|
||||
|
||||
> **WARNING:** keep in mind that playing large video files over the internet
|
||||
:::{warning}
|
||||
Keep in mind that playing large video files over the internet network
|
||||
can take some time, and *glitches* may occur between slide transitions for this
|
||||
reason.
|
||||
|
||||
:::
|
||||
|
||||
### Using the Github starter template
|
||||
|
||||
|
@ -185,12 +185,11 @@ class Example(Slide):
|
||||
|
||||
self.play(Transform(step, step_5))
|
||||
self.play(Transform(code, code_step_5))
|
||||
self.next_slide()
|
||||
self.next_slide(auto_next=True)
|
||||
|
||||
self.play(Transform(step, step_6))
|
||||
self.play(Transform(code, code_step_6))
|
||||
self.play(code.animate.shift(UP), FadeIn(code_step_7), FadeIn(or_text))
|
||||
self.next_slide()
|
||||
|
||||
watch_text = Text("Watch result on next slides!").shift(2 * DOWN).scale(0.5)
|
||||
|
||||
|
@ -8,6 +8,7 @@ from .__version__ import __version__
|
||||
from .convert import convert
|
||||
from .logger import logger
|
||||
from .present import list_scenes, present
|
||||
from .render import render
|
||||
from .wizard import init, wizard
|
||||
|
||||
|
||||
@ -33,9 +34,7 @@ def cli(notify_outdated_version: bool) -> None:
|
||||
manim_info_url = "https://pypi.org/pypi/manim-slides/json"
|
||||
warn_prompt = "Cannot check if latest release of Manim Slides is installed"
|
||||
try:
|
||||
req_info: requests.models.Response = requests.get(
|
||||
manim_info_url, timeout=10
|
||||
)
|
||||
req_info: requests.models.Response = requests.get(manim_info_url, timeout=2)
|
||||
req_info.raise_for_status()
|
||||
stable = req_info.json()["info"]["version"]
|
||||
if stable != __version__:
|
||||
@ -67,6 +66,7 @@ cli.add_command(convert)
|
||||
cli.add_command(init)
|
||||
cli.add_command(list_scenes)
|
||||
cli.add_command(present)
|
||||
cli.add_command(render)
|
||||
cli.add_command(wizard)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
@ -1 +1 @@
|
||||
__version__ = "5.0.0-rc3"
|
||||
__version__ = "5.1.4"
|
||||
|
@ -1,6 +1,9 @@
|
||||
import json
|
||||
import shutil
|
||||
from functools import wraps
|
||||
from inspect import Parameter, signature
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
|
||||
|
||||
import rtoml
|
||||
@ -14,7 +17,6 @@ from pydantic import (
|
||||
model_validator,
|
||||
)
|
||||
from pydantic_extra_types.color import Color
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from .logger import logger
|
||||
|
||||
@ -35,6 +37,13 @@ class Signal(BaseModel): # type: ignore[misc]
|
||||
receiver(*args)
|
||||
|
||||
|
||||
def key_id(name: str) -> PositiveInt:
|
||||
"""Avoid importing Qt too early."""
|
||||
from qtpy.QtCore import Qt
|
||||
|
||||
return getattr(Qt, f"Key_{name}")
|
||||
|
||||
|
||||
class Key(BaseModel): # type: ignore[misc]
|
||||
"""Represents a list of key codes, with optionally a name."""
|
||||
|
||||
@ -70,14 +79,22 @@ class Key(BaseModel): # type: ignore[misc]
|
||||
|
||||
|
||||
class Keys(BaseModel): # type: ignore[misc]
|
||||
QUIT: Key = Key(ids=[Qt.Key_Q], name="QUIT")
|
||||
PLAY_PAUSE: Key = Key(ids=[Qt.Key_Space], name="PLAY / PAUSE")
|
||||
NEXT: Key = Key(ids=[Qt.Key_Right], name="NEXT")
|
||||
PREVIOUS: Key = Key(ids=[Qt.Key_Left], name="PREVIOUS")
|
||||
REVERSE: Key = Key(ids=[Qt.Key_V], name="REVERSE")
|
||||
REPLAY: Key = Key(ids=[Qt.Key_R], name="REPLAY")
|
||||
FULL_SCREEN: Key = Key(ids=[Qt.Key_F], name="TOGGLE FULL SCREEN")
|
||||
HIDE_MOUSE: Key = Key(ids=[Qt.Key_H], name="HIDE / SHOW MOUSE")
|
||||
QUIT: Key = Field(default_factory=lambda: Key(ids=[key_id("Q")], name="QUIT"))
|
||||
PLAY_PAUSE: Key = Field(
|
||||
default_factory=lambda: Key(ids=[key_id("Space")], name="PLAY / PAUSE")
|
||||
)
|
||||
NEXT: Key = Field(default_factory=lambda: Key(ids=[key_id("Right")], name="NEXT"))
|
||||
PREVIOUS: Key = Field(
|
||||
default_factory=lambda: Key(ids=[key_id("Left")], name="PREVIOUS")
|
||||
)
|
||||
REVERSE: Key = Field(default_factory=lambda: Key(ids=[key_id("V")], name="REVERSE"))
|
||||
REPLAY: Key = Field(default_factory=lambda: Key(ids=[key_id("R")], name="REPLAY"))
|
||||
FULL_SCREEN: Key = Field(
|
||||
default_factory=lambda: Key(ids=[key_id("F")], name="TOGGLE FULL SCREEN")
|
||||
)
|
||||
HIDE_MOUSE: Key = Field(
|
||||
default_factory=lambda: Key(ids=[key_id("H")], name="HIDE / SHOW MOUSE")
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
@ -118,7 +135,7 @@ class Keys(BaseModel): # type: ignore[misc]
|
||||
class Config(BaseModel): # type: ignore[misc]
|
||||
"""General Manim Slides config."""
|
||||
|
||||
keys: Keys = Keys()
|
||||
keys: Keys = Field(default_factory=Keys)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: Path) -> "Config":
|
||||
@ -135,10 +152,87 @@ class Config(BaseModel): # type: ignore[misc]
|
||||
return self
|
||||
|
||||
|
||||
class PreSlideConfig(BaseModel): # type: ignore
|
||||
class BaseSlideConfig(BaseModel): # type: ignore
|
||||
"""Base class for slide config."""
|
||||
|
||||
loop: bool = False
|
||||
auto_next: bool = False
|
||||
playback_rate: float = 1.0
|
||||
reversed_playback_rate: float = 1.0
|
||||
notes: str = ""
|
||||
dedent_notes: bool = True
|
||||
|
||||
@classmethod
|
||||
def wrapper(cls, arg_name: str) -> Callable[..., Any]:
|
||||
"""
|
||||
Wrap a function to transform keyword argument into an instance of this class.
|
||||
|
||||
The function signature is updated to reflect the new keyword-only arguments.
|
||||
|
||||
The wrapped function must follow two criteria:
|
||||
- its last parameter must be ``**kwargs`` (or equivalent);
|
||||
- and its second last parameter must be ``<arg_name>``.
|
||||
"""
|
||||
|
||||
def _wrapper_(fun: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@wraps(fun)
|
||||
def __wrapper__(*args: Any, **kwargs: Any) -> Any: # noqa: N807
|
||||
fun_kwargs = {
|
||||
key: value
|
||||
for key, value in kwargs.items()
|
||||
if key not in cls.__fields__
|
||||
}
|
||||
fun_kwargs[arg_name] = cls(**kwargs)
|
||||
return fun(*args, **fun_kwargs)
|
||||
|
||||
sig = signature(fun)
|
||||
parameters = list(sig.parameters.values())
|
||||
parameters[-2:-1] = [
|
||||
Parameter(
|
||||
field_name,
|
||||
Parameter.KEYWORD_ONLY,
|
||||
default=field_info.default,
|
||||
annotation=field_info.annotation,
|
||||
)
|
||||
for field_name, field_info in cls.__fields__.items()
|
||||
]
|
||||
|
||||
sig = sig.replace(parameters=parameters)
|
||||
__wrapper__.__signature__ = sig # type: ignore[attr-defined]
|
||||
|
||||
return __wrapper__
|
||||
|
||||
return _wrapper_
|
||||
|
||||
@model_validator(mode="after")
|
||||
@classmethod
|
||||
def apply_dedent_notes(
|
||||
cls, base_slide_config: "BaseSlideConfig"
|
||||
) -> "BaseSlideConfig":
|
||||
if base_slide_config.dedent_notes:
|
||||
base_slide_config.notes = dedent(base_slide_config.notes)
|
||||
|
||||
return base_slide_config
|
||||
|
||||
|
||||
class PreSlideConfig(BaseSlideConfig):
|
||||
"""Slide config to be used prior to rendering."""
|
||||
|
||||
start_animation: int
|
||||
end_animation: int
|
||||
loop: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_base_slide_config_and_animation_indices(
|
||||
cls,
|
||||
base_slide_config: BaseSlideConfig,
|
||||
start_animation: int,
|
||||
end_animation: int,
|
||||
) -> "PreSlideConfig":
|
||||
return cls(
|
||||
start_animation=start_animation,
|
||||
end_animation=end_animation,
|
||||
**base_slide_config.dict(),
|
||||
)
|
||||
|
||||
@field_validator("start_animation", "end_animation")
|
||||
@classmethod
|
||||
@ -164,21 +258,37 @@ class PreSlideConfig(BaseModel): # type: ignore
|
||||
|
||||
return pre_slide_config
|
||||
|
||||
@model_validator(mode="after")
|
||||
@classmethod
|
||||
def loop_and_auto_next_disallowed(
|
||||
cls, pre_slide_config: "PreSlideConfig"
|
||||
) -> "PreSlideConfig":
|
||||
if pre_slide_config.loop and pre_slide_config.auto_next:
|
||||
raise ValueError(
|
||||
"You cannot have both `loop=True` and `auto_next=True`, "
|
||||
"because a looping slide has no ending. "
|
||||
"This may be supported in the future if "
|
||||
"https://github.com/jeertmans/manim-slides/pull/299 gets merged."
|
||||
)
|
||||
|
||||
return pre_slide_config
|
||||
|
||||
@property
|
||||
def slides_slice(self) -> slice:
|
||||
return slice(self.start_animation, self.end_animation)
|
||||
|
||||
|
||||
class SlideConfig(BaseModel): # type: ignore[misc]
|
||||
class SlideConfig(BaseSlideConfig):
|
||||
"""Slide config to be used after rendering."""
|
||||
|
||||
file: FilePath
|
||||
rev_file: FilePath
|
||||
loop: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_pre_slide_config_and_files(
|
||||
cls, pre_slide_config: PreSlideConfig, file: Path, rev_file: Path
|
||||
) -> "SlideConfig":
|
||||
return cls(file=file, rev_file=rev_file, loop=pre_slide_config.loop)
|
||||
return cls(file=file, rev_file=rev_file, **pre_slide_config.dict())
|
||||
|
||||
|
||||
class PresentationConfig(BaseModel): # type: ignore[misc]
|
||||
@ -209,7 +319,9 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
|
||||
with open(path, "w") as f:
|
||||
f.write(self.model_dump_json(indent=2))
|
||||
|
||||
def copy_to(self, folder: Path, use_cached: bool = True) -> "PresentationConfig":
|
||||
def copy_to(
|
||||
self, folder: Path, use_cached: bool = True, include_reversed: bool = True
|
||||
) -> "PresentationConfig":
|
||||
"""Copy the files to a given directory."""
|
||||
for slide_config in self.slides:
|
||||
file = slide_config.file
|
||||
@ -224,10 +336,7 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
|
||||
if not use_cached or not dest.exists():
|
||||
shutil.copy(file, dest)
|
||||
|
||||
if not use_cached or not rev_dest.exists():
|
||||
if include_reversed and (not use_cached or not rev_dest.exists()):
|
||||
shutil.copy(rev_file, rev_dest)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
DEFAULT_CONFIG = Config()
|
||||
|
@ -6,13 +6,14 @@ import sys
|
||||
import tempfile
|
||||
import webbrowser
|
||||
from base64 import b64encode
|
||||
from collections import deque
|
||||
from enum import Enum
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Type, Union
|
||||
|
||||
import av
|
||||
import click
|
||||
import cv2
|
||||
import pptx
|
||||
from click import Context, Parameter
|
||||
from jinja2 import Template
|
||||
@ -61,7 +62,9 @@ def validate_config_option(
|
||||
config[key] = value
|
||||
except ValueError:
|
||||
raise click.BadParameter(
|
||||
f"Configuration options `{c_option}` could not be parsed into a proper (key, value) pair. Please use an `=` sign to separate key from value."
|
||||
f"Configuration options `{c_option}` could not be parsed into "
|
||||
"a proper (key, value) pair. "
|
||||
"Please use an `=` sign to separate key from value."
|
||||
) from None
|
||||
|
||||
return config
|
||||
@ -75,6 +78,27 @@ def file_to_data_uri(file: Path) -> str:
|
||||
return f"data:{mime_type};base64,{b64}"
|
||||
|
||||
|
||||
def get_duration_ms(file: Path) -> float:
|
||||
"""Read a video and return its duration in milliseconds."""
|
||||
with av.open(str(file)) as container:
|
||||
video = container.streams.video[0]
|
||||
|
||||
return float(1000 * video.duration * video.time_base)
|
||||
|
||||
|
||||
def read_image_from_video_file(file: Path, frame_index: "FrameIndex") -> Image:
|
||||
"""Read a image from a video file at a given index."""
|
||||
with av.open(str(file)) as container:
|
||||
frames = container.decode(video=0)
|
||||
|
||||
if frame_index == FrameIndex.last:
|
||||
(frame,) = deque(frames, 1)
|
||||
else:
|
||||
frame = next(frames)
|
||||
|
||||
return frame.to_image()
|
||||
|
||||
|
||||
class Converter(BaseModel): # type: ignore
|
||||
presentation_configs: conlist(PresentationConfig, min_length=1) # type: ignore[valid-type]
|
||||
assets_dir: str = "{basename}_assets"
|
||||
@ -344,7 +368,7 @@ class RevealJS(Converter):
|
||||
hide_cursor_time: int = 5000
|
||||
# Appearance options from RevealJS
|
||||
background_color: Color = "black"
|
||||
reveal_version: str = "4.6.1"
|
||||
reveal_version: str = "5.1.0"
|
||||
reveal_theme: RevealTheme = RevealTheme.black
|
||||
title: str = "Manim Slides"
|
||||
# Pydantic options
|
||||
@ -385,7 +409,7 @@ class RevealJS(Converter):
|
||||
full_assets_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for presentation_config in self.presentation_configs:
|
||||
presentation_config.copy_to(full_assets_dir)
|
||||
presentation_config.copy_to(full_assets_dir, include_reversed=False)
|
||||
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@ -395,8 +419,18 @@ class RevealJS(Converter):
|
||||
options = self.dict()
|
||||
options["assets_dir"] = assets_dir
|
||||
|
||||
has_notes = any(
|
||||
slide_config.notes != ""
|
||||
for presentation_config in self.presentation_configs
|
||||
for slide_config in presentation_config.slides
|
||||
)
|
||||
|
||||
content = revealjs_template.render(
|
||||
file_to_data_uri=file_to_data_uri, **options
|
||||
file_to_data_uri=file_to_data_uri,
|
||||
get_duration_ms=get_duration_ms,
|
||||
has_notes=has_notes,
|
||||
env=os.environ,
|
||||
**options,
|
||||
)
|
||||
|
||||
f.write(content)
|
||||
@ -417,23 +451,6 @@ class PDF(Converter):
|
||||
|
||||
def convert_to(self, dest: Path) -> None:
|
||||
"""Convert this configuration into a PDF presentation, saved to DEST."""
|
||||
|
||||
def read_image_from_video_file(file: Path, frame_index: FrameIndex) -> Image:
|
||||
cap = cv2.VideoCapture(str(file))
|
||||
|
||||
if frame_index == FrameIndex.last:
|
||||
index = cap.get(cv2.CAP_PROP_FRAME_COUNT)
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, index - 1)
|
||||
|
||||
ret, frame = cap.read()
|
||||
cap.release()
|
||||
|
||||
if ret:
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
return Image.fromarray(frame)
|
||||
else:
|
||||
raise ValueError("Failed to read {image_index} image from video file")
|
||||
|
||||
images = []
|
||||
|
||||
for i, presentation_config in enumerate(self.presentation_configs):
|
||||
@ -498,50 +515,48 @@ class PowerPoint(Converter):
|
||||
nsmap = {"p": "http://schemas.openxmlformats.org/presentationml/2006/main"}
|
||||
return etree.ElementBase.xpath(el, query, namespaces=nsmap)
|
||||
|
||||
def save_first_image_from_video_file(file: Path) -> Optional[str]:
|
||||
cap = cv2.VideoCapture(file.as_posix())
|
||||
ret, frame = cap.read()
|
||||
cap.release()
|
||||
with tempfile.TemporaryDirectory() as directory_name:
|
||||
directory = Path(directory_name)
|
||||
frame_number = 0
|
||||
for i, presentation_config in enumerate(self.presentation_configs):
|
||||
for slide_config in tqdm(
|
||||
presentation_config.slides,
|
||||
desc=f"Generating video slides for config {i + 1}",
|
||||
leave=False,
|
||||
):
|
||||
file = slide_config.file
|
||||
|
||||
if ret:
|
||||
f = tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".png")
|
||||
cv2.imwrite(f.name, frame)
|
||||
f.close()
|
||||
return f.name
|
||||
else:
|
||||
logger.warn("Failed to read first image from video file")
|
||||
return None
|
||||
mime_type = mimetypes.guess_type(file)[0]
|
||||
|
||||
for i, presentation_config in enumerate(self.presentation_configs):
|
||||
for slide_config in tqdm(
|
||||
presentation_config.slides,
|
||||
desc=f"Generating video slides for config {i + 1}",
|
||||
leave=False,
|
||||
):
|
||||
file = slide_config.file
|
||||
if self.poster_frame_image is None:
|
||||
poster_frame_image = str(directory / f"{frame_number}.png")
|
||||
image = read_image_from_video_file(
|
||||
file, frame_index=FrameIndex.first
|
||||
)
|
||||
image.save(poster_frame_image)
|
||||
|
||||
mime_type = mimetypes.guess_type(file)[0]
|
||||
frame_number += 1
|
||||
else:
|
||||
poster_frame_image = str(self.poster_frame_image)
|
||||
|
||||
if self.poster_frame_image is None:
|
||||
poster_frame_image = save_first_image_from_video_file(file)
|
||||
else:
|
||||
poster_frame_image = str(self.poster_frame_image)
|
||||
slide = prs.slides.add_slide(layout)
|
||||
movie = slide.shapes.add_movie(
|
||||
str(file),
|
||||
self.left,
|
||||
self.top,
|
||||
self.width * 9525,
|
||||
self.height * 9525,
|
||||
poster_frame_image=poster_frame_image,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
if slide_config.notes != "":
|
||||
slide.notes_slide.notes_text_frame.text = slide_config.notes
|
||||
|
||||
slide = prs.slides.add_slide(layout)
|
||||
movie = slide.shapes.add_movie(
|
||||
str(file),
|
||||
self.left,
|
||||
self.top,
|
||||
self.width * 9525,
|
||||
self.height * 9525,
|
||||
poster_frame_image=poster_frame_image,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
if self.auto_play_media:
|
||||
auto_play_media(movie, loop=slide_config.loop)
|
||||
if self.auto_play_media:
|
||||
auto_play_media(movie, loop=slide_config.loop)
|
||||
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
prs.save(dest)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
prs.save(dest)
|
||||
|
||||
|
||||
def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@ -606,10 +621,11 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@click.argument("dest", type=click.Path(dir_okay=False, path_type=Path))
|
||||
@click.option(
|
||||
"--to",
|
||||
type=click.Choice(["html", "pdf", "pptx"], case_sensitive=False),
|
||||
default="html",
|
||||
type=click.Choice(["auto", "html", "pdf", "pptx"], case_sensitive=False),
|
||||
metavar="FORMAT",
|
||||
default="auto",
|
||||
show_default=True,
|
||||
help="Set the conversion format to use.",
|
||||
help="Set the conversion format to use. Use 'auto' to detect format from DEST.",
|
||||
)
|
||||
@click.option(
|
||||
"--open",
|
||||
@ -624,14 +640,16 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"config_options",
|
||||
multiple=True,
|
||||
callback=validate_config_option,
|
||||
help="Configuration options passed to the converter. E.g., pass `-cslide_number=true` to display slide numbers.",
|
||||
help="Configuration options passed to the converter. "
|
||||
"E.g., pass ``-cslide_number=true`` to display slide numbers.",
|
||||
)
|
||||
@click.option(
|
||||
"--use-template",
|
||||
"template",
|
||||
metavar="FILE",
|
||||
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
||||
help="Use the template given by FILE instead of default one. To echo the default template, use `--show-template`.",
|
||||
help="Use the template given by FILE instead of default one. "
|
||||
"To echo the default template, use ``--show-template``.",
|
||||
)
|
||||
@show_template_option
|
||||
@show_config_options
|
||||
@ -650,7 +668,19 @@ def convert(
|
||||
presentation_configs = get_scenes_presentation_config(scenes, folder)
|
||||
|
||||
try:
|
||||
converter = Converter.from_string(to)(
|
||||
if to == "auto":
|
||||
fmt = dest.suffix[1:].lower()
|
||||
try:
|
||||
cls = Converter.from_string(fmt)
|
||||
except KeyError:
|
||||
logger.warn(
|
||||
f"Could not guess conversion format from {dest!s}, defaulting to HTML."
|
||||
)
|
||||
cls = RevealJS
|
||||
else:
|
||||
cls = Converter.from_string(to)
|
||||
|
||||
converter = cls(
|
||||
presentation_configs=presentation_configs,
|
||||
template=template,
|
||||
**config_options,
|
||||
|
@ -2,4 +2,3 @@ from pathlib import Path
|
||||
|
||||
FOLDER_PATH: Path = Path("./slides")
|
||||
CONFIG_PATH: Path = Path(".manim-slides.toml")
|
||||
FFMPEG_BIN: Path = Path("ffmpeg")
|
||||
|
@ -21,7 +21,9 @@ This directive requires three additional dependencies:
|
||||
with Sphinx.
|
||||
You can install them manually, or with the extra keyword:
|
||||
|
||||
pip install manim-slides[sphinx-directive]
|
||||
.. code-block:: bash
|
||||
|
||||
pip install "manim-slides[sphinx-directive]"
|
||||
|
||||
Note that you will still need to install Manim's platform-specific dependencies,
|
||||
see
|
||||
@ -69,6 +71,7 @@ render scenes that are defined within doctests, for example::
|
||||
>>> class DirectiveDoctestExample(Slide):
|
||||
... def construct(self):
|
||||
... self.play(Create(dot))
|
||||
...
|
||||
|
||||
A third application is to render scenes from another specific file::
|
||||
|
||||
@ -114,12 +117,72 @@ directive:
|
||||
A list of methods, separated by spaces,
|
||||
that is rendered in a reference block after the source code.
|
||||
|
||||
template
|
||||
A path to the template file to use.
|
||||
|
||||
config_options
|
||||
An unprocessed string of options to pass to ``manim-slides convert``.
|
||||
Options must be separated with a space, and each option must be
|
||||
a key, value pair using an equal sign as a separator.
|
||||
|
||||
Unlike for the CLI version, you don't need to prepend each option with
|
||||
``-c``.
|
||||
|
||||
E.g., pass ``slide_number=true controls=false``.
|
||||
|
||||
By default, ``controls=true`` is set.
|
||||
|
||||
Examples
|
||||
--------
|
||||
The following code::
|
||||
|
||||
.. manim-slides:: MySlide
|
||||
:hide_source:
|
||||
:config_options: slide_number=true controls=false
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class MySlide(Slide):
|
||||
def construct(self):
|
||||
text = Text("Hello")
|
||||
self.wipe([], text)
|
||||
|
||||
self.next_slide()
|
||||
self.play(text.animate.scale(2))
|
||||
|
||||
self.next_slide()
|
||||
self.zoom(text)
|
||||
|
||||
Renders as follows:
|
||||
|
||||
.. manim-slides:: MySlide
|
||||
:hide_source:
|
||||
:config_options: slide_number=true controls=false
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class MySlide(Slide):
|
||||
def construct(self):
|
||||
text = Text("Hello")
|
||||
self.wipe([], text)
|
||||
|
||||
self.next_slide()
|
||||
self.play(text.animate.scale(2))
|
||||
|
||||
self.next_slide()
|
||||
self.zoom(text)
|
||||
|
||||
|
||||
""" # noqa: D400, D415
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import itertools as it
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from timeit import timeit
|
||||
@ -195,6 +258,10 @@ class ManimSlidesDirective(Directive):
|
||||
"ref_classes": lambda arg: process_name_list(arg, "class"),
|
||||
"ref_functions": lambda arg: process_name_list(arg, "func"),
|
||||
"ref_methods": lambda arg: process_name_list(arg, "meth"),
|
||||
"template": lambda arg: Path(arg),
|
||||
"config_options": lambda arg: dict(
|
||||
option.split("=") for option in shlex.split(arg)
|
||||
),
|
||||
}
|
||||
final_argument_whitespace = True
|
||||
|
||||
@ -333,9 +400,20 @@ class ManimSlidesDirective(Directive):
|
||||
presentation_configs = get_scenes_presentation_config(
|
||||
[clsname], Path("./slides")
|
||||
)
|
||||
RevealJS(presentation_configs=presentation_configs, controls="true").convert_to(
|
||||
destfile
|
||||
)
|
||||
|
||||
template = self.options.get("template", None)
|
||||
|
||||
if template:
|
||||
template = source_file_name.parents[0].joinpath(template)
|
||||
|
||||
config_options = self.options.get("config_options", {})
|
||||
config_options.setdefault("controls", "true")
|
||||
|
||||
RevealJS(
|
||||
presentation_configs=presentation_configs,
|
||||
template=template,
|
||||
**config_options,
|
||||
).convert_to(destfile)
|
||||
|
||||
rendered_template = jinja2.Template(TEMPLATE).render(
|
||||
clsname=clsname,
|
||||
|
@ -16,12 +16,14 @@ Utilities for using Manim Slides with IPython (in particular: Jupyter notebooks)
|
||||
This magic requires two additional dependencies: ``manim`` and ``IPython``.
|
||||
You can install them manually, or with the extra keyword:
|
||||
|
||||
pip install manim-slides[magic]
|
||||
.. code-block:: bash
|
||||
|
||||
pip install "manim-slides[magic]"
|
||||
|
||||
Note that you will still need to install Manim's platform-specific dependencies,
|
||||
see
|
||||
`their installation page <https://docs.manim.community/en/stable/installation.html>`_.
|
||||
""" # noqa: D400, D415
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -142,6 +144,7 @@ class ManimSlidesMagic(Magics): # type: ignore
|
||||
In case you want to hide the red box containing the output progress bar, the ``progress_bar`` config
|
||||
option should be set to ``None``. This can also be done by passing ``--progress_bar None`` as a
|
||||
CLI flag.
|
||||
|
||||
"""
|
||||
if local_ns is None:
|
||||
local_ns = {}
|
||||
@ -246,9 +249,7 @@ class ManimSlidesMagic(Magics): # type: ignore
|
||||
)
|
||||
else:
|
||||
result = HTML(
|
||||
"""<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="{src}"></iframe></div>""".format(
|
||||
src=tmpfile.as_posix()
|
||||
)
|
||||
f"""<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="{tmpfile.as_posix()}"></iframe></div>"""
|
||||
)
|
||||
|
||||
display(result)
|
||||
|
@ -41,6 +41,9 @@ def make_logger() -> logging.Logger:
|
||||
logger.setLevel(logging.getLogger("manim").level)
|
||||
logger.addHandler(rich_handler)
|
||||
|
||||
if not (libav_logger := logging.getLogger("libav")).hasHandlers():
|
||||
libav_logger.addHandler(rich_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
|
@ -6,18 +6,27 @@ from typing import List, Optional, Tuple
|
||||
import click
|
||||
from click import Context, Parameter
|
||||
from pydantic import ValidationError
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from ..commons import config_path_option, folder_path_option, verbosity_option
|
||||
from ..config import Config, PresentationConfig
|
||||
from ..logger import logger
|
||||
from .player import Player
|
||||
|
||||
ASPECT_RATIO_MODES = {
|
||||
"keep": Qt.KeepAspectRatio,
|
||||
"ignore": Qt.IgnoreAspectRatio,
|
||||
}
|
||||
PREFERRED_QT_VERSIONS = ("6.5.1", "6.5.2")
|
||||
|
||||
|
||||
def warn_if_non_desirable_pyside6_version() -> None:
|
||||
from qtpy import API, QT_VERSION
|
||||
|
||||
if sys.version_info < (3, 12) and (
|
||||
API != "pyside6" or QT_VERSION not in PREFERRED_QT_VERSIONS
|
||||
):
|
||||
logger.warn(
|
||||
f"You are using {API = }, {QT_VERSION = }, "
|
||||
"but we recommend installing 'PySide6==6.5.2', mainly to avoid "
|
||||
"flashing screens between slides, "
|
||||
"see issue https://github.com/jeertmans/manim-slides/issues/293. "
|
||||
"You can do so with `pip install 'manim-slides[pyside6]'`."
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@ -130,7 +139,8 @@ def start_at_callback(
|
||||
return tuple(map(str_to_int_or_none, values_tuple))
|
||||
|
||||
raise click.BadParameter(
|
||||
f"exactly 2 arguments are expected but you gave {n_values}, please use commas to separate them",
|
||||
f"exactly 2 arguments are expected but you gave {n_values}, "
|
||||
"please use commas to separate them",
|
||||
ctx=ctx,
|
||||
param=param,
|
||||
)
|
||||
@ -154,7 +164,7 @@ def start_at_callback(
|
||||
"--skip-all",
|
||||
is_flag=True,
|
||||
help="Skip all slides, useful the test if slides are working. "
|
||||
"Automatically sets `--exit-after-last-slide` to True.",
|
||||
"Automatically sets ``--exit-after-last-slide`` to True.",
|
||||
)
|
||||
@click.option(
|
||||
"--exit-after-last-slide",
|
||||
@ -182,7 +192,7 @@ def start_at_callback(
|
||||
type=str,
|
||||
callback=start_at_callback,
|
||||
default=(None, None),
|
||||
help="Start presenting at (x, y), equivalent to --sacn x --sasn y, "
|
||||
help="Start presenting at (x, y), equivalent to ``--sacn x --sasn y``, "
|
||||
"and overrides values if not None.",
|
||||
)
|
||||
@click.option(
|
||||
@ -210,7 +220,35 @@ def start_at_callback(
|
||||
metavar="NUMBER",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Presents content on the given screen (a.k.a. display).",
|
||||
help="Present content on the given screen (a.k.a. display).",
|
||||
)
|
||||
@click.option(
|
||||
"--playback-rate",
|
||||
metavar="RATE",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="Playback rate of the video slides, see PySide6 docs for details. "
|
||||
" The playback rate of each slide is defined as the product of its default "
|
||||
" playback rate and the provided value.",
|
||||
)
|
||||
@click.option(
|
||||
"--next-terminates-loop",
|
||||
"next_terminates_loop",
|
||||
is_flag=True,
|
||||
help="If set, pressing next will turn any looping slide into a play slide.",
|
||||
)
|
||||
@click.option(
|
||||
"--hide-info-window",
|
||||
is_flag=True,
|
||||
help="Hide info window.",
|
||||
)
|
||||
@click.option(
|
||||
"--info-window-screen",
|
||||
"info_window_screen_number",
|
||||
metavar="NUMBER",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Put info window on the given screen (a.k.a. display).",
|
||||
)
|
||||
@click.help_option("-h", "--help")
|
||||
@verbosity_option
|
||||
@ -227,7 +265,11 @@ def present(
|
||||
start_at: Tuple[Optional[int], Optional[int], Optional[int]],
|
||||
start_at_scene_number: int,
|
||||
start_at_slide_number: int,
|
||||
screen_number: Optional[int] = None,
|
||||
screen_number: Optional[int],
|
||||
playback_rate: float,
|
||||
next_terminates_loop: bool,
|
||||
hide_info_window: bool,
|
||||
info_window_screen_number: Optional[int],
|
||||
) -> None:
|
||||
"""
|
||||
Present SCENE(s), one at a time, in order.
|
||||
@ -258,27 +300,44 @@ def present(
|
||||
start_at_scene_number = start_at[0]
|
||||
|
||||
if start_at[1]:
|
||||
start_at_scene_number = start_at[1]
|
||||
start_at_slide_number = start_at[1]
|
||||
|
||||
if maybe_app := QApplication.instance():
|
||||
app = maybe_app
|
||||
else:
|
||||
app = QApplication(sys.argv)
|
||||
warn_if_non_desirable_pyside6_version()
|
||||
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtGui import QScreen
|
||||
|
||||
from ..qt_utils import qapp
|
||||
from .player import Player
|
||||
|
||||
app = qapp()
|
||||
app.setApplicationName("Manim Slides")
|
||||
|
||||
if screen_number is not None:
|
||||
def get_screen(number: int) -> Optional[QScreen]:
|
||||
try:
|
||||
screen = app.screens()[screen_number]
|
||||
return app.screens()[number]
|
||||
except IndexError:
|
||||
logger.error(
|
||||
f"Invalid screen number {screen_number}, "
|
||||
f"Invalid screen number {number}, "
|
||||
f"allowed values are from 0 to {len(app.screens())-1} (incl.)"
|
||||
)
|
||||
screen = None
|
||||
return None
|
||||
|
||||
if screen_number is not None:
|
||||
screen = get_screen(screen_number)
|
||||
else:
|
||||
screen = None
|
||||
|
||||
if info_window_screen_number is not None:
|
||||
info_window_screen = get_screen(info_window_screen_number)
|
||||
else:
|
||||
info_window_screen = None
|
||||
|
||||
aspect_ratio_modes = {
|
||||
"keep": Qt.KeepAspectRatio,
|
||||
"ignore": Qt.IgnoreAspectRatio,
|
||||
}
|
||||
|
||||
player = Player(
|
||||
config,
|
||||
presentation_configs,
|
||||
@ -287,13 +346,17 @@ def present(
|
||||
skip_all=skip_all,
|
||||
exit_after_last_slide=exit_after_last_slide,
|
||||
hide_mouse=hide_mouse,
|
||||
aspect_ratio_mode=ASPECT_RATIO_MODES[aspect_ratio],
|
||||
aspect_ratio_mode=aspect_ratio_modes[aspect_ratio],
|
||||
presentation_index=start_at_scene_number,
|
||||
slide_index=start_at_slide_number,
|
||||
screen=screen,
|
||||
playback_rate=playback_rate,
|
||||
next_terminates_loop=next_terminates_loop,
|
||||
hide_info_window=hide_info_window,
|
||||
info_window_screen=info_window_screen,
|
||||
)
|
||||
|
||||
player.show()
|
||||
|
||||
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
||||
sys.exit(app.exec_())
|
||||
sys.exit(app.exec())
|
||||
|
@ -1,11 +1,18 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QUrl, Signal, Slot
|
||||
from PySide6.QtGui import QCloseEvent, QIcon, QKeyEvent, QScreen
|
||||
from PySide6.QtMultimedia import QMediaPlayer
|
||||
from PySide6.QtMultimediaWidgets import QVideoWidget
|
||||
from PySide6.QtWidgets import QDialog, QGridLayout, QLabel, QMainWindow
|
||||
from qtpy.QtCore import Qt, QTimer, QUrl, Signal, Slot
|
||||
from qtpy.QtGui import QCloseEvent, QIcon, QKeyEvent, QScreen
|
||||
from qtpy.QtMultimedia import QAudioOutput, QMediaPlayer
|
||||
from qtpy.QtMultimediaWidgets import QVideoWidget
|
||||
from qtpy.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..config import Config, PresentationConfig, SlideConfig
|
||||
from ..logger import logger
|
||||
@ -14,25 +21,145 @@ from ..resources import * # noqa: F403
|
||||
WINDOW_NAME = "Manim Slides"
|
||||
|
||||
|
||||
class Info(QDialog): # type: ignore[misc]
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
class Info(QWidget): # type: ignore[misc]
|
||||
key_press_event: Signal = Signal(QKeyEvent)
|
||||
close_event: Signal = Signal(QCloseEvent)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
full_screen: bool,
|
||||
aspect_ratio_mode: Qt.AspectRatioMode,
|
||||
screen: Optional[QScreen],
|
||||
) -> None:
|
||||
super().__init__()
|
||||
|
||||
if screen:
|
||||
self.setScreen(screen)
|
||||
self.move(screen.geometry().topLeft())
|
||||
|
||||
if full_screen:
|
||||
self.setWindowState(Qt.WindowFullScreen)
|
||||
|
||||
layout = QHBoxLayout()
|
||||
|
||||
# Current slide view
|
||||
|
||||
left_layout = QVBoxLayout()
|
||||
left_layout.addWidget(
|
||||
QLabel("Current slide"),
|
||||
alignment=Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter,
|
||||
)
|
||||
main_video_widget = QVideoWidget()
|
||||
main_video_widget.setAspectRatioMode(aspect_ratio_mode)
|
||||
main_video_widget.setFixedSize(720, 480)
|
||||
self.video_sink = main_video_widget.videoSink()
|
||||
left_layout.addWidget(main_video_widget)
|
||||
|
||||
# Current slide informations
|
||||
|
||||
layout = QGridLayout()
|
||||
self.scene_label = QLabel()
|
||||
self.slide_label = QLabel()
|
||||
self.start_time = datetime.now()
|
||||
self.time_label = QLabel()
|
||||
self.elapsed_label = QLabel("00h00m00s")
|
||||
self.timer = QTimer()
|
||||
self.timer.start(1000) # every second
|
||||
self.timer.timeout.connect(self.update_time)
|
||||
|
||||
layout.addWidget(QLabel("Scene:"), 1, 1)
|
||||
layout.addWidget(QLabel("Slide:"), 2, 1)
|
||||
layout.addWidget(self.scene_label, 1, 2)
|
||||
layout.addWidget(self.slide_label, 2, 2)
|
||||
self.setLayout(layout)
|
||||
self.setFixedWidth(150)
|
||||
self.setFixedHeight(80)
|
||||
bottom_left_layout = QHBoxLayout()
|
||||
bottom_left_layout.addWidget(
|
||||
QLabel("Scene:"),
|
||||
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight,
|
||||
)
|
||||
bottom_left_layout.addWidget(
|
||||
self.scene_label,
|
||||
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
|
||||
)
|
||||
bottom_left_layout.addWidget(
|
||||
QLabel("Slide:"),
|
||||
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight,
|
||||
)
|
||||
bottom_left_layout.addWidget(
|
||||
self.slide_label,
|
||||
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
|
||||
)
|
||||
bottom_left_layout.addWidget(
|
||||
QLabel("Time:"),
|
||||
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight,
|
||||
)
|
||||
bottom_left_layout.addWidget(
|
||||
self.time_label,
|
||||
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
|
||||
)
|
||||
bottom_left_layout.addWidget(
|
||||
QLabel("Elapsed:"),
|
||||
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight,
|
||||
)
|
||||
bottom_left_layout.addWidget(
|
||||
self.elapsed_label,
|
||||
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
|
||||
)
|
||||
left_layout.addLayout(bottom_left_layout)
|
||||
layout.addLayout(left_layout)
|
||||
|
||||
if parent := self.parent():
|
||||
self.closeEvent = parent.closeEvent
|
||||
self.keyPressEvent = parent.keyPressEvent
|
||||
layout.addSpacing(20)
|
||||
|
||||
# Next slide preview
|
||||
|
||||
right_layout = QVBoxLayout()
|
||||
right_layout.addWidget(
|
||||
QLabel("Next slide"),
|
||||
alignment=Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter,
|
||||
)
|
||||
next_video_widget = QVideoWidget()
|
||||
next_video_widget.setAspectRatioMode(aspect_ratio_mode)
|
||||
next_video_widget.setFixedSize(360, 240)
|
||||
self.next_media_player = QMediaPlayer()
|
||||
self.next_media_player.setVideoOutput(next_video_widget)
|
||||
self.next_media_player.setLoops(-1)
|
||||
|
||||
right_layout.addWidget(next_video_widget)
|
||||
|
||||
# Notes
|
||||
|
||||
self.slide_notes = QLabel()
|
||||
self.slide_notes.setWordWrap(True)
|
||||
self.slide_notes.setTextFormat(Qt.TextFormat.MarkdownText)
|
||||
self.slide_notes.setFixedWidth(360)
|
||||
right_layout.addWidget(
|
||||
self.slide_notes,
|
||||
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
|
||||
)
|
||||
layout.addLayout(right_layout)
|
||||
|
||||
widget = QWidget()
|
||||
|
||||
widget.setLayout(layout)
|
||||
|
||||
main_layout = QVBoxLayout()
|
||||
main_layout.addWidget(widget, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.setLayout(main_layout)
|
||||
|
||||
@Slot()
|
||||
def update_time(self) -> None:
|
||||
now = datetime.now()
|
||||
seconds = (now - self.start_time).total_seconds()
|
||||
hours, seconds = divmod(seconds, 3600)
|
||||
minutes, seconds = divmod(seconds, 60)
|
||||
self.time_label.setText(now.strftime("%Y/%m/%d %H:%M:%S"))
|
||||
self.elapsed_label.setText(
|
||||
f"{int(hours):02d}h{int(minutes):02d}m{int(seconds):02d}s"
|
||||
)
|
||||
|
||||
@Slot()
|
||||
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
|
||||
self.close_event.emit(event)
|
||||
|
||||
@Slot()
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
|
||||
self.key_press_event.emit(event)
|
||||
|
||||
|
||||
class Player(QMainWindow): # type: ignore[misc]
|
||||
@ -53,6 +180,10 @@ class Player(QMainWindow): # type: ignore[misc]
|
||||
presentation_index: int = 0,
|
||||
slide_index: int = 0,
|
||||
screen: Optional[QScreen] = None,
|
||||
playback_rate: float = 1.0,
|
||||
next_terminates_loop: bool = False,
|
||||
hide_info_window: bool = False,
|
||||
info_window_screen: Optional[QScreen] = None,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
@ -65,11 +196,12 @@ class Player(QMainWindow): # type: ignore[misc]
|
||||
self.presentation_configs = presentation_configs
|
||||
self.__current_presentation_index = 0
|
||||
self.__current_slide_index = 0
|
||||
self.__current_file: Path = self.current_slide_config.file
|
||||
|
||||
self.current_presentation_index = presentation_index
|
||||
self.current_slide_index = slide_index
|
||||
|
||||
self.__current_file: Path = self.current_slide_config.file
|
||||
|
||||
self.__playing_reversed_slide = False
|
||||
|
||||
# Widgets
|
||||
@ -94,21 +226,35 @@ class Player(QMainWindow): # type: ignore[misc]
|
||||
self.icon = QIcon(":/icon.png")
|
||||
self.setWindowIcon(self.icon)
|
||||
|
||||
self.audio_output = QAudioOutput()
|
||||
self.video_widget = QVideoWidget()
|
||||
self.video_sink = self.video_widget.videoSink()
|
||||
self.video_widget.setAspectRatioMode(aspect_ratio_mode)
|
||||
self.setCentralWidget(self.video_widget)
|
||||
|
||||
self.media_player = QMediaPlayer(self)
|
||||
self.media_player.setAudioOutput(self.audio_output)
|
||||
self.media_player.setVideoOutput(self.video_widget)
|
||||
self.playback_rate = playback_rate
|
||||
|
||||
self.presentation_changed.connect(self.presentation_changed_callback)
|
||||
self.slide_changed.connect(self.slide_changed_callback)
|
||||
|
||||
self.info = Info(parent=self)
|
||||
self.info = Info(
|
||||
full_screen=full_screen,
|
||||
aspect_ratio_mode=aspect_ratio_mode,
|
||||
screen=info_window_screen,
|
||||
)
|
||||
self.info.close_event.connect(self.closeEvent)
|
||||
self.info.key_press_event.connect(self.keyPressEvent)
|
||||
self.video_sink.videoFrameChanged.connect(
|
||||
lambda frame: self.info.video_sink.setVideoFrame(frame)
|
||||
)
|
||||
self.hide_info_window = hide_info_window
|
||||
|
||||
# Connecting key callbacks
|
||||
|
||||
self.config.keys.QUIT.connect(self.quit)
|
||||
self.config.keys.QUIT.connect(self.close)
|
||||
self.config.keys.PLAY_PAUSE.connect(self.play_pause)
|
||||
self.config.keys.NEXT.connect(self.next)
|
||||
self.config.keys.PREVIOUS.connect(self.previous)
|
||||
@ -122,6 +268,7 @@ class Player(QMainWindow): # type: ignore[misc]
|
||||
# Misc
|
||||
|
||||
self.exit_after_last_slide = exit_after_last_slide
|
||||
self.next_terminates_loop = next_terminates_loop
|
||||
|
||||
# Setting-up everything
|
||||
|
||||
@ -129,7 +276,18 @@ class Player(QMainWindow): # type: ignore[misc]
|
||||
|
||||
def media_status_changed(status: QMediaPlayer.MediaStatus) -> None:
|
||||
self.media_player.setLoops(1) # Otherwise looping slides never end
|
||||
if status == QMediaPlayer.EndOfMedia:
|
||||
if status == QMediaPlayer.MediaStatus.EndOfMedia:
|
||||
self.load_next_slide()
|
||||
|
||||
self.media_player.mediaStatusChanged.connect(media_status_changed)
|
||||
|
||||
else:
|
||||
|
||||
def media_status_changed(status: QMediaPlayer.MediaStatus) -> None:
|
||||
if (
|
||||
status == QMediaPlayer.MediaStatus.EndOfMedia
|
||||
and self.current_slide_config.auto_next
|
||||
):
|
||||
self.load_next_slide()
|
||||
|
||||
self.media_player.mediaStatusChanged.connect(media_status_changed)
|
||||
@ -161,7 +319,7 @@ class Player(QMainWindow): # type: ignore[misc]
|
||||
elif -self.presentations_count <= index < 0:
|
||||
self.__current_presentation_index = index + self.presentations_count
|
||||
else:
|
||||
logger.warn(f"Could not set presentation index to {index}")
|
||||
logger.warn(f"Could not set presentation index to {index}.")
|
||||
return
|
||||
|
||||
self.presentation_changed.emit()
|
||||
@ -185,7 +343,7 @@ class Player(QMainWindow): # type: ignore[misc]
|
||||
elif -self.current_slides_count <= index < 0:
|
||||
self.__current_slide_index = index + self.current_slides_count
|
||||
else:
|
||||
logger.warn(f"Could not set slide index to {index}")
|
||||
logger.warn(f"Could not set slide index to {index}.")
|
||||
return
|
||||
|
||||
self.slide_changed.emit()
|
||||
@ -202,6 +360,28 @@ class Player(QMainWindow): # type: ignore[misc]
|
||||
def current_file(self, file: Path) -> None:
|
||||
self.__current_file = file
|
||||
|
||||
@property
|
||||
def next_slide_config(self) -> Optional[SlideConfig]:
|
||||
if self.playing_reversed_slide:
|
||||
return self.current_slide_config
|
||||
elif self.current_slide_index < self.current_slides_count - 1:
|
||||
return self.presentation_configs[self.current_presentation_index].slides[
|
||||
self.current_slide_index + 1
|
||||
]
|
||||
elif self.current_presentation_index < self.presentations_count - 1:
|
||||
return self.presentation_configs[
|
||||
self.current_presentation_index + 1
|
||||
].slides[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def next_file(self) -> Optional[Path]:
|
||||
if slide_config := self.next_slide_config:
|
||||
return slide_config.file # type: ignore[no-any-return]
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def playing_reversed_slide(self) -> bool:
|
||||
return self.__playing_reversed_slide
|
||||
@ -215,9 +395,18 @@ class Player(QMainWindow): # type: ignore[misc]
|
||||
"""
|
||||
|
||||
def load_current_media(self, start_paused: bool = False) -> None:
|
||||
url = QUrl.fromLocalFile(self.current_file)
|
||||
url = QUrl.fromLocalFile(str(self.current_file))
|
||||
self.media_player.setSource(url)
|
||||
|
||||
if self.playing_reversed_slide:
|
||||
self.media_player.setPlaybackRate(
|
||||
self.current_slide_config.reversed_playback_rate * self.playback_rate
|
||||
)
|
||||
else:
|
||||
self.media_player.setPlaybackRate(
|
||||
self.current_slide_config.playback_rate * self.playback_rate
|
||||
)
|
||||
|
||||
if start_paused:
|
||||
self.media_player.pause()
|
||||
else:
|
||||
@ -251,13 +440,15 @@ class Player(QMainWindow): # type: ignore[misc]
|
||||
def load_next_slide(self) -> None:
|
||||
if self.playing_reversed_slide:
|
||||
self.playing_reversed_slide = False
|
||||
self.preview_next_slide() # Slide number did not change, but next did
|
||||
elif self.current_slide_index < self.current_slides_count - 1:
|
||||
self.current_slide_index += 1
|
||||
elif self.current_presentation_index < self.presentations_count - 1:
|
||||
self.current_presentation_index += 1
|
||||
self.current_slide_index = 0
|
||||
elif self.exit_after_last_slide:
|
||||
self.quit()
|
||||
self.close()
|
||||
return
|
||||
else:
|
||||
logger.info("No more slide to play.")
|
||||
return
|
||||
@ -284,20 +475,36 @@ class Player(QMainWindow): # type: ignore[misc]
|
||||
index = self.current_slide_index
|
||||
count = self.current_slides_count
|
||||
self.info.slide_label.setText(f"{index+1:4d}/{count:4<d}")
|
||||
self.info.slide_notes.setText(self.current_slide_config.notes)
|
||||
self.preview_next_slide()
|
||||
|
||||
def preview_next_slide(self) -> None:
|
||||
if slide_config := self.next_slide_config:
|
||||
url = QUrl.fromLocalFile(str(slide_config.file))
|
||||
self.info.next_media_player.setSource(url)
|
||||
self.info.next_media_player.play()
|
||||
|
||||
def show(self) -> None:
|
||||
super().show()
|
||||
self.info.show()
|
||||
|
||||
if not self.hide_info_window:
|
||||
self.info.show()
|
||||
|
||||
@Slot()
|
||||
def quit(self) -> None:
|
||||
def close(self) -> None:
|
||||
logger.info("Closing gracefully...")
|
||||
self.info.deleteLater()
|
||||
self.deleteLater()
|
||||
self.info.close()
|
||||
super().close()
|
||||
|
||||
@Slot()
|
||||
def next(self) -> None:
|
||||
if self.media_player.playbackState() == QMediaPlayer.PausedState:
|
||||
if self.media_player.playbackState() == QMediaPlayer.PlaybackState.PausedState:
|
||||
self.media_player.play()
|
||||
elif self.next_terminates_loop and self.media_player.loops() != 1:
|
||||
position = self.media_player.position()
|
||||
self.media_player.setLoops(1)
|
||||
self.media_player.stop()
|
||||
self.media_player.setPosition(position)
|
||||
self.media_player.play()
|
||||
else:
|
||||
self.load_next_slide()
|
||||
@ -309,6 +516,7 @@ class Player(QMainWindow): # type: ignore[misc]
|
||||
@Slot()
|
||||
def reverse(self) -> None:
|
||||
self.load_reversed_slide()
|
||||
self.preview_next_slide()
|
||||
|
||||
@Slot()
|
||||
def replay(self) -> None:
|
||||
@ -318,9 +526,9 @@ class Player(QMainWindow): # type: ignore[misc]
|
||||
@Slot()
|
||||
def play_pause(self) -> None:
|
||||
state = self.media_player.playbackState()
|
||||
if state == QMediaPlayer.PausedState:
|
||||
if state == QMediaPlayer.PlaybackState.PausedState:
|
||||
self.media_player.play()
|
||||
elif state == QMediaPlayer.PlayingState:
|
||||
elif state == QMediaPlayer.PlaybackState.PlayingState:
|
||||
self.media_player.pause()
|
||||
|
||||
@Slot()
|
||||
@ -338,7 +546,7 @@ class Player(QMainWindow): # type: ignore[misc]
|
||||
self.setCursor(Qt.BlankCursor)
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
|
||||
self.quit()
|
||||
self.close()
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
|
||||
key = event.key()
|
||||
|
14
manim_slides/qt_utils.py
Normal file
14
manim_slides/qt_utils.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""Qt utils."""
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
|
||||
def qapp() -> QApplication:
|
||||
"""
|
||||
Return a QApplication instance, creating one
|
||||
if needed.
|
||||
"""
|
||||
if app := QApplication.instance():
|
||||
return app
|
||||
|
||||
return QApplication([])
|
54
manim_slides/render.py
Normal file
54
manim_slides/render.py
Normal file
@ -0,0 +1,54 @@
|
||||
"""
|
||||
Alias command to either
|
||||
``manim render [OPTIONS] [ARGS]...`` or
|
||||
``manimgl [OPTIONS] [ARGS]...``.
|
||||
|
||||
This is especially useful for two reasons:
|
||||
|
||||
1. You can are sure to execute the rendering command with the same Python environment
|
||||
as for ``manim-slides``.
|
||||
2. You can pass options to the config.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Tuple
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command(
|
||||
context_settings=dict(
|
||||
ignore_unknown_options=True, allow_extra_args=True, help_option_names=("-h",)
|
||||
),
|
||||
options_metavar="[-h] [--CE|--GL]",
|
||||
)
|
||||
@click.option(
|
||||
"--CE",
|
||||
is_flag=True,
|
||||
envvar="MANIM_RENDERER",
|
||||
show_envvar=True,
|
||||
help="If set, use Manim Community Edition (CE) renderer. "
|
||||
"If this or ``--GL`` is not set, defaults to CE renderer.",
|
||||
)
|
||||
@click.option(
|
||||
"--GL",
|
||||
is_flag=True,
|
||||
envvar="MANIMGL_RENDERER",
|
||||
show_envvar=True,
|
||||
help="If set, use ManimGL renderer.",
|
||||
)
|
||||
@click.argument("args", metavar="[RENDERER_ARGS]...", nargs=-1, type=click.UNPROCESSED)
|
||||
def render(ce: bool, gl: bool, args: Tuple[str, ...]) -> None:
|
||||
"""
|
||||
Render SCENE(s) from the input FILE, using the specified renderer.
|
||||
|
||||
Use ``manim-slides render --help`` to see help information for
|
||||
a specific renderer.
|
||||
"""
|
||||
if ce and gl:
|
||||
raise click.UsageError("You cannot specify both --CE and --GL renderers.")
|
||||
if gl:
|
||||
subprocess.run([sys.executable, "-m", "manimlib", *args])
|
||||
else:
|
||||
subprocess.run([sys.executable, "-m", "manim", "render", *args])
|
@ -4,7 +4,7 @@
|
||||
# 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
|
||||
from qtpy import QtCore
|
||||
|
||||
qt_resource_data = b"\
|
||||
\x00\x00\x08\x1c\
|
||||
|
@ -58,6 +58,7 @@ class Wipe(AnimationGroup): # type: ignore[misc]
|
||||
self.next_slide()
|
||||
|
||||
self.play(Wipe(circle, square, shift=3 * LEFT))
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@ -122,6 +123,7 @@ class Zoom(AnimationGroup): # type: ignore[misc]
|
||||
for i in range(2):
|
||||
self.play(Zoom(circles[i], circles[i+1]))
|
||||
self.next_slide()
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
|
@ -1,19 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["BaseSlide"]
|
||||
|
||||
import platform
|
||||
from abc import abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Any, List, MutableMapping, Optional, Sequence, Tuple, ValuesView
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
MutableMapping,
|
||||
Sequence,
|
||||
ValuesView,
|
||||
)
|
||||
|
||||
import numpy as np
|
||||
from tqdm import tqdm
|
||||
|
||||
from ..config import PresentationConfig, PreSlideConfig, SlideConfig
|
||||
from ..defaults import FFMPEG_BIN, FOLDER_PATH
|
||||
from ..config import BaseSlideConfig, PresentationConfig, PreSlideConfig, SlideConfig
|
||||
from ..defaults import FOLDER_PATH
|
||||
from ..logger import logger
|
||||
from ..utils import concatenate_video_files, merge_basenames, reverse_video_file
|
||||
from . import MANIM
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .animation import Wipe, Zoom
|
||||
|
||||
if MANIM:
|
||||
from manim.mobject.mobject import Mobject
|
||||
else:
|
||||
@ -28,66 +39,61 @@ class BaseSlide:
|
||||
) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._output_folder: Path = output_folder
|
||||
self._slides: List[PreSlideConfig] = []
|
||||
self._pre_slide_config_kwargs: MutableMapping[str, Any] = {}
|
||||
self._slides: list[PreSlideConfig] = []
|
||||
self._base_slide_config: BaseSlideConfig = BaseSlideConfig()
|
||||
self._current_slide = 1
|
||||
self._current_animation = 0
|
||||
self._start_animation = 0
|
||||
self._canvas: MutableMapping[str, Mobject] = {}
|
||||
self._wait_time_between_slides = 0.0
|
||||
|
||||
@property
|
||||
def _ffmpeg_bin(self) -> Path:
|
||||
"""Return the path to the ffmpeg binaries."""
|
||||
return FFMPEG_BIN
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _frame_height(self) -> float:
|
||||
"""Return the scene's frame height."""
|
||||
...
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _frame_width(self) -> float:
|
||||
"""Return the scene's frame width."""
|
||||
...
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _background_color(self) -> str:
|
||||
"""Return the scene's background color."""
|
||||
...
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _resolution(self) -> Tuple[int, int]:
|
||||
def _resolution(self) -> tuple[int, int]:
|
||||
"""Return the scene's resolution used during rendering."""
|
||||
...
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _partial_movie_files(self) -> List[Path]:
|
||||
def _partial_movie_files(self) -> list[Path]:
|
||||
"""Return a list of partial movie files, a.k.a animations."""
|
||||
...
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _show_progress_bar(self) -> bool:
|
||||
"""Return True if progress bar should be displayed."""
|
||||
...
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _leave_progress_bar(self) -> bool:
|
||||
"""Return True if progress bar should be left after completed."""
|
||||
...
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _start_at_animation_number(self) -> Optional[int]:
|
||||
def _start_at_animation_number(self) -> int | None:
|
||||
"""If set, return the animation number at which rendering start."""
|
||||
...
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def canvas(self) -> MutableMapping[str, Mobject]:
|
||||
@ -150,6 +156,7 @@ class BaseSlide:
|
||||
|
||||
self.remove_from_canvas("title", "slide_number")
|
||||
self.wipe(self.mobjects_without_canvas, [])
|
||||
|
||||
"""
|
||||
return self._canvas
|
||||
|
||||
@ -185,7 +192,9 @@ class BaseSlide:
|
||||
the canvas.
|
||||
"""
|
||||
return [
|
||||
mobject for mobject in self.mobjects if mobject not in self.canvas_mobjects # type: ignore[attr-defined]
|
||||
mobject
|
||||
for mobject in self.mobjects # type: ignore[attr-defined]
|
||||
if mobject not in self.canvas_mobjects
|
||||
]
|
||||
|
||||
@property
|
||||
@ -240,6 +249,7 @@ class BaseSlide:
|
||||
self.next_slide()
|
||||
|
||||
self.play(FadeOut(circle))
|
||||
|
||||
"""
|
||||
return self._wait_time_between_slides
|
||||
|
||||
@ -252,7 +262,13 @@ class BaseSlide:
|
||||
super().play(*args, **kwargs) # type: ignore[misc]
|
||||
self._current_animation += 1
|
||||
|
||||
def next_slide(self, *, loop: bool = False, **kwargs: Any) -> None:
|
||||
@BaseSlideConfig.wrapper("base_slide_config")
|
||||
def next_slide(
|
||||
self,
|
||||
*,
|
||||
base_slide_config: BaseSlideConfig,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""
|
||||
Create a new slide with previous animations, and setup options
|
||||
for the next slide.
|
||||
@ -266,6 +282,29 @@ class BaseSlide:
|
||||
or ignored if `manimlib` API is used.
|
||||
:param loop:
|
||||
If set, next slide will be looping.
|
||||
:param auto_next:
|
||||
If set, next slide will play immediately play the next slide
|
||||
upon terminating.
|
||||
|
||||
Note that this is only supported by ``manim-slides present``
|
||||
and ``manim-slides convert --to=html``.
|
||||
:param playback_rate:
|
||||
Playback rate at which the video is played.
|
||||
|
||||
Note that this is only supported by ``manim-slides present``.
|
||||
:param reversed_playback_rate:
|
||||
Playback rate at which the reversed video is played.
|
||||
|
||||
Note that this is only supported by ``manim-slides present``.
|
||||
:param notes:
|
||||
Presenter notes, in Markdown format.
|
||||
|
||||
Note that PowerPoint does not support Markdown.
|
||||
|
||||
Note that this is only supported by ``manim-slides present``
|
||||
and ``manim-slides convert --to=html/pptx``.
|
||||
:param dedent_notes:
|
||||
If set, apply :func:`textwrap.dedent` to notes.
|
||||
:param kwargs:
|
||||
Keyword arguments to be passed to
|
||||
:meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`,
|
||||
@ -328,21 +367,68 @@ class BaseSlide:
|
||||
self.next_slide()
|
||||
|
||||
self.play(FadeOut(dot))
|
||||
|
||||
The following contains one slide that triggers the next slide
|
||||
upon terminating.
|
||||
|
||||
.. manim-slides:: AutoNextExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class AutoNextExample(Slide):
|
||||
def construct(self):
|
||||
square = Square(color=RED, side_length=2)
|
||||
|
||||
self.play(GrowFromCenter(square))
|
||||
|
||||
self.next_slide(auto_next=True)
|
||||
|
||||
self.play(Wiggle(square))
|
||||
|
||||
self.next_slide()
|
||||
|
||||
self.wipe(square)
|
||||
|
||||
The following contains speaker notes. On the webbrowser,
|
||||
the speaker view can be triggered by pressing :kbd:`S`.
|
||||
|
||||
.. manim-slides:: SpeakerNotesExample
|
||||
|
||||
from manim import *
|
||||
from manim_slides import Slide
|
||||
|
||||
class SpeakerNotesExample(Slide):
|
||||
def construct(self):
|
||||
self.next_slide(notes="Some introduction")
|
||||
square = Square(color=GREEN, side_length=2)
|
||||
|
||||
self.play(GrowFromCenter(square))
|
||||
|
||||
self.next_slide(notes="We now rotate the slide")
|
||||
|
||||
self.play(Rotate(square, PI / 2))
|
||||
|
||||
self.next_slide(notes="Bye bye")
|
||||
|
||||
self.zoom(square)
|
||||
|
||||
"""
|
||||
if self._current_animation > self._start_animation:
|
||||
if self.wait_time_between_slides > 0.0:
|
||||
self.wait(self.wait_time_between_slides) # type: ignore[attr-defined]
|
||||
|
||||
self._slides.append(
|
||||
PreSlideConfig(
|
||||
start_animation=self._start_animation,
|
||||
end_animation=self._current_animation,
|
||||
**self._pre_slide_config_kwargs,
|
||||
PreSlideConfig.from_base_slide_config_and_animation_indices(
|
||||
self._base_slide_config,
|
||||
self._start_animation,
|
||||
self._current_animation,
|
||||
)
|
||||
)
|
||||
|
||||
self._pre_slide_config_kwargs = dict(loop=loop)
|
||||
self._current_slide += 1
|
||||
self._current_slide += 1
|
||||
|
||||
self._base_slide_config = base_slide_config
|
||||
self._start_animation = self._current_animation
|
||||
|
||||
def _add_last_slide(self) -> None:
|
||||
@ -354,10 +440,10 @@ class BaseSlide:
|
||||
return
|
||||
|
||||
self._slides.append(
|
||||
PreSlideConfig(
|
||||
start_animation=self._start_animation,
|
||||
end_animation=self._current_animation,
|
||||
**self._pre_slide_config_kwargs,
|
||||
PreSlideConfig.from_base_slide_config_and_animation_indices(
|
||||
self._base_slide_config,
|
||||
self._start_animation,
|
||||
self._current_animation,
|
||||
)
|
||||
)
|
||||
|
||||
@ -376,7 +462,7 @@ class BaseSlide:
|
||||
|
||||
scene_files_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
files: List[Path] = self._partial_movie_files
|
||||
files: list[Path] = self._partial_movie_files
|
||||
|
||||
# We must filter slides that end before the animation offset
|
||||
if offset := self._start_at_animation_number:
|
||||
@ -387,7 +473,7 @@ class BaseSlide:
|
||||
slide.start_animation = max(0, slide.start_animation - offset)
|
||||
slide.end_animation -= offset
|
||||
|
||||
slides: List[SlideConfig] = []
|
||||
slides: list[SlideConfig] = []
|
||||
|
||||
for pre_slide_config in tqdm(
|
||||
self._slides,
|
||||
@ -404,11 +490,11 @@ class BaseSlide:
|
||||
|
||||
# We only concat animations if it was not present
|
||||
if not use_cache or not dst_file.exists():
|
||||
concatenate_video_files(self._ffmpeg_bin, slide_files, dst_file)
|
||||
concatenate_video_files(slide_files, dst_file)
|
||||
|
||||
# We only reverse video if it was not present
|
||||
if not use_cache or not rev_file.exists():
|
||||
reverse_video_file(self._ffmpeg_bin, dst_file, rev_file)
|
||||
reverse_video_file(dst_file, rev_file)
|
||||
|
||||
slides.append(
|
||||
SlideConfig.from_pre_slide_config_and_files(
|
||||
@ -436,8 +522,9 @@ class BaseSlide:
|
||||
self,
|
||||
*args: Any,
|
||||
direction: np.ndarray = LEFT,
|
||||
return_animation: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
) -> Wipe | None:
|
||||
"""
|
||||
Play a wipe animation that will shift all the current objects outside of the
|
||||
current scene's scope, and all the future objects inside.
|
||||
@ -445,6 +532,8 @@ class BaseSlide:
|
||||
:param args: Positional arguments passed to
|
||||
:class:`Wipe<manim_slides.slide.animation.Wipe>`.
|
||||
:param direction: The wipe direction, that will be scaled by the scene size.
|
||||
:param return_animation: If set, return the animation instead of
|
||||
playing it. This is useful to combine multiple animations with this one.
|
||||
:param kwargs: Keyword arguments passed to
|
||||
:class:`Wipe<manim_slides.slide.animation.Wipe>`.
|
||||
|
||||
@ -471,7 +560,14 @@ class BaseSlide:
|
||||
self.wipe(Group(square, text), beautiful, direction=UP)
|
||||
self.next_slide()
|
||||
|
||||
self.wipe(beautiful, circle, direction=DOWN + RIGHT)
|
||||
anim = self.wipe(
|
||||
beautiful,
|
||||
circle,
|
||||
direction=DOWN + RIGHT,
|
||||
return_animation=True
|
||||
)
|
||||
self.play(anim)
|
||||
|
||||
"""
|
||||
from .animation import Wipe
|
||||
|
||||
@ -486,13 +582,18 @@ class BaseSlide:
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
if return_animation:
|
||||
return animation
|
||||
|
||||
self.play(animation)
|
||||
return None
|
||||
|
||||
def zoom(
|
||||
self,
|
||||
*args: Any,
|
||||
return_animation: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
) -> Zoom | None:
|
||||
"""
|
||||
Play a zoom animation that will fade out all the current objects, and fade in
|
||||
all the future objects. Objects are faded in a direction that goes towards the
|
||||
@ -500,6 +601,8 @@ class BaseSlide:
|
||||
|
||||
:param args: Positional arguments passed to
|
||||
:class:`Zoom<manim_slides.slide.animation.Zoom>`.
|
||||
:param return_animation: If set, return the animation instead of
|
||||
playing it. This is useful to combine multiple animations with this one.
|
||||
:param kwargs: Keyword arguments passed to
|
||||
:class:`Zoom<manim_slides.slide.animation.Zoom>`.
|
||||
|
||||
@ -521,10 +624,22 @@ class BaseSlide:
|
||||
self.zoom(circle, square)
|
||||
self.next_slide()
|
||||
|
||||
self.zoom(square, circle, out=True, scale=10.0)
|
||||
anim = self.zoom(
|
||||
square,
|
||||
circle,
|
||||
out=True,
|
||||
scale=10.0,
|
||||
return_animation=True
|
||||
)
|
||||
self.play(anim)
|
||||
|
||||
"""
|
||||
from .animation import Zoom
|
||||
|
||||
animation = Zoom(*args, **kwargs)
|
||||
|
||||
if return_animation:
|
||||
return animation
|
||||
|
||||
self.play(animation)
|
||||
return None
|
||||
|
@ -3,6 +3,7 @@ from typing import Any, List, Optional, Tuple
|
||||
|
||||
from manim import Scene, ThreeDScene, config
|
||||
|
||||
from ..config import BaseSlideConfig
|
||||
from .base import BaseSlide
|
||||
|
||||
|
||||
@ -12,15 +13,6 @@ class Slide(BaseSlide, Scene): # type: ignore[misc]
|
||||
for slides rendering.
|
||||
"""
|
||||
|
||||
@property
|
||||
def _ffmpeg_bin(self) -> Path:
|
||||
# Prior to v0.16.0.post0,
|
||||
# ffmpeg was stored as a constant in manim.constants
|
||||
try:
|
||||
return Path(config.ffmpeg_executable)
|
||||
except AttributeError:
|
||||
return super()._ffmpeg_bin
|
||||
|
||||
@property
|
||||
def _frame_height(self) -> float:
|
||||
return config["frame_height"] # type: ignore
|
||||
@ -31,7 +23,7 @@ class Slide(BaseSlide, Scene): # type: ignore[misc]
|
||||
|
||||
@property
|
||||
def _background_color(self) -> str:
|
||||
color = config["background_color"]
|
||||
color = self.camera.background_color
|
||||
if hex_color := getattr(color, "hex", None):
|
||||
return hex_color # type: ignore
|
||||
else: # manim>=0.18, see https://github.com/ManimCommunity/manim/pull/3020
|
||||
@ -79,9 +71,18 @@ class Slide(BaseSlide, Scene): # type: ignore[misc]
|
||||
"""
|
||||
self.next_slide(*args, **kwargs)
|
||||
|
||||
def next_slide(self, *args: Any, loop: bool = False, **kwargs: Any) -> None:
|
||||
@BaseSlideConfig.wrapper("base_slide_config")
|
||||
def next_slide(
|
||||
self,
|
||||
*args: Any,
|
||||
base_slide_config: BaseSlideConfig,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
Scene.next_section(self, *args, **kwargs)
|
||||
BaseSlide.next_slide(self, loop=loop)
|
||||
BaseSlide.next_slide.__wrapped__(
|
||||
self,
|
||||
base_slide_config=base_slide_config,
|
||||
)
|
||||
|
||||
def render(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""MANIM render."""
|
||||
@ -143,6 +144,7 @@ class ThreeDSlide(Slide, ThreeDScene): # type: ignore[misc]
|
||||
self.next_slide()
|
||||
|
||||
self.play(*[FadeOut(mobject) for mobject in self.mobjects])
|
||||
|
||||
"""
|
||||
|
||||
pass
|
||||
|
@ -36,7 +36,13 @@
|
||||
{%- endif -%}
|
||||
{% if slide_config.loop -%}
|
||||
data-background-video-loop
|
||||
{%- endif -%}
|
||||
{% if slide_config.auto_next -%}
|
||||
data-autoslide="{{ get_duration_ms(slide_config.file) }}"
|
||||
{%- endif -%}>
|
||||
{% if slide_config.notes != "" -%}
|
||||
<aside class="notes" data-markdown>{{ slide_config.notes }}</aside>
|
||||
{%- endif %}
|
||||
</section>
|
||||
{%- endfor -%}
|
||||
{%- endfor -%}
|
||||
@ -47,9 +53,17 @@
|
||||
|
||||
<!-- To include plugins, see: https://revealjs.com/plugins/ -->
|
||||
|
||||
{% if has_notes -%}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/plugin/markdown/markdown.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/plugin/notes/notes.min.js"></script>
|
||||
{%- endif -%}
|
||||
|
||||
<!-- <script src="index.js"></script> -->
|
||||
<script>
|
||||
Reveal.initialize({
|
||||
{% if has_notes -%}
|
||||
plugins: [ RevealMarkdown, RevealNotes ],
|
||||
{%- endif %}
|
||||
// The "normal" size of the presentation, aspect ratio will
|
||||
// be preserved when the presentation is scaled to fit different
|
||||
// resolutions. Can be specified using percentage units.
|
||||
@ -302,28 +316,36 @@
|
||||
hideCursorTime: {{ hide_cursor_time }}
|
||||
});
|
||||
|
||||
{% if data_uri %}
|
||||
// Fix found by @t-fritsch on GitHub
|
||||
// see: https://github.com/hakimel/reveal.js/discussions/3362#discussioncomment-6651475.
|
||||
function fixBase64VideoBackground(event) {
|
||||
// event.previousSlide, event.currentSlide, event.indexh, event.indexv
|
||||
if (event.currentSlide.getAttribute('data-background-video')) {
|
||||
const background = Reveal.getSlideBackground(event.indexh, event.indexv),
|
||||
video = background.querySelector('video'),
|
||||
sources = video.querySelectorAll('source');
|
||||
{% if data_uri -%}
|
||||
// Fix found by @t-fritsch on GitHub
|
||||
// see: https://github.com/hakimel/reveal.js/discussions/3362#discussioncomment-6651475.
|
||||
function fixBase64VideoBackground(event) {
|
||||
// event.previousSlide, event.currentSlide, event.indexh, event.indexv
|
||||
if (event.currentSlide.getAttribute('data-background-video')) {
|
||||
const background = Reveal.getSlideBackground(event.indexh, event.indexv),
|
||||
video = background.querySelector('video'),
|
||||
sources = video.querySelectorAll('source');
|
||||
|
||||
sources.forEach((source, i) => {
|
||||
const src = source.getAttribute('src');
|
||||
if(src.match(/^data:video.*;base64$/)) {
|
||||
const nextSrc = sources[i+1]?.getAttribute('src');
|
||||
video.setAttribute('src', `${src},${nextSrc}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
sources.forEach((source, i) => {
|
||||
const src = source.getAttribute('src');
|
||||
if(src.match(/^data:video.*;base64$/)) {
|
||||
const nextSrc = sources[i+1]?.getAttribute('src');
|
||||
video.setAttribute('src', `${src},${nextSrc}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reveal.on( 'ready', fixBase64VideoBackground );
|
||||
Reveal.on( 'slidechanged', fixBase64VideoBackground );
|
||||
{% endif %}
|
||||
}
|
||||
Reveal.on( 'ready', fixBase64VideoBackground );
|
||||
Reveal.on( 'slidechanged', fixBase64VideoBackground );
|
||||
{%- endif %}
|
||||
</script>
|
||||
|
||||
{% if env['READTHEDOCS'] -%}
|
||||
<style>
|
||||
readthedocs-flyout, readthedocs-notification {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
{%- endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
@ -1,46 +1,65 @@
|
||||
import hashlib
|
||||
import subprocess
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from typing import Iterator, List
|
||||
|
||||
import av
|
||||
|
||||
from .logger import logger
|
||||
|
||||
|
||||
def concatenate_video_files(ffmpeg_bin: Path, files: List[Path], dest: Path) -> None:
|
||||
def concatenate_video_files(files: List[Path], dest: Path) -> None:
|
||||
"""Concatenate multiple video files into one."""
|
||||
f = tempfile.NamedTemporaryFile(mode="w", delete=False)
|
||||
f.writelines(f"file '{path.absolute()}'\n" for path in files)
|
||||
f.close()
|
||||
|
||||
command: List[str] = [
|
||||
str(ffmpeg_bin),
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
f.name,
|
||||
"-c",
|
||||
"copy",
|
||||
str(dest),
|
||||
"-y",
|
||||
]
|
||||
logger.debug(" ".join(command))
|
||||
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
output, error = process.communicate()
|
||||
def _filter(files: List[Path]) -> Iterator[Path]:
|
||||
"""Patch possibly empty video files."""
|
||||
for file in files:
|
||||
with av.open(str(file)) as container:
|
||||
if len(container.streams.video) > 0:
|
||||
yield file
|
||||
else:
|
||||
logger.warn(
|
||||
f"Skipping video file {file} because it does "
|
||||
"not contain any video stream. "
|
||||
"This is probably caused by Manim, see: "
|
||||
"https://github.com/jeertmans/manim-slides/issues/390."
|
||||
)
|
||||
|
||||
if output:
|
||||
logger.debug(output.decode())
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||
f.writelines(f"file '{file}'\n" for file in _filter(files))
|
||||
tmp_file = f.name
|
||||
|
||||
if error:
|
||||
logger.debug(error.decode())
|
||||
|
||||
if not dest.exists():
|
||||
raise ValueError(
|
||||
"could not properly concatenate files, use `-v DEBUG` for more details"
|
||||
with av.open(
|
||||
tmp_file, format="concat", options={"safe": "0"}
|
||||
) as input_container, av.open(str(dest), mode="w") as output_container:
|
||||
input_video_stream = input_container.streams.video[0]
|
||||
output_video_stream = output_container.add_stream(
|
||||
template=input_video_stream,
|
||||
)
|
||||
|
||||
if len(input_container.streams.audio) > 0:
|
||||
input_audio_stream = input_container.streams.audio[0]
|
||||
output_audio_stream = output_container.add_stream(
|
||||
template=input_audio_stream,
|
||||
)
|
||||
|
||||
for packet in input_container.demux():
|
||||
if packet.dts is None:
|
||||
continue
|
||||
|
||||
ptype = packet.stream.type
|
||||
|
||||
if ptype == "video":
|
||||
packet.stream = output_video_stream
|
||||
elif ptype == "audio":
|
||||
packet.stream = output_audio_stream
|
||||
else:
|
||||
continue # We don't support subtitles
|
||||
output_container.mux(packet)
|
||||
|
||||
os.unlink(tmp_file) # https://stackoverflow.com/a/54768241
|
||||
|
||||
|
||||
def merge_basenames(files: List[Path]) -> Path:
|
||||
"""Merge multiple filenames by concatenating basenames."""
|
||||
@ -63,15 +82,45 @@ def merge_basenames(files: List[Path]) -> Path:
|
||||
return dirname.joinpath(basename + ext)
|
||||
|
||||
|
||||
def reverse_video_file(ffmpeg_bin: Path, src: Path, dst: Path) -> None:
|
||||
"""Reverses a video file, writting the result to `dst`."""
|
||||
command = [str(ffmpeg_bin), "-y", "-i", str(src), "-vf", "reverse", str(dst)]
|
||||
logger.debug(" ".join(command))
|
||||
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
output, error = process.communicate()
|
||||
def link_nodes(*nodes: av.filter.context.FilterContext) -> None:
|
||||
"""Code from https://github.com/PyAV-Org/PyAV/issues/239."""
|
||||
for c, n in zip(nodes, nodes[1:]):
|
||||
c.link_to(n)
|
||||
|
||||
if output:
|
||||
logger.debug(output.decode())
|
||||
|
||||
if error:
|
||||
logger.debug(error.decode())
|
||||
def reverse_video_file(src: Path, dest: Path) -> None:
|
||||
"""Reverses a video file, writting the result to `dest`."""
|
||||
with av.open(str(src)) as input_container, av.open(
|
||||
str(dest), mode="w"
|
||||
) as output_container:
|
||||
input_stream = input_container.streams.video[0]
|
||||
output_stream = output_container.add_stream(
|
||||
codec_name=input_stream.codec_context.name, rate=input_stream.base_rate
|
||||
)
|
||||
output_stream.width = input_stream.width
|
||||
output_stream.height = input_stream.height
|
||||
output_stream.pix_fmt = input_stream.pix_fmt
|
||||
|
||||
graph = av.filter.Graph()
|
||||
link_nodes(
|
||||
graph.add_buffer(template=input_stream),
|
||||
graph.add("reverse"),
|
||||
graph.add("buffersink"),
|
||||
)
|
||||
graph.configure()
|
||||
|
||||
frames_count = 0
|
||||
for frame in input_container.decode(video=0):
|
||||
graph.push(frame)
|
||||
frames_count += 1
|
||||
|
||||
graph.push(None) # EOF: https://github.com/PyAV-Org/PyAV/issues/886.
|
||||
|
||||
for _ in range(frames_count):
|
||||
frame = graph.pull()
|
||||
|
||||
for packet in output_stream.encode(frame):
|
||||
output_container.mux(packet)
|
||||
|
||||
for packet in output_stream.encode():
|
||||
output_container.mux(packet)
|
||||
|
85
manim_slides/wizard/__init__.py
Normal file
85
manim_slides/wizard/__init__.py
Normal file
@ -0,0 +1,85 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from ..commons import config_options, verbosity_option
|
||||
from ..config import Config
|
||||
from ..defaults import CONFIG_PATH
|
||||
from ..logger import logger
|
||||
|
||||
|
||||
@click.command()
|
||||
@config_options
|
||||
@click.help_option("-h", "--help")
|
||||
@verbosity_option
|
||||
def wizard(config_path: Path, force: bool, merge: bool) -> None:
|
||||
"""Launch configuration wizard."""
|
||||
return _init(config_path, force, merge, skip_interactive=False)
|
||||
|
||||
|
||||
@click.command()
|
||||
@config_options
|
||||
@click.help_option("-h", "--help")
|
||||
@verbosity_option
|
||||
def init(
|
||||
config_path: Path, 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: Path, force: bool, merge: bool, skip_interactive: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Actual initialization code for configuration file, with optional interactive
|
||||
mode.
|
||||
"""
|
||||
if config_path.exists():
|
||||
click.secho(f"The `{CONFIG_PATH}` configuration file exists")
|
||||
|
||||
if not force and not merge:
|
||||
choice = click.prompt(
|
||||
"Do you want to continue and (o)verwrite / (m)erge it, or (q)uit?",
|
||||
type=click.Choice(["o", "m", "q"], case_sensitive=False),
|
||||
)
|
||||
|
||||
force = choice == "o"
|
||||
merge = choice == "m"
|
||||
|
||||
if not force and not merge:
|
||||
logger.debug("Exiting without doing anything")
|
||||
sys.exit(0)
|
||||
|
||||
config = Config()
|
||||
|
||||
if force:
|
||||
logger.debug(f"Overwriting `{config_path}` if exists")
|
||||
elif merge:
|
||||
logger.debug("Merging new config into `{config_path}`")
|
||||
|
||||
if not skip_interactive:
|
||||
if config_path.exists():
|
||||
config = Config.from_file(config_path)
|
||||
|
||||
from ..qt_utils import qapp
|
||||
from .wizard import Wizard
|
||||
|
||||
app = qapp()
|
||||
app.setApplicationName("Manim Slides Wizard")
|
||||
window = Wizard(config)
|
||||
window.show()
|
||||
app.exec()
|
||||
|
||||
if window.closed_without_saving:
|
||||
sys.exit(0)
|
||||
|
||||
config = window.config
|
||||
|
||||
if merge:
|
||||
config = Config.from_file(config_path).merge_with(config)
|
||||
|
||||
config.to_file(config_path)
|
||||
|
||||
click.secho(f"Configuration file successfully saved to `{config_path}`")
|
@ -1,13 +1,9 @@
|
||||
import sys
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QIcon, QKeyEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtGui import QIcon, QKeyEvent
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QGridLayout,
|
||||
@ -18,11 +14,9 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .commons import config_options, verbosity_option
|
||||
from .config import Config, Key
|
||||
from .defaults import CONFIG_PATH
|
||||
from .logger import logger
|
||||
from .resources import * # noqa: F403
|
||||
from ..config import Config, Key
|
||||
from ..logger import logger
|
||||
from ..resources import * # noqa: F403
|
||||
|
||||
WINDOW_NAME: str = "Configuration Wizard"
|
||||
|
||||
@ -57,12 +51,13 @@ class Wizard(QWidget): # type: ignore
|
||||
self.config = config
|
||||
self.icon = QIcon(":/icon.png")
|
||||
self.setWindowIcon(self.icon)
|
||||
self.closed_without_saving = False
|
||||
|
||||
button = QDialogButtonBox.Save | QDialogButtonBox.Cancel
|
||||
|
||||
self.buttonBox = QDialogButtonBox(button)
|
||||
self.buttonBox.accepted.connect(self.save_config)
|
||||
self.buttonBox.rejected.connect(self.close_without_saving)
|
||||
self.button_box = QDialogButtonBox(button)
|
||||
self.button_box.accepted.connect(self.save_config)
|
||||
self.button_box.rejected.connect(self.close_without_saving)
|
||||
|
||||
self.buttons = []
|
||||
|
||||
@ -87,17 +82,17 @@ class Wizard(QWidget): # type: ignore
|
||||
)
|
||||
self.layout.addWidget(button, i, 1)
|
||||
|
||||
self.layout.addWidget(self.buttonBox, len(self.buttons), 1)
|
||||
self.layout.addWidget(self.button_box, len(self.buttons), 1)
|
||||
|
||||
self.setLayout(self.layout)
|
||||
|
||||
def close_without_saving(self) -> None:
|
||||
logger.debug("Closing configuration wizard without saving")
|
||||
self.closed_without_saving = True
|
||||
self.deleteLater()
|
||||
sys.exit(0)
|
||||
|
||||
def closeEvent(self, event: Any) -> None: # noqa: N802
|
||||
self.closeWithoutSaving()
|
||||
self.close_without_saving()
|
||||
event.accept()
|
||||
|
||||
def save_config(self) -> None:
|
||||
@ -111,7 +106,7 @@ class Wizard(QWidget): # type: ignore
|
||||
"Two or more actions share a common key: make sure actions have distinct key codes."
|
||||
)
|
||||
msg.setWindowTitle("Error: duplicated keys")
|
||||
msg.exec_()
|
||||
msg.exec()
|
||||
return
|
||||
|
||||
self.deleteLater()
|
||||
@ -119,78 +114,8 @@ class Wizard(QWidget): # type: ignore
|
||||
def open_dialog(self, button_number: int, key: Key) -> None:
|
||||
button = self.buttons[button_number]
|
||||
dialog = KeyInput()
|
||||
dialog.exec_()
|
||||
dialog.exec()
|
||||
if dialog.key is not None:
|
||||
key_name = keymap[dialog.key]
|
||||
key.set_ids(dialog.key)
|
||||
button.setText(key_name)
|
||||
|
||||
|
||||
@click.command()
|
||||
@config_options
|
||||
@click.help_option("-h", "--help")
|
||||
@verbosity_option
|
||||
def wizard(config_path: Path, force: bool, merge: bool) -> None:
|
||||
"""Launch configuration wizard."""
|
||||
return _init(config_path, force, merge, skip_interactive=False)
|
||||
|
||||
|
||||
@click.command()
|
||||
@config_options
|
||||
@click.help_option("-h", "--help")
|
||||
@verbosity_option
|
||||
def init(
|
||||
config_path: Path, 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: Path, force: bool, merge: bool, skip_interactive: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Actual initialization code for configuration file, with optional interactive
|
||||
mode.
|
||||
"""
|
||||
if config_path.exists():
|
||||
click.secho(f"The `{CONFIG_PATH}` configuration file exists")
|
||||
|
||||
if not force and not merge:
|
||||
choice = click.prompt(
|
||||
"Do you want to continue and (o)verwrite / (m)erge it, or (q)uit?",
|
||||
type=click.Choice(["o", "m", "q"], case_sensitive=False),
|
||||
)
|
||||
|
||||
force = choice == "o"
|
||||
merge = choice == "m"
|
||||
|
||||
if not force and not merge:
|
||||
logger.debug("Exiting without doing anything")
|
||||
sys.exit(0)
|
||||
|
||||
config = Config()
|
||||
|
||||
if force:
|
||||
logger.debug(f"Overwriting `{config_path}` if exists")
|
||||
elif merge:
|
||||
logger.debug("Merging new config into `{config_path}`")
|
||||
|
||||
if not skip_interactive:
|
||||
if config_path.exists():
|
||||
config = Config.from_file(config_path)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Manim Slides Wizard")
|
||||
window = Wizard(config)
|
||||
window.show()
|
||||
app.exec()
|
||||
|
||||
config = window.config
|
||||
|
||||
if merge:
|
||||
config = Config.from_file(config_path).merge_with(config)
|
||||
|
||||
config.to_file(config_path)
|
||||
|
||||
click.secho(f"Configuration file successfully saved to `{config_path}`")
|
4240
poetry.lock
generated
4240
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
216
pyproject.toml
216
pyproject.toml
@ -1,9 +1,89 @@
|
||||
[build-system]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
requires = ["setuptools", "poetry-core>=1.0.0"]
|
||||
build-backend = "pdm.backend"
|
||||
requires = ["pdm-backend", "setuptools"]
|
||||
|
||||
[tool.black]
|
||||
target-version = ["py38"]
|
||||
[project]
|
||||
authors = [{name = "Jérome Eertmans", email = "jeertmans@icloud.com"}]
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Topic :: Multimedia :: Video",
|
||||
"Topic :: Multimedia :: Graphics",
|
||||
"Topic :: Scientific/Engineering",
|
||||
]
|
||||
dependencies = [
|
||||
"av>=9.0.0",
|
||||
"click>=8.1.3",
|
||||
"click-default-group>=1.2.2",
|
||||
"jinja2>=3.1.2",
|
||||
"lxml>=4.9.2",
|
||||
"numpy>=1.19",
|
||||
"pillow>=9.5.0",
|
||||
"pydantic>=2.0.1",
|
||||
"pydantic-extra-types>=2.0.0",
|
||||
"python-pptx>=0.6.21",
|
||||
"qtpy>=2.4.1",
|
||||
"requests>=2.28.1",
|
||||
"rich>=13.3.2",
|
||||
"rtoml>=0.9.0",
|
||||
"tqdm>=4.64.1",
|
||||
]
|
||||
description = "Tool for live presentations using manim"
|
||||
dynamic = ["version"]
|
||||
keywords = ["manim", "slides", "plugin", "manimgl"]
|
||||
license = {text = "MIT"}
|
||||
name = "manim-slides"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9,<3.13"
|
||||
|
||||
[project.optional-dependencies]
|
||||
docs = [
|
||||
"manim-slides[magic,sphinx-directive]",
|
||||
"furo>=2023.5.20",
|
||||
"ipykernel>=6.25.1",
|
||||
"myst-parser>=2.0.0",
|
||||
"nbsphinx>=0.9.2",
|
||||
"pandoc>=2.3",
|
||||
"sphinx>=7.0.1",
|
||||
"sphinx-click>=4.4.0",
|
||||
"sphinx-copybutton>=0.5.1",
|
||||
"sphinxext-opengraph>=0.7.5",
|
||||
]
|
||||
full = [
|
||||
"manim-slides[magic,manim,manimgl,sphinx-directive]",
|
||||
]
|
||||
magic = ["manim-slides[manim]", "ipython>=8.12.2"]
|
||||
manim = ["manim>=0.17.3"]
|
||||
manimgl = ["manimgl>=1.6.1"]
|
||||
pyqt6 = ["pyqt6>=6.6.1"]
|
||||
pyqt6-full = ["manim-slides[full,pyqt6]"]
|
||||
pyside6 = ["pyside6>=6.5.1,<6.5.3;python_version<'3.12'"]
|
||||
pyside6-full = ["manim-slides[full,pyside6]"]
|
||||
sphinx-directive = ["docutils>=0.20.1", "manim-slides[manim]"]
|
||||
|
||||
[project.scripts]
|
||||
manim-slides = "manim_slides.__main__:cli"
|
||||
|
||||
[project.urls]
|
||||
Changelog = "https://github.com/jeertmans/manim-slides/releases"
|
||||
Documentation = "https://eertmans.be/manim-slides"
|
||||
Founding = "https://github.com/sponsors/jeertmans"
|
||||
Homepage = "https://github.com/jeertmans/manim-slides"
|
||||
Repository = "https://github.com/jeertmans/manim-slides"
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"raise NotImplementedError",
|
||||
"if TYPE_CHECKING:",
|
||||
"if typing.TYPE_CHECKING:",
|
||||
]
|
||||
precision = 2
|
||||
|
||||
[tool.mypy]
|
||||
disallow_untyped_decorators = false
|
||||
@ -11,117 +91,45 @@ install_types = true
|
||||
python_version = "3.8"
|
||||
strict = true
|
||||
|
||||
[tool.poetry]
|
||||
authors = [
|
||||
"Jérome Eertmans <jeertmans@icloud.com>"
|
||||
[tool.pdm.dev-dependencies]
|
||||
dev = [
|
||||
"bump2version>=1.0.1",
|
||||
"pre-commit>=3.5.0",
|
||||
]
|
||||
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 :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Topic :: Multimedia :: Video",
|
||||
"Topic :: Multimedia :: Graphics",
|
||||
"Topic :: Scientific/Engineering"
|
||||
github-action = ["setuptools"]
|
||||
test = [
|
||||
"manim-slides[manim,manimgl,pyqt6]",
|
||||
"pytest>=7.4.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"pytest-env>=0.8.2",
|
||||
"pytest-qt>=4.2.0",
|
||||
"pytest-xdist>=3.3.1",
|
||||
]
|
||||
description = "Tool for live presentations using manim"
|
||||
documentation = "https://eertmans.be/manim-slides"
|
||||
exclude = ["docs/", "static/"]
|
||||
homepage = "https://github.com/jeertmans/manim-slides"
|
||||
keywords = ["manim", "slides", "plugin", "manimgl"]
|
||||
license = "MIT"
|
||||
name = "manim-slides"
|
||||
packages = [
|
||||
{include = "manim_slides"}
|
||||
]
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/jeertmans/manim-slides"
|
||||
version = "5.0.0-rc3"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
click = "^8.1.3"
|
||||
click-default-group = "^1.2.2"
|
||||
docutils = {version = "^0.20.1", optional = true}
|
||||
ipython = {version = ">=8.12.2", optional = true}
|
||||
jinja2 = "^3.1.2"
|
||||
lxml = "^4.9.2"
|
||||
manim = {version = "^0.17.3", optional = true}
|
||||
manimgl = {version = "^1.6.1", optional = true}
|
||||
numpy = "^1.19"
|
||||
opencv-python = "^4.6.0.66"
|
||||
pillow = "^9.5.0"
|
||||
pydantic = "^2.0.1"
|
||||
pydantic-extra-types = "^2.0.0"
|
||||
pyside6 = "6.5.2"
|
||||
python = ">=3.8.1,<3.12"
|
||||
python-pptx = "^0.6.21"
|
||||
requests = "^2.28.1"
|
||||
rich = "^13.3.2"
|
||||
rtoml = "^0.9.0"
|
||||
tqdm = "^4.64.1"
|
||||
[tool.pdm.resolution.overrides]
|
||||
manimpango = "<1.0.0,>=0.5.0" # This conflicts with ManimGL, hopefully not an issue
|
||||
skia-pathops = "0.8.0.post1" # From manim 0.18.0 (Python 3.12 support)
|
||||
|
||||
[tool.poetry.extras]
|
||||
magic = ["manim", "ipython"]
|
||||
manim = ["manim"]
|
||||
manimgl = ["manimgl"]
|
||||
sphinx-directive = ["docutils", "manim"]
|
||||
|
||||
[tool.poetry.group.dev]
|
||||
optional = true
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = "^22.10.0"
|
||||
bump2version = "^1.0.1"
|
||||
isort = "^5.12.0"
|
||||
mypy = "^0.991"
|
||||
pre-commit = "^3.0.2"
|
||||
ruff = "^0.0.219"
|
||||
|
||||
[tool.poetry.group.docs]
|
||||
optional = true
|
||||
|
||||
[tool.poetry.group.docs.dependencies]
|
||||
furo = "^2023.5.20"
|
||||
ipykernel = "^6.25.1"
|
||||
manim = "^0.17.3"
|
||||
myst-parser = "^2.0.0"
|
||||
nbsphinx = "^0.9.2"
|
||||
pandoc = "^2.3"
|
||||
sphinx = "^7.0.1"
|
||||
sphinx-click = "^4.4.0"
|
||||
sphinx-copybutton = "^0.5.1"
|
||||
sphinxext-opengraph = "^0.7.5"
|
||||
|
||||
[tool.poetry.group.test]
|
||||
optional = true
|
||||
|
||||
[tool.poetry.group.test.dependencies]
|
||||
manim = "^0.17.3"
|
||||
manimgl = "^1.6.1"
|
||||
pytest = "^7.4.0"
|
||||
pytest-cov = "^4.1.0"
|
||||
pytest-env = "^0.8.2"
|
||||
pytest-xdist = "^3.3.1"
|
||||
|
||||
[tool.poetry.plugins]
|
||||
|
||||
[tool.poetry.plugins."console_scripts"]
|
||||
manim-slides = "manim_slides.__main__:cli"
|
||||
[tool.pdm.version]
|
||||
path = "manim_slides/__version__.py"
|
||||
source = "file"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
env = [
|
||||
"QT_QPA_PLATFORM=offscreen"
|
||||
"QT_QPA_PLATFORM=offscreen",
|
||||
]
|
||||
filterwarnings = [
|
||||
"error",
|
||||
"ignore::DeprecationWarning"
|
||||
"ignore::DeprecationWarning",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
extend-exclude = ["manim_slides/resources.py"]
|
||||
extend-include = ["*.ipynb"]
|
||||
line-length = 88
|
||||
target-version = "py38"
|
||||
|
||||
[tool.ruff.lint]
|
||||
extend-ignore = [
|
||||
"D100",
|
||||
"D101",
|
||||
@ -134,9 +142,7 @@ extend-ignore = [
|
||||
"D203",
|
||||
"D205",
|
||||
"D212",
|
||||
"E501"
|
||||
"E501",
|
||||
]
|
||||
extend-select = ["B", "C90", "D", "I", "N", "RUF", "UP", "T"]
|
||||
isort = {known-first-party = ['manim_slides', 'tests']}
|
||||
line-length = 88
|
||||
target-version = "py38"
|
||||
isort = {known-first-party = ["manim_slides", "tests"]}
|
||||
|
BIN
static/favicon.png
Normal file
BIN
static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.1 KiB |
@ -51,3 +51,28 @@ class ManimSlidesLogo(Scene):
|
||||
) # order matters
|
||||
logo.move_to(ORIGIN)
|
||||
self.add(logo)
|
||||
|
||||
|
||||
class ManimSlidesFavicon(Scene):
|
||||
def construct(self):
|
||||
tex_template = TexTemplate()
|
||||
tex_template.add_to_preamble(r"\usepackage{graphicx}\usepackage{fontawesome5}")
|
||||
fill_color = "#c9d1d9"
|
||||
stroke_color = "#343434"
|
||||
play = Tex(
|
||||
r"\faStepBackward\faStepForward",
|
||||
fill_color=fill_color,
|
||||
stroke_color=stroke_color,
|
||||
tex_template=tex_template,
|
||||
).scale(4)
|
||||
comment = Tex(
|
||||
r"\reflectbox{\faComment*[regular]}",
|
||||
fill_color=fill_color,
|
||||
stroke_color=stroke_color,
|
||||
tex_template=tex_template,
|
||||
).scale(9)
|
||||
comment.move_to(play)
|
||||
comment.shift(0.4 * DOWN)
|
||||
favicon = VGroup(comment, play).scale(3)
|
||||
favicon.move_to(ORIGIN)
|
||||
self.add(favicon)
|
||||
|
5
static/make_favicon.sh
Executable file
5
static/make_favicon.sh
Executable file
@ -0,0 +1,5 @@
|
||||
#! /bin/bash
|
||||
|
||||
pdm run manim-slides render -t -qk -s --format png --resolution 64,64 static/logo.py ManimSlidesFavicon && mv media/images/logo/*.png static/favicon.png
|
||||
|
||||
ln -f -r -s static/favicon.png docs/source/_static/favicon.png
|
@ -1,21 +1,21 @@
|
||||
#! /bin/bash
|
||||
|
||||
MANIM_SLIDES_THEME=light poetry run manim render -qk -s --format png --resolution 2560,1280 static/logo.py && mv media/images/logo/*.png static/logo.png
|
||||
MANIM_SLIDES_THEME=light pdm run manim-slides render -qk -s --format png --resolution 2560,1280 static/logo.py ManimSlidesLogo && mv media/images/logo/*.png static/logo.png
|
||||
|
||||
ln -f -r -s static/logo.png docs/source/_static/logo.png
|
||||
|
||||
MANIM_SLIDES_THEME=dark_docs poetry run manim render -qk -s --format png --resolution 2560,1280 static/logo.py && mv media/images/logo/*.png static/logo_dark_docs.png
|
||||
MANIM_SLIDES_THEME=dark_docs pdm run manim-slides render -qk -s --format png --resolution 2560,1280 static/logo.py ManimSlidesLogo && mv media/images/logo/*.png static/logo_dark_docs.png
|
||||
|
||||
ln -f -r -s static/logo_dark_docs.png docs/source/_static/logo_dark_docs.png
|
||||
|
||||
MANIM_SLIDES_THEME=dark_github poetry run manim render -qk -s --format png --resolution 2560,1280 static/logo.py && mv media/images/logo/*.png static/logo_dark_github.png
|
||||
MANIM_SLIDES_THEME=dark_github pdm run manim-slides render -qk -s --format png --resolution 2560,1280 static/logo.py ManimSlidesLogo && mv media/images/logo/*.png static/logo_dark_github.png
|
||||
|
||||
ln -f -r -s static/logo_dark_github.png docs/source/_static/logo_dark_github.png
|
||||
|
||||
MANIM_SLIDES_THEME=light poetry run manim render -t -qk -s --format png --resolution 2560,1280 static/logo.py && mv media/images/logo/*.png static/logo_light_transparent.png
|
||||
MANIM_SLIDES_THEME=light pdm run manim-slides render -t -qk -s --format png --resolution 2560,1280 static/logo.py ManimSlidesLogo && mv media/images/logo/*.png static/logo_light_transparent.png
|
||||
|
||||
ln -f -r -s static/logo_light_transparent.png docs/source/_static/logo_light_transparent.png
|
||||
|
||||
MANIM_SLIDES_THEME=dark_docs poetry run manim render -t -qk -s --format png --resolution 2560,1280 static/logo.py && mv media/images/logo/*.png static/logo_dark_transparent.png
|
||||
MANIM_SLIDES_THEME=dark_docs pdm run manim-slides render -t -qk -s --format png --resolution 2560,1280 static/logo.py ManimSlidesLogo && mv media/images/logo/*.png static/logo_dark_transparent.png
|
||||
|
||||
ln -f -r -s static/logo_dark_transparent.png docs/source/_static/logo_dark_transparent.png
|
||||
|
@ -8,36 +8,46 @@ import pytest
|
||||
from manim_slides.config import PresentationConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.fixture(scope="session")
|
||||
def tests_folder() -> Iterator[Path]:
|
||||
yield Path(__file__).parent.resolve(strict=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.fixture(scope="session")
|
||||
def project_folder(tests_folder: Path) -> Iterator[Path]:
|
||||
yield tests_folder.parent.resolve(strict=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.fixture(scope="session")
|
||||
def data_folder(tests_folder: Path) -> Iterator[Path]:
|
||||
yield (tests_folder / "data").resolve(strict=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.fixture(scope="session")
|
||||
def slides_folder(data_folder: Path) -> Iterator[Path]:
|
||||
yield (data_folder / "slides").resolve(strict=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.fixture(scope="session")
|
||||
def slides_file(data_folder: Path) -> Iterator[Path]:
|
||||
yield (data_folder / "slides.py").resolve(strict=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.fixture(scope="session")
|
||||
def manimgl_config(project_folder: Path) -> Iterator[Path]:
|
||||
yield (project_folder / "custom_config.yml").resolve(strict=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def video_file(data_folder: Path) -> Iterator[Path]:
|
||||
yield (data_folder / "video.mp4").resolve(strict=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def video_data_uri_file(data_folder: Path) -> Iterator[Path]:
|
||||
yield (data_folder / "video_data_uri.txt").resolve(strict=True)
|
||||
|
||||
|
||||
def random_path(
|
||||
length: int = 20,
|
||||
dirname: Path = Path("./media/videos/example"),
|
||||
@ -61,7 +71,7 @@ def paths() -> Generator[List[Path], None, None]:
|
||||
yield [random_path() for _ in range(20)]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.fixture(scope="session")
|
||||
def presentation_config(
|
||||
slides_folder: Path,
|
||||
) -> Generator[PresentationConfig, None, None]:
|
||||
|
BIN
tests/data/video.mp4
Normal file
BIN
tests/data/video.mp4
Normal file
Binary file not shown.
1
tests/data/video_data_uri.txt
Normal file
1
tests/data/video_data_uri.txt
Normal file
File diff suppressed because one or more lines are too long
105
tests/test_base_slide.py
Normal file
105
tests/test_base_slide.py
Normal file
@ -0,0 +1,105 @@
|
||||
from typing import MutableMapping
|
||||
|
||||
import pytest
|
||||
|
||||
from manim_slides.slide.base import BaseSlide
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_slide() -> BaseSlide:
|
||||
return BaseSlide() # type: ignore[abstract]
|
||||
|
||||
|
||||
class TestBaseSlide:
|
||||
def test_frame_height(self, base_slide: BaseSlide) -> None:
|
||||
with pytest.raises(NotImplementedError):
|
||||
_ = base_slide._frame_height
|
||||
|
||||
def test_frame_width(self, base_slide: BaseSlide) -> None:
|
||||
with pytest.raises(NotImplementedError):
|
||||
_ = base_slide._frame_width
|
||||
|
||||
def test_background_color(self, base_slide: BaseSlide) -> None:
|
||||
with pytest.raises(NotImplementedError):
|
||||
_ = base_slide._background_color
|
||||
|
||||
def test_resolution(self, base_slide: BaseSlide) -> None:
|
||||
with pytest.raises(NotImplementedError):
|
||||
_ = base_slide._resolution
|
||||
|
||||
def test_partial_movie_files(self, base_slide: BaseSlide) -> None:
|
||||
with pytest.raises(NotImplementedError):
|
||||
_ = base_slide._partial_movie_files
|
||||
|
||||
def test_show_progress_bar(self, base_slide: BaseSlide) -> None:
|
||||
with pytest.raises(NotImplementedError):
|
||||
_ = base_slide._show_progress_bar
|
||||
|
||||
def test_leave_progress_bar(self, base_slide: BaseSlide) -> None:
|
||||
with pytest.raises(NotImplementedError):
|
||||
_ = base_slide._leave_progress_bar
|
||||
|
||||
def test_start_at_animation_number(self, base_slide: BaseSlide) -> None:
|
||||
with pytest.raises(NotImplementedError):
|
||||
_ = base_slide._start_at_animation_number
|
||||
|
||||
def test_canvas(self, base_slide: BaseSlide) -> None:
|
||||
assert len(base_slide.canvas) == 0
|
||||
assert isinstance(base_slide.canvas, MutableMapping)
|
||||
|
||||
def test_add_to__and_remove_from_canvas(self, base_slide: BaseSlide) -> None:
|
||||
assert len(base_slide.canvas) == 0
|
||||
|
||||
base_slide.add_to_canvas(a=1, b=2)
|
||||
|
||||
assert len(base_slide.canvas) == 2
|
||||
assert base_slide.canvas["a"] == 1
|
||||
assert base_slide.canvas["b"] == 2
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
_ = base_slide.canvas["c"]
|
||||
|
||||
base_slide.add_to_canvas(b=3, c=4)
|
||||
|
||||
assert len(base_slide.canvas) == 3
|
||||
|
||||
assert sorted(base_slide.canvas_mobjects) == [1, 3, 4]
|
||||
|
||||
base_slide.remove_from_canvas("a", "b", "c")
|
||||
|
||||
assert len(base_slide.canvas) == 0
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
base_slide.remove_from_canvas("a")
|
||||
|
||||
def test_mobjects_without_canvas(self) -> None:
|
||||
pass # This property should be tested in test_slide.py
|
||||
|
||||
def test_wait_time_between_slides(self, base_slide: BaseSlide) -> None:
|
||||
assert base_slide.wait_time_between_slides == 0.0
|
||||
|
||||
base_slide.wait_time_between_slides = 1.0
|
||||
|
||||
assert base_slide.wait_time_between_slides == 1.0
|
||||
|
||||
base_slide.wait_time_between_slides = -1.0
|
||||
|
||||
assert base_slide.wait_time_between_slides == 0.0
|
||||
|
||||
def test_play(self) -> None:
|
||||
pass # This method should be tested in test_slide.py
|
||||
|
||||
def test_next_slide(self) -> None:
|
||||
pass # This method should be tested in test_slide.py
|
||||
|
||||
def test_add_last_slide(self) -> None:
|
||||
pass # This method should be tested in test_slide.py
|
||||
|
||||
def test_save_slides(self) -> None:
|
||||
pass # This method should be tested in test_slide.py
|
||||
|
||||
def test_zoom(self) -> None:
|
||||
pass # This method should be tested in test_slide.py
|
||||
|
||||
def test_wipe(self) -> None:
|
||||
pass # This method should be tested in test_slide.py
|
@ -31,9 +31,19 @@ from manim_slides.convert import (
|
||||
SlideNumber,
|
||||
Transition,
|
||||
TransitionSpeed,
|
||||
file_to_data_uri,
|
||||
get_duration_ms,
|
||||
)
|
||||
|
||||
|
||||
def test_get_duration_ms(video_file: Path) -> None:
|
||||
assert get_duration_ms(video_file) == 2000.0
|
||||
|
||||
|
||||
def test_file_to_data_uri(video_file: Path, video_data_uri_file: Path) -> None:
|
||||
assert file_to_data_uri(video_file) == video_data_uri_file.read_text().strip()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("enum_type",),
|
||||
[
|
||||
@ -143,11 +153,14 @@ class TestConverter:
|
||||
file_contents = Path(out_file).read_text()
|
||||
assert "manim" in file_contents.casefold()
|
||||
|
||||
@pytest.mark.parametrize("frame_index", ("first", "last"))
|
||||
def test_pdf_converter(
|
||||
self, tmp_path: Path, presentation_config: PresentationConfig
|
||||
self, frame_index: str, tmp_path: Path, presentation_config: PresentationConfig
|
||||
) -> None:
|
||||
out_file = tmp_path / "slides.pdf"
|
||||
PDF(presentation_configs=[presentation_config]).convert_to(out_file)
|
||||
PDF(
|
||||
presentation_configs=[presentation_config], frame_index=frame_index
|
||||
).convert_to(out_file)
|
||||
assert out_file.exists()
|
||||
|
||||
def test_converter_no_presentation_config(self) -> None:
|
||||
|
@ -1,6 +1,6 @@
|
||||
from pathlib import Path
|
||||
|
||||
from manim_slides.defaults import CONFIG_PATH, FFMPEG_BIN, FOLDER_PATH
|
||||
from manim_slides.defaults import CONFIG_PATH, FOLDER_PATH
|
||||
|
||||
|
||||
def test_folder_path() -> None:
|
||||
@ -9,7 +9,3 @@ def test_folder_path() -> None:
|
||||
|
||||
def test_config_path() -> None:
|
||||
assert CONFIG_PATH == Path(".manim-slides.toml")
|
||||
|
||||
|
||||
def test_ffmpeg_bin() -> None:
|
||||
assert FFMPEG_BIN == Path("ffmpeg")
|
||||
|
@ -8,35 +8,39 @@ from manim_slides.__main__ import cli
|
||||
|
||||
def test_help() -> None:
|
||||
runner = CliRunner()
|
||||
results = runner.invoke(cli, ["-S", "--help"])
|
||||
|
||||
assert results.exit_code == 0
|
||||
with runner.isolated_filesystem():
|
||||
results = runner.invoke(cli, ["-S", "--help"])
|
||||
|
||||
results = runner.invoke(cli, ["-S", "-h"])
|
||||
assert results.exit_code == 0
|
||||
|
||||
assert results.exit_code == 0
|
||||
results = runner.invoke(cli, ["-S", "-h"])
|
||||
|
||||
assert results.exit_code == 0
|
||||
assert "Usage: cli [OPTIONS] COMMAND [ARGS]..." in results.stdout
|
||||
|
||||
|
||||
def test_defaults_to_present(slides_folder: Path) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
results = runner.invoke(
|
||||
cli, ["BasicSlide", "--folder", str(slides_folder), "-s"]
|
||||
)
|
||||
results = runner.invoke(cli, ["-S", "BasicSlide", "--help"])
|
||||
|
||||
assert results.exit_code == 0
|
||||
assert "Usage: cli present" in results.stdout
|
||||
|
||||
|
||||
def test_present(slides_folder: Path) -> None:
|
||||
@pytest.mark.parametrize(
|
||||
["subcommand"], [["present"], ["convert"], ["init"], ["list-scenes"], ["wizard"]]
|
||||
)
|
||||
def test_help_subcommand(subcommand: str) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
results = runner.invoke(
|
||||
cli, ["present", "BasicSlide", "--folder", str(slides_folder), "-s"]
|
||||
)
|
||||
results = runner.invoke(cli, ["-S", subcommand, "--help"])
|
||||
|
||||
assert results.exit_code == 0
|
||||
assert f"Usage: cli {subcommand}" in results.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("extension",), [("html",), ("pdf",), ("pptx",)])
|
||||
@ -60,6 +64,28 @@ def test_convert(slides_folder: Path, extension: str) -> None:
|
||||
assert results.exit_code == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("extension", "expected_log"),
|
||||
[("html", ""), ("pdf", ""), ("pptx", ""), ("ppt", "WARNING")],
|
||||
)
|
||||
def test_convert_auto(slides_folder: Path, extension: str, expected_log: str) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
results = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"convert",
|
||||
"BasicSlide",
|
||||
f"basic_example.{extension}",
|
||||
"--folder",
|
||||
str(slides_folder),
|
||||
],
|
||||
)
|
||||
|
||||
assert results.exit_code == 0, expected_log in results.output
|
||||
|
||||
|
||||
def test_init() -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
@ -90,8 +116,3 @@ def test_list_scenes(slides_folder: Path) -> None:
|
||||
|
||||
assert results.exit_code == 0
|
||||
assert "BasicSlide" in results.output
|
||||
|
||||
|
||||
def test_wizard() -> None:
|
||||
# TODO
|
||||
pass
|
||||
|
1
tests/test_player.py
Normal file
1
tests/test_player.py
Normal file
@ -0,0 +1 @@
|
||||
# TODO
|
141
tests/test_present.py
Normal file
141
tests/test_present.py
Normal file
@ -0,0 +1,141 @@
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Tuple
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from manim_slides.present import present
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def auto_shutdown_qapp() -> Iterator[None]:
|
||||
if app := QApplication.instance():
|
||||
app.quit()
|
||||
|
||||
yield
|
||||
|
||||
if app := QApplication.instance():
|
||||
app.quit()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def args(slides_folder: Path) -> Iterator[Tuple[str, ...]]:
|
||||
yield ("--folder", str(slides_folder), "--skip-all", "--playback-rate", "25")
|
||||
|
||||
|
||||
def test_present(args: Tuple[str, ...]) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
results = runner.invoke(present, ["BasicSlide", *args])
|
||||
|
||||
assert results.exit_code == 0
|
||||
assert results.stdout == ""
|
||||
|
||||
|
||||
def test_present_unexisting_slide(args: Tuple[str, ...]) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
results = runner.invoke(present, ["UnexistingSlide", *args])
|
||||
|
||||
assert results.exit_code != 0
|
||||
assert "UnexistingSlide.json does not exist" in results.stdout
|
||||
|
||||
|
||||
def test_present_full_screen(args: Tuple[str, ...]) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
results = runner.invoke(present, ["BasicSlide", "--fullscreen", *args])
|
||||
|
||||
assert results.exit_code == 0
|
||||
assert results.stdout == ""
|
||||
|
||||
|
||||
def test_present_hide_mouse(args: Tuple[str, ...]) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
results = runner.invoke(present, ["BasicSlide", "--hide-mouse", *args])
|
||||
|
||||
assert results.exit_code == 0
|
||||
assert results.stdout == ""
|
||||
|
||||
|
||||
def test_present_ignore_aspect_ratio(args: Tuple[str, ...]) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
results = runner.invoke(
|
||||
present, ["BasicSlide", "--aspect-ratio", "ignore", *args]
|
||||
)
|
||||
|
||||
assert results.exit_code == 0
|
||||
assert results.stdout == ""
|
||||
|
||||
|
||||
def test_present_start_at(args: Tuple[str, ...]) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
results = runner.invoke(present, ["BasicSlide", "--start-at", "-1,-1", *args])
|
||||
|
||||
assert results.exit_code == 0
|
||||
assert results.stdout == ""
|
||||
|
||||
|
||||
def test_present_start_at_invalid(args: Tuple[str, ...]) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
results = runner.invoke(present, ["BasicSlide", "--start-at", "0,1234", *args])
|
||||
|
||||
assert results.exit_code == 0
|
||||
assert "Could not set presentation index to 1234"
|
||||
|
||||
|
||||
def test_present_start_at_scene_number(args: Tuple[str, ...]) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
results = runner.invoke(
|
||||
present, ["BasicSlide", "BasicSlide", "--start-at-scene-number", "1", *args]
|
||||
)
|
||||
|
||||
assert results.exit_code == 0
|
||||
assert results.stdout == ""
|
||||
|
||||
|
||||
def test_present_start_at_slide_number(args: Tuple[str, ...]) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
results = runner.invoke(
|
||||
present, ["BasicSlide", "--start-at-slide-number", "1", *args]
|
||||
)
|
||||
|
||||
assert results.exit_code == 0
|
||||
assert results.stdout == ""
|
||||
|
||||
|
||||
def test_present_set_screen(args: Tuple[str, ...]) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
results = runner.invoke(present, ["BasicSlide", "--screen", "0", *args])
|
||||
|
||||
assert results.exit_code == 0
|
||||
assert results.stdout == ""
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Fails when running the whole test suite.")
|
||||
def test_present_set_invalid_screen(args: Tuple[str, ...]) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
results = runner.invoke(present, ["BasicSlide", "--screen", "999", *args])
|
||||
|
||||
assert results.exit_code == 0
|
||||
assert "Invalid screen number 999" in results.stdout
|
14
tests/test_qt_utils.py
Normal file
14
tests/test_qt_utils.py
Normal file
@ -0,0 +1,14 @@
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from manim_slides.qt_utils import qapp
|
||||
|
||||
|
||||
def test_qapp() -> None:
|
||||
assert isinstance(qapp(), QApplication)
|
||||
|
||||
|
||||
def test_duplicated_qapp() -> None:
|
||||
app1 = qapp()
|
||||
app2 = qapp()
|
||||
|
||||
assert app1 == app2
|
@ -1,13 +1,12 @@
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import numpy as np
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
from manim import (
|
||||
BLACK,
|
||||
BLUE,
|
||||
DOWN,
|
||||
LEFT,
|
||||
@ -20,36 +19,30 @@ from manim import (
|
||||
GrowFromCenter,
|
||||
Text,
|
||||
)
|
||||
from manim.__main__ import main as manim_cli
|
||||
from packaging import version
|
||||
from pydantic import ValidationError
|
||||
|
||||
from manim_slides.config import PresentationConfig
|
||||
from manim_slides.defaults import FOLDER_PATH
|
||||
from manim_slides.render import render
|
||||
from manim_slides.slide.manim import Slide
|
||||
|
||||
|
||||
@click.command(
|
||||
context_settings=dict(
|
||||
ignore_unknown_options=True,
|
||||
allow_extra_args=True,
|
||||
)
|
||||
)
|
||||
@click.pass_context
|
||||
def manimgl_cli(ctx: click.Context) -> None:
|
||||
subprocess.run([sys.executable, "-m", "manimlib", *ctx.args])
|
||||
|
||||
|
||||
cli = pytest.mark.parametrize(
|
||||
["cli"],
|
||||
@pytest.mark.parametrize(
|
||||
"renderer",
|
||||
[
|
||||
[manim_cli],
|
||||
[manimgl_cli],
|
||||
"--CE",
|
||||
pytest.param(
|
||||
"--GL",
|
||||
marks=pytest.mark.skipif(
|
||||
version.parse(np.__version__) >= version.parse("1.25"),
|
||||
reason="ManimGL requires numpy<1.25, which is outdate",
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@cli
|
||||
def test_render_basic_slide(
|
||||
cli: click.Command,
|
||||
renderer: str,
|
||||
slides_file: Path,
|
||||
presentation_config: PresentationConfig,
|
||||
manimgl_config: Path,
|
||||
@ -58,9 +51,11 @@ def test_render_basic_slide(
|
||||
|
||||
with runner.isolated_filesystem() as tmp_dir:
|
||||
shutil.copy(manimgl_config, tmp_dir)
|
||||
results = runner.invoke(cli, [str(slides_file), "BasicSlide", "-ql"])
|
||||
results = runner.invoke(
|
||||
render, [renderer, str(slides_file), "BasicSlide", "-ql"]
|
||||
)
|
||||
|
||||
assert results.exit_code == 0
|
||||
assert results.exit_code == 0, results
|
||||
|
||||
local_slides_folder = (Path(tmp_dir) / "slides").resolve(strict=True)
|
||||
|
||||
@ -114,6 +109,13 @@ class TestSlide:
|
||||
assert len(self._canvas) == 0
|
||||
assert self._wait_time_between_slides == 0.0
|
||||
|
||||
@assert_constructs
|
||||
class TestBackgroundColor(Slide):
|
||||
def construct(self) -> None:
|
||||
assert self._background_color == BLACK.to_hex() # DEFAULT
|
||||
self.camera.background_color = BLUE
|
||||
assert self._background_color == BLUE.to_hex()
|
||||
|
||||
@assert_renders
|
||||
class TestMultipleAnimationsInLastSlide(Slide):
|
||||
"""Check against solution for issue #161."""
|
||||
@ -154,16 +156,89 @@ class TestSlide:
|
||||
|
||||
self.add(text)
|
||||
|
||||
assert "loop" not in self._pre_slide_config_kwargs
|
||||
assert not self._base_slide_config.loop
|
||||
|
||||
self.next_slide(loop=True)
|
||||
self.play(text.animate.scale(2))
|
||||
|
||||
assert self._pre_slide_config_kwargs["loop"]
|
||||
assert self._base_slide_config.loop
|
||||
|
||||
self.next_slide(loop=False)
|
||||
|
||||
assert not self._pre_slide_config_kwargs["loop"]
|
||||
assert not self._base_slide_config.loop
|
||||
|
||||
@assert_constructs
|
||||
class TestAutoNext(Slide):
|
||||
def construct(self) -> None:
|
||||
text = Text("Some text")
|
||||
|
||||
self.add(text)
|
||||
|
||||
assert not self._base_slide_config.auto_next
|
||||
|
||||
self.next_slide(auto_next=True)
|
||||
self.play(text.animate.scale(2))
|
||||
|
||||
assert self._base_slide_config.auto_next
|
||||
|
||||
self.next_slide(auto_next=False)
|
||||
|
||||
assert not self._base_slide_config.auto_next
|
||||
|
||||
@assert_constructs
|
||||
class TestLoopAndAutoNextFails(Slide):
|
||||
def construct(self) -> None:
|
||||
text = Text("Some text")
|
||||
|
||||
self.add(text)
|
||||
|
||||
self.next_slide(loop=True, auto_next=True)
|
||||
self.play(text.animate.scale(2))
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
self.next_slide()
|
||||
|
||||
@assert_constructs
|
||||
class TestPlaybackRate(Slide):
|
||||
def construct(self) -> None:
|
||||
text = Text("Some text")
|
||||
|
||||
self.add(text)
|
||||
|
||||
assert self._base_slide_config.playback_rate == 1.0
|
||||
|
||||
self.next_slide(playback_rate=2.0)
|
||||
self.play(text.animate.scale(2))
|
||||
|
||||
assert self._base_slide_config.playback_rate == 2.0
|
||||
|
||||
@assert_constructs
|
||||
class TestReversedPlaybackRate(Slide):
|
||||
def construct(self) -> None:
|
||||
text = Text("Some text")
|
||||
|
||||
self.add(text)
|
||||
|
||||
assert self._base_slide_config.reversed_playback_rate == 1.0
|
||||
|
||||
self.next_slide(reversed_playback_rate=2.0)
|
||||
self.play(text.animate.scale(2))
|
||||
|
||||
assert self._base_slide_config.reversed_playback_rate == 2.0
|
||||
|
||||
@assert_constructs
|
||||
class TestNotes(Slide):
|
||||
def construct(self) -> None:
|
||||
text = Text("Some text")
|
||||
|
||||
self.add(text)
|
||||
|
||||
assert self._base_slide_config.notes == ""
|
||||
|
||||
self.next_slide(notes="test")
|
||||
self.play(text.animate.scale(2))
|
||||
|
||||
assert self._base_slide_config.notes == "test"
|
||||
|
||||
@assert_constructs
|
||||
class TestWipe(Slide):
|
||||
@ -197,6 +272,42 @@ class TestSlide:
|
||||
assert text not in self.mobjects
|
||||
assert bye in self.mobjects
|
||||
|
||||
@assert_constructs
|
||||
class TestPlay(Slide):
|
||||
def construct(self) -> None:
|
||||
assert self._current_animation == 0
|
||||
circle = Circle(color=BLUE)
|
||||
dot = Dot()
|
||||
|
||||
self.play(GrowFromCenter(circle))
|
||||
assert self._current_animation == 1
|
||||
self.play(FadeIn(dot))
|
||||
assert self._current_animation == 2
|
||||
|
||||
@assert_constructs
|
||||
class TestWaitTimeBetweenSlides(Slide):
|
||||
def construct(self) -> None:
|
||||
self._wait_time_between_slides = 1.0
|
||||
assert self._current_animation == 0
|
||||
circle = Circle(color=BLUE)
|
||||
self.play(GrowFromCenter(circle))
|
||||
assert self._current_animation == 1
|
||||
self.next_slide()
|
||||
assert self._current_animation == 2 # self.wait = +1
|
||||
|
||||
@assert_constructs
|
||||
class TestNextSlide(Slide):
|
||||
def construct(self) -> None:
|
||||
assert self._current_slide == 1
|
||||
self.next_slide()
|
||||
assert self._current_slide == 1
|
||||
circle = Circle(color=BLUE)
|
||||
self.play(GrowFromCenter(circle))
|
||||
self.next_slide()
|
||||
assert self._current_slide == 2
|
||||
self.next_slide()
|
||||
assert self._current_slide == 2
|
||||
|
||||
@assert_constructs
|
||||
class TestCanvas(Slide):
|
||||
def construct(self) -> None:
|
||||
|
182
tests/test_wizard.py
Normal file
182
tests/test_wizard.py
Normal file
@ -0,0 +1,182 @@
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
from pytest import MonkeyPatch
|
||||
from pytestqt.qtbot import QtBot
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QMessageBox,
|
||||
)
|
||||
|
||||
from manim_slides.config import Config, Key
|
||||
from manim_slides.defaults import CONFIG_PATH
|
||||
from manim_slides.wizard import init, wizard
|
||||
from manim_slides.wizard.wizard import KeyInput, Wizard
|
||||
|
||||
|
||||
class TestKeyInput:
|
||||
def test_default_is_none(self, qtbot: QtBot) -> None:
|
||||
widget = KeyInput()
|
||||
widget.show()
|
||||
qtbot.addWidget(widget)
|
||||
assert widget.key is None
|
||||
|
||||
def test_send_key(self, qtbot: QtBot) -> None:
|
||||
widget = KeyInput()
|
||||
widget.show()
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.keyPress(widget, Qt.Key_Q)
|
||||
assert widget.key is Qt.Key_Q.value
|
||||
|
||||
|
||||
class TestWizard:
|
||||
def test_close_without_saving(self, qtbot: QtBot) -> None:
|
||||
wizard = Wizard(Config())
|
||||
wizard.show()
|
||||
qtbot.addWidget(wizard)
|
||||
wizard.button_box.rejected.emit()
|
||||
assert wizard.closed_without_saving
|
||||
|
||||
def test_save_valid_config(self, qtbot: QtBot) -> None:
|
||||
widget = Wizard(Config())
|
||||
widget.show()
|
||||
qtbot.addWidget(widget)
|
||||
widget.button_box.accepted.emit()
|
||||
assert not widget.closed_without_saving
|
||||
|
||||
def test_save_invalid_config(self, qtbot: QtBot, monkeypatch: MonkeyPatch) -> None:
|
||||
wizard = Wizard(Config())
|
||||
wizard.show()
|
||||
qtbot.addWidget(wizard)
|
||||
|
||||
def open_dialog(button_number: int, key: Key) -> None:
|
||||
button = wizard.buttons[button_number]
|
||||
dialog = KeyInput()
|
||||
qtbot.addWidget(dialog)
|
||||
qtbot.keyPress(dialog, Qt.Key_Q)
|
||||
assert dialog.key is not None
|
||||
key.set_ids(dialog.key)
|
||||
button.setText("Q")
|
||||
assert button.text() == "Q"
|
||||
dialog.close()
|
||||
|
||||
message_boxes = []
|
||||
|
||||
def exec_patched(self: QMessageBox) -> None:
|
||||
self.show()
|
||||
message_boxes.append(self)
|
||||
|
||||
monkeypatch.setattr(QMessageBox, "exec", exec_patched)
|
||||
|
||||
for i, (key, _) in enumerate(wizard.config.keys.dict().items()):
|
||||
open_dialog(i, getattr(wizard.config.keys, key))
|
||||
|
||||
wizard.button_box.accepted.emit()
|
||||
message_box = message_boxes.pop()
|
||||
qtbot.addWidget(message_box)
|
||||
assert message_box.isVisible()
|
||||
|
||||
|
||||
def test_init() -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
assert not CONFIG_PATH.exists()
|
||||
results = runner.invoke(
|
||||
init,
|
||||
)
|
||||
|
||||
assert results.exit_code == 0
|
||||
assert CONFIG_PATH.exists()
|
||||
assert Config().dict() == Config.from_file(CONFIG_PATH).dict()
|
||||
|
||||
|
||||
def test_init_custom_path() -> None:
|
||||
runner = CliRunner()
|
||||
custom_path = Path("config.toml")
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
assert not custom_path.exists()
|
||||
results = runner.invoke(
|
||||
init,
|
||||
["--config", str(custom_path)],
|
||||
)
|
||||
|
||||
assert results.exit_code == 0
|
||||
assert not CONFIG_PATH.exists()
|
||||
assert custom_path.exists()
|
||||
assert Config().dict() == Config.from_file(custom_path).dict()
|
||||
|
||||
|
||||
def test_init_path_exists() -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
assert not CONFIG_PATH.exists()
|
||||
results = runner.invoke(
|
||||
init,
|
||||
)
|
||||
|
||||
assert results.exit_code == 0
|
||||
assert CONFIG_PATH.exists()
|
||||
assert Config().dict() == Config.from_file(CONFIG_PATH).dict()
|
||||
|
||||
results = runner.invoke(init, input="o")
|
||||
|
||||
assert results.exit_code == 0
|
||||
|
||||
results = runner.invoke(init, input="m")
|
||||
|
||||
assert results.exit_code == 0
|
||||
|
||||
results = runner.invoke(init, input="q")
|
||||
|
||||
assert results.exit_code == 0
|
||||
|
||||
|
||||
def test_wizard(monkeypatch: MonkeyPatch) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
assert not CONFIG_PATH.exists()
|
||||
|
||||
def show(self: Wizard) -> None:
|
||||
self.button_box.accepted.emit()
|
||||
|
||||
def exec_patched(self: QApplication) -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(Wizard, "show", show)
|
||||
monkeypatch.setattr(QApplication, "exec", exec_patched)
|
||||
|
||||
results = runner.invoke(
|
||||
wizard,
|
||||
)
|
||||
|
||||
assert results.exit_code == 0
|
||||
assert CONFIG_PATH.exists()
|
||||
assert Config().dict() == Config.from_file(CONFIG_PATH).dict()
|
||||
|
||||
|
||||
def test_wizard_closed_without_saving(monkeypatch: MonkeyPatch) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
with runner.isolated_filesystem():
|
||||
assert not CONFIG_PATH.exists()
|
||||
|
||||
def show(self: Wizard) -> None:
|
||||
self.button_box.rejected.emit()
|
||||
|
||||
def exec_patched(self: QApplication) -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(Wizard, "show", show)
|
||||
monkeypatch.setattr(QApplication, "exec", exec_patched)
|
||||
|
||||
results = runner.invoke(
|
||||
wizard,
|
||||
)
|
||||
|
||||
assert results.exit_code == 0
|
||||
assert not CONFIG_PATH.exists()
|
Reference in New Issue
Block a user