Compare commits

..

5 Commits

75 changed files with 3811 additions and 7425 deletions

View File

@ -1,9 +1,5 @@
[bumpversion]
current_version = 5.0.0-rc1
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(-rc(?P<release>\d+))?
serialize =
{major}.{minor}.{patch}-rc{release}
{major}.{minor}.{patch}
current_version = 4.13.1
commit = True
message = chore(version): bump {current_version} to {new_version}
@ -14,7 +10,3 @@ 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}

View File

@ -1,48 +0,0 @@
on: [push]
name: Code Coverage
jobs:
test:
name: Coverage
runs-on: ubuntu-latest
env:
QT_QPA_PLATFORM: offscreen
MANIM_SLIDES_VERBOSITY: debug
PYTHONFAULTHANDLER: 1
DISPLAY: :99
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Install Poetry
run: pipx install poetry
- name: Install Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
cache: poetry
- name: Install manim dependencies on Ubuntu
run: |
sudo apt-get install libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev
- name: Install xvfb on Ubuntu
run: |
sudo apt-get install xvfb
nohup Xvfb $DISPLAY &
- name: Install Manim Slides
run: |
poetry install --with test
- name: Run pytest and coverage
run: poetry run pytest --cov-report xml --cov=manim_slides tests/
- name: Upload to codecov.io
uses: codecov/codecov-action@v3
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
fail_ci_if_error: true

View File

@ -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@v1
with:
name: paper
# This is the output path where Pandoc will write the compiled

View File

@ -8,7 +8,7 @@ jobs:
languagetool_check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v1
- uses: reviewdog/action-languagetool@v1
with:
reporter: github-pr-review

View File

@ -41,17 +41,13 @@ jobs:
python-version: '3.9'
cache: poetry
- name: Setup Pages
uses: actions/configure-pages@v3
uses: actions/configure-pages@v2
- 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
run: poetry install --extras=manim --with docs
- name: Restore cached media
id: cache-media-restore
uses: actions/cache/restore@v3
@ -84,7 +80,7 @@ jobs:
run: cd docs && poetry run make html
- name: Upload artifact
if: github.event_name != 'pull_request'
uses: actions/upload-pages-artifact@v2
uses: actions/upload-pages-artifact@v1
with:
# Upload docs/build/html dir
path: docs/build/html/
@ -93,4 +89,4 @@ jobs:
- name: Deploy to GitHub Pages
id: deployment
if: github.event_name != 'pull_request'
uses: actions/deploy-pages@v2
uses: actions/deploy-pages@v1

View File

@ -1,53 +1,21 @@
on:
pull_request:
paths:
- pyproject.toml
- poetry.lock
- '**.py'
- .github/workflows/test_examples.yml
workflow_dispatch:
name: Tests
name: Test Examples
env:
QT_QPA_PLATFORM: offscreen
MANIM_SLIDES_VERBOSITY: debug
PYTHONFAULTHANDLER: 1
DISPLAY: :99
jobs:
pytest:
strategy:
fail-fast: false
matrix:
pyversion: ['3.8', '3.9', '3.10', '3.11']
runs-on: ubuntu-latest
env:
QT_QPA_PLATFORM: offscreen
MANIM_SLIDES_VERBOSITY: debug
PYTHONFAULTHANDLER: 1
DISPLAY: :99
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Install Poetry
run: pipx install poetry
- name: Install Python
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.pyversion }}
cache: poetry
- name: Run apt-get update on Ubuntu
run: sudo apt-get update
- name: Install manim dependencies on Ubuntu
run: |
sudo apt-get install libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev
- name: Install xvfb on Ubuntu
run: |
sudo apt-get install xvfb
nohup Xvfb $DISPLAY &
- name: Install Manim Slides
run: |
poetry install --with test
- name: Run pytest
run: poetry run pytest -x -n auto
build-examples:
strategy:
fail-fast: false
@ -77,18 +45,11 @@ jobs:
pyversion: '3.10'
manim: manim
runs-on: ${{ matrix.os }}
env:
QT_QPA_PLATFORM: offscreen
MANIM_SLIDES_VERBOSITY: debug
PYTHONFAULTHANDLER: 1
DISPLAY: :99
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Install Poetry
run: pipx install poetry
- name: Install Python
uses: actions/setup-python@v4
with:
@ -101,11 +62,9 @@ jobs:
run: |
echo "${HOME}/.local/bin" >> $GITHUB_PATH
echo "/Users/runner/Library/Python/${{ matrix.pyversion }}/bin" >> $GITHUB_PATH
- name: Append to Path on Ubuntu
if: matrix.os == 'ubuntu-latest'
run: echo "${HOME}/.local/bin" >> $GITHUB_PATH
- name: Append to Path on Windows
if: matrix.os == 'windows-latest'
run: echo "${HOME}/.local/bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
@ -114,31 +73,25 @@ jobs:
- name: Install manim dependencies on MacOs
if: matrix.os == 'macos-latest' && matrix.manim == 'manim'
run: brew install ffmpeg py3cairo
- name: Install manimgl dependencies on MacOS
if: matrix.os == 'macos-latest' && matrix.manim == 'manimgl'
run: brew install ffmpeg
- name: Run apt-get update on Ubuntu
if: matrix.os == 'ubuntu-latest'
run: sudo apt-get update
- name: Install manim dependencies on Ubuntu
if: matrix.os == 'ubuntu-latest' && matrix.manim == 'manim'
run: |
sudo apt-get install libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev
- name: Install manimgl dependencies on Ubuntu
if: matrix.os == 'ubuntu-latest' && matrix.manim == 'manimgl'
run: |
sudo apt-get install libpango1.0-dev ffmpeg freeglut3-dev
- name: Install xvfb on Ubuntu
if: matrix.os == 'ubuntu-latest' && matrix.manim == 'manimgl'
run: |
sudo apt-get install xvfb
nohup Xvfb $DISPLAY &
- name: Install Windows dependencies
if: matrix.os == 'windows-latest'
run: choco install ffmpeg
@ -146,13 +99,13 @@ jobs:
# Install Manim Slides
- name: Install Manim Slides
run: |
poetry install --extras ${{ matrix.manim }}
poetry config experimental.new-installer false
poetry install --with test
# Render slides
- name: Render slides
if: matrix.manim == 'manim'
run: poetry run manim -ql example.py BasicExample ThreeDExample
- name: Render slides
if: matrix.manim == 'manimgl'
run: poetry run -v manimgl -l example.py BasicExample ThreeDExample

39
.gitignore vendored
View File

@ -1,33 +1,33 @@
# Python files
__pycache__/
/env
/tests
/build
/dist
*.egg-info/
# Manim files
images/
/media
docs/source/media/
/presentation
# ManimGL files
videos/
# Manim Slides files
.manim-slides.toml
/.vscode
slides/
!tests/data/slides/
.manim-slides.json
videos/
images/
docs/build/
docs/source/_static/slides_assets/
docs/source/_static/slides.html
slides_assets/
# Docs
docs/build/
slides.html
docs/source/reference/.ipynb_checkpoints/
docs/source/_static/basic_example_assets/
docs/source/_static/basic_example.html
@ -36,11 +36,8 @@ docs/source/_static/three_d_example.html
docs/source/_static/three_d_example_assets/
docs/source/reference/media/
# JOSE Paper
paper/paper.pdf
paper/media/
# Others
coverage.xml
*.jats
paper/paper.pdf

View File

@ -12,7 +12,7 @@ repos:
- id: isort
name: isort (python)
- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
rev: v2.10.0
rev: v2.8.0
hooks:
- id: pretty-format-yaml
args: [--autofix]
@ -20,16 +20,15 @@ repos:
exclude: poetry.lock
args: [--autofix]
- repo: https://github.com/psf/black
rev: 23.7.0
rev: 23.3.0
hooks:
- id: black
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.0.284
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.0.265
hooks:
- id: ruff
args: [--fix]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.5.0
rev: v1.2.0
hooks:
- id: mypy
additional_dependencies: [types-requests, types-setuptools]

View File

@ -1,60 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
<!-- start changelog -->
## [v5 (Unreleased)](https://github.com/jeertmans/languagetool-rust/compare/v4.16.0...HEAD)
Prior to v5, there was no real CHANGELOG other than the GitHub releases,
with most of the content automatically generated by GitHub from merged
pull requests.
In an effort to better document changes, this CHANGELOG document is now created.
### Added
- Added the following option aliases to `manim-slides present`:
`-F` and `--full-screen` for `fullscreen`,
`-H` for `--hide-mouse`,
and `-S` for `--screen-number`.
[#243](https://github.com/jeertmans/manim-slides/pull/243)
- Added a full screen key binding (defaults to <kbd>F</kbd>) in the
presenter.
[#243](https://github.com/jeertmans/manim-slides/pull/243)
### Changed
- Automatically concatenate all animations from a slide into one.
This is a **breaking change** because the config file format is
different from the previous one. For migration help, see associated PR.
[#242](https://github.com/jeertmans/manim-slides/pull/242)
- Changed the player interface to only use PySide6, and not a combination of
PySide6 and OpenCV. A few features have been removed (see removed section),
but the new player should be much easier to maintain and more performant,
than its predecessor.
[#243](https://github.com/jeertmans/manim-slides/pull/243)
- Changed the slide config format to exclude unecessary information.
`StypeType` is removed in favor to one boolean `loop` field. This is
a **breaking change** and one should re-render the slides to apply changes.
[#243](https://github.com/jeertmans/manim-slides/pull/243)
- Renamed key bindings in the config. This is a **breaking change** and one
should either manually rename them (see list below) or re-init a config.
List of changes: `CONTINUE` to `NEXT`, `BACK` to `PREVIOUS`, and
`REWIND` to `REPLAY`.
[#243](https://github.com/jeertmans/manim-slides/pull/243)
### Removed
- Removed `--start-at-animation-number` option from `manim-slides present`.
[#242](https://github.com/jeertmans/manim-slides/pull/242)
- Removed the following options from `manim-slides present`:
`--resolution`, `--record-to`, `--resize-mode`, and `--background-color`.
[#243](https://github.com/jeertmans/manim-slides/pull/243)
- Removed `PERF` verbosity level because not used anymore.
[#245](https://github.com/jeertmans/manim-slides/pull/245)
<!-- end changelog -->

View File

@ -1,32 +0,0 @@
# This CITATION.cff file was generated with cffinit.
# Visit https://bit.ly/cffinit to generate yours today!
cff-version: 1.2.0
title: Manim Slides
message: A Python package for presenting Manim content anywhere
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.
keywords:
- Education
- Math Animations
- Presentation Tool
- PowerPoint
- Python
license: MIT
version: v5.0.0-rc1

View File

@ -8,10 +8,6 @@
[![Python version][pypi-python-version-badge]][pypi-version-url]
[![PyPI - Downloads][pypi-download-badge]][pypi-version-url]
[![Documentation][documentation-badge]][documentation-url]
[![DOI][doi-badge]][doi-url]
[![JOSE Paper][jose-badge]][jose-url]
[![codecov][codecov-badge]][codecov-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!
@ -262,10 +258,3 @@ you can do so at: [jeertmans@icloud.com](mailto:jeertmans@icloud.com).
[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/
[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

View File

@ -1,6 +1,3 @@
# Changelog
```{include} ../../CHANGELOG.md
:start-after: <!-- start changelog -->
:end-before: <!-- end changelog -->
```
Changes between releases are listed in Manim Slides' [Github releases](https://github.com/jeertmans/manim-slides/releases). You can read the [latest release here](https://github.com/jeertmans/manim-slides/releases).

View File

@ -15,24 +15,15 @@ author = "Jérome Eertmans"
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = [
# Built-in
"sphinx.ext.autodoc",
"sphinx.ext.intersphinx",
"sphinx.ext.viewcode",
# Additional
"nbsphinx",
"myst_parser",
"sphinxext.opengraph",
"sphinx_click",
"myst_parser",
"sphinx_copybutton",
# Custom
"manim_slides.docs.manim_slides_directive",
]
typehints_defaults = "comma"
typehints_use_signature = True
typehints_use_signature_return = True
myst_enable_extensions = [
"colon_fence",
"html_admonition",

View File

@ -6,21 +6,21 @@ The following summarizes the different presentation features Manim Slides offers
:widths: auto
:align: center
| Feature / Constraint | [`present`](reference/cli.md) | [`convert --to=html`](reference/cli.md) | [`convert --to=pptx`](reference/cli.md) | [`convert --to=pdf`](reference/cli.md)
| :--- | :---: | :---: | :---: | :---: |
| Basic navigation through slides | Yes | Yes | Yes | Yes (static image) |
| Replay slide | Yes | No | No | N/A |
| Pause animation | Yes | No | No | N/A |
| Play slide in reverse | Yes | No | No | N/A |
| Slide count | Yes | Yes (optional) | Yes (optional) | N/A |
| Animation count | Yes | No | No | N/A |
| Needs Python with Manim Slides installed | Yes | No | No | No
| Requires internet access | No | Yes | No | No |
| Auto. play slides | Yes | Yes | Yes | N/A |
| Loops support | Yes | Yes | Yes | N/A |
| Fully customizable | No | Yes (`--use-template` option) | No | No |
| Other dependencies | None | A modern web browser | PowerPoint or LibreOffice Impress[^1] | None |
| Works cross-platforms | Yes | Yes | Partly[^1][^2] | Yes |
| Feature / Constraint | [`present`](reference/cli.md) | [`convert --to=html`](reference/cli.md) | [`convert --to=pptx`](reference/cli.md) |
| :--- | :---: | :---: | :---: |
| Basic navigation through slides | Yes | Yes | Yes |
| Replay slide | Yes | No | No |
| Pause animation | Yes | No | No |
| Play slide in reverse | Yes | No | No |
| Slide count | Yes | Yes (optional) | Yes (optional) |
| Animation count | Yes | No | No |
| Needs Python with Manim Slides installed | Yes | No | No |
| Requires internet access | No | Yes | No |
| Auto. play slides | Yes | Yes | Yes |
| Loops support | Yes | Yes | Yes |
| Fully customizable | No | Yes (`--use-template` option) | No |
| Other dependencies | None | A modern web browser | PowerPoint or LibreOffice Impress[^1]
| Works cross-platforms | Yes | Yes | Partly[^1][^2] |
:::
[^1]: If you encounter a problem where slides do not automatically play or loops do not work, please [file an issue on GitHub](https://github.com/jeertmans/manim-slides/issues/new/choose).

View File

@ -26,7 +26,7 @@ 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.
Slide through the demo below to get a quick glimpse on what you can do with Manin Slides.
<!-- From: https://faq.dailymotion.com/hc/en-us/articles/360022841393-How-to-preserve-the-player-aspect-ratio-on-a-responsive-page -->

View File

@ -1,26 +1,12 @@
# Application Programming Interface
Manim Slides' API is very limited: it simply consists of two classes, `Slide`
and `ThreeDSlide`, which are subclasses of `Scene` and `ThreeDScene` from Manim.
Manim Slides' API is very limited: it simply consists of two classes, `Slide` and `ThreeDSlide`, which are subclasses of `Scene` and `ThreeDScene` from Manim.
Therefore, we only document here the methods we think the end-user will ever
use, not the methods used internally when rendering.
Thefore, we only document here the methods we think the end-user will ever use, not the methods used internally when rendering.
```{eval-rst}
.. autoclass:: manim_slides.Slide
:members:
add_to_canvas,
canvas,
canvas_mobjects,
end_loop,
mobjects_without_canvas,
next_slide,
pause,
remove_from_canvas,
start_loop,
wait_time_between_slides,
wipe,
zoom,
:members: start_loop, end_loop, pause, next_slide
.. autoclass:: manim_slides.ThreeDSlide
:members:

View File

@ -66,56 +66,6 @@ Example using 3D camera. As Manim and ManimGL handle 3D differently, definitions
:end-before: [manimgl-3d]
```
## Subclass Custom Scenes
For compatibility reasons, Manim Slides only provides subclasses for
`Scene` and `ThreeDScene`.
However, subclassing other scene classes is totally possible,
and very simple to do actually!
[For example](https://github.com/jeertmans/manim-slides/discussions/185),
you can subclass the `MovingCameraScene` class from `manim`
with the following code:
```{code-block} python
:linenos:
from manim import *
from manim_slides import Slide
class MovingCameraSlide(Slide, MovingCameraScene):
pass
```
And later use this class anywhere in your code:
```{code-block} python
:linenos:
class SubclassExample(MovingCameraSlide):
def construct(self):
eq1 = MathTex("x", "=", "1")
eq2 = MathTex("x", "=", "2")
self.play(Write(eq1))
self.next_slide()
self.play(
TransformMatchingTex(eq1, eq2),
self.camera.frame.animate.scale(0.5)
)
self.wait()
```
:::{note}
If you do not plan to reuse `MovingCameraSlide` more than once, then you can
directly write the `construct` method in the body of `MovingCameraSlide`.
:::
## Advanced Example
A more advanced example is `ConvertExample`, which is used as demo slide and tutorial.

View File

@ -10,9 +10,7 @@ cli
examples
gui
html
IPython magic <ipython_magic>
sharing
Sphinx Extension <sphinx_extension>
```
[Application Programming Interface](./api): list of classes and methods that may
@ -25,13 +23,6 @@ Slides' executable.
[Graphical User Interface](./gui): details about the main Manim Slide' feature.
[HTML Presentation](./html): an alternative way of presenting your animations.
[IPython Magic](./ipython_magic): a magic to render and display Manim Slides inside notebooks.
+ [Example](./magic_example): example notebook using the magics.
[HTML Presenetation](./html): an alternative way of presenting your animations.
[Sharing](./sharing): how to share your presentation with others.
[Sphinx Extension](./sphinx_extension): a Sphinx extension for diplaying Manim Slides animations within your documentation.

View File

@ -1,6 +0,0 @@
# Manim Slides' IPython magic
```{eval-rst}
.. automodule:: manim_slides.ipython.ipython_magic
:members: ManimSlidesMagic
```

View File

@ -1,100 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "6896875b-34ce-4fc5-809c-669c295067e7",
"metadata": {},
"source": [
"# Jupyter Magic Example\n",
"\n",
"This small example shows how to use the Manim Slides cell (`%%manim_slides`) and line (`%manim_slides`) magics:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "a73f1c06-c7f8-4f19-a90e-e283bfb8c7c5",
"metadata": {},
"outputs": [],
"source": [
"from manim import *\n",
"from manim_slides import *"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "68dda1a0-74ff-4d9e-9575-5b25a98f21e7",
"metadata": {},
"outputs": [],
"source": [
"%%manim_slides -v WARNING --progress_bar None MySlide --manim-slides controls=true\n",
"\n",
"config.media_embed = True\n",
"\n",
"class MySlide(Slide):\n",
" def construct(self):\n",
" square = Square()\n",
" circle = Circle()\n",
" \n",
" self.play(Create(square))\n",
" self.next_slide()\n",
" self.play(Transform(square, circle))"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "54fa2d3e-bfee-417d-b64b-f3f30a8749ea",
"metadata": {},
"outputs": [],
"source": [
"class MyOtherSlide(Slide):\n",
" def construct(self):\n",
" text = VGroup(\n",
" Text(\"Press\"),\n",
" Text(\"and\"),\n",
" Text(\"loop\"),\n",
" ).arrange(DOWN, buff=1.)\n",
" \n",
" self.play(Write(text))\n",
" self.next_slide()\n",
" self.start_loop()\n",
" self.play(Indicate(text[-1], scale_factor=2., run_time=.5))\n",
" self.end_loop()\n",
" self.play(FadeOut(text))"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "7d8ad450-1487-4ca7-8d89-bf8ac344e1fa",
"metadata": {},
"outputs": [],
"source": [
"%manim_slides -v WARNING --progress_bar None MyOtherSlide --manim-slides controls=true"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "manim-slides",
"language": "python",
"name": "manim-slides"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.10.6"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@ -150,10 +150,7 @@ reason.
### With PowerPoint (*EXPERIMENTAL*)
A recent conversion feature is to the PowerPoint format, thanks to the
`python-pptx` package. Even though it is fully working,
it is still considered in an *EXPERIMENTAL* status because we do not
exactly know what versions of PowerPoint (or LibreOffice Impress) are supported.
A recent conversion feature is to the PowerPoint format, thanks to the `python-pptx` package. Even though it is fully working, it is still considered in an *EXPERIMENTAL* status because we do not exactly know what versions of PowerPoint (or LibreOffice Impress) are supported.
Basically, you can create a PowerPoint in a single command:
@ -161,24 +158,6 @@ Basically, you can create a PowerPoint in a single command:
manim-slides convert --to=pptx BasicExample basic_example.pptx
```
All the videos and necessary files will be contained inside the `.pptx` file, so
you can safely share it with anyone. By default, the `poster_frame_image`, i.e.,
what is displayed by PowerPoint when the video is not playing, is the first
frame of each slide. This allows for smooth transitions.
All the videos and necessary files will be contained inside the `.pptx` file, so you can safely share it with anyone. By default, the `poster_frame_image`, i.e., what is displayed by PowerPoint when the video is not playing, is the first frame of each slide. This allows for smooth transitions.
In the future, we hope to provide more features to this format,
so feel free to suggest new features too!
### Static PDF presentation
If you ever need backup slides, that are only made of PDF pages
with static images, you can generate such a PDF with the following command:
```bash
manim-slides convert --to=pdf BasicExample basic_example.pdf
```
Note that you will lose all the benefits from animated slides. Therefore,
this is only recommended to be used as a backup plan. By default, the last frame
of each slide will be printed. This can be changed to be the first one with
`-cframe_index=first`.
In the future, we hope to provide more features to this format, so feel free to suggest new features too!

View File

@ -1,6 +0,0 @@
# Manim Slides' Sphinx directive
```{eval-rst}
.. automodule:: manim_slides.docs.manim_slides_directive
:members: ManimSlidesDirective
```

View File

@ -2,14 +2,16 @@
# type: ignore
import sys
if "manimlib" in sys.modules:
if "manim" in sys.modules:
from manim import *
MANIMGL = False
elif "manimlib" in sys.modules:
from manimlib import *
MANIMGL = True
else:
from manim import *
MANIMGL = False
raise ImportError("This script must be run with either `manim` or `manimgl`")
from manim_slides import Slide, ThreeDSlide
@ -70,9 +72,10 @@ class TestFileTooLong(Slide):
class ConvertExample(Slide):
"""WARNING: this example does not seem to work with ManimGL."""
def construct(self):
self.wait_time_between_slides = 0.1
def tinywait(self):
self.wait(0.1)
def construct(self):
title = VGroup(
Text("From Manim animations", t2c={"From": BLUE}),
Text("to slides presentation", t2c={"to": BLUE}),
@ -207,32 +210,41 @@ class Example(Slide):
language="console",
).shift(DOWN)
self.play(self.wipe(title, code))
self.clear()
self.play(FadeIn(code))
self.tinywait()
self.next_slide()
self.play(FadeIn(step, shift=RIGHT))
self.play(Transform(code, code_step_1))
self.tinywait()
self.next_slide()
self.play(Transform(step, step_2))
self.play(Transform(code, code_step_2))
self.tinywait()
self.next_slide()
self.play(Transform(step, step_3))
self.play(Transform(code, code_step_3))
self.tinywait()
self.next_slide()
self.play(Transform(step, step_4))
self.play(Transform(code, code_step_4))
self.tinywait()
self.next_slide()
self.play(Transform(step, step_5))
self.play(Transform(code, code_step_5))
self.tinywait()
self.next_slide()
self.play(Transform(step, step_6))
self.play(Transform(code, code_step_6))
self.play(code.animate.shift(UP), FadeIn(code_step_7), FadeIn(or_text))
self.tinywait()
self.next_slide()
watch_text = Text("Watch result on next slides!").shift(2 * DOWN).scale(0.5)
@ -252,8 +264,10 @@ class Example(Slide):
self.play(Transform(dot, square))
self.remove(dot)
self.add(square)
self.tinywait()
self.next_slide()
self.play(Rotate(square, angle=PI / 4))
self.tinywait()
self.next_slide()
learn_more_text = (
@ -266,6 +280,7 @@ class Example(Slide):
)
self.play(Transform(square, learn_more_text))
self.tinywait()
# For ThreeDExample, things are different
@ -331,10 +346,7 @@ else:
)
self.play(GrowFromCenter(circle))
def updater(m, dt):
return m.increment_theta((75 * DEGREES / 4) * dt)
updater = lambda m, dt: m.increment_theta((75 * DEGREES / 4) * dt)
frame.add_updater(updater)
self.next_slide()

View File

@ -13,20 +13,6 @@ class module(ModuleType):
"manim_slides.slide", None, None, ["Slide", "ThreeDSlide"]
)
return getattr(module, name)
elif name == "ManimSlidesMagic":
module = __import__(
"manim_slides.ipython.ipython_magic", None, None, ["ManimSlidesMagic"]
)
magic = getattr(module, name)
from IPython import get_ipython
ipy = get_ipython()
if ipy is not None:
ipy.register_magics(magic)
return magic
return ModuleType.__getattribute__(self, name)
@ -57,6 +43,6 @@ new_module.__dict__.update(
"__path__": __path__,
"__doc__": __doc__,
"__version__": __version__,
"__all__": ("__version__", "ManimSlidesMagic", "Slide", "ThreeDSlide"),
"__all__": ("__version__", "Slides", "ThreeDSlide"),
}
)

View File

@ -1 +1 @@
__version__ = "5.0.0-rc1"
__version__ = "4.13.1"

View File

@ -44,7 +44,7 @@ def config_options(function: F) -> F:
def verbosity_option(function: F) -> F:
"""Wraps a function to add verbosity option."""
def callback(ctx: Context, param: Parameter, value: str) -> None:
def callback(ctx: Context, param: Parameter, value: bool) -> None:
if not value or ctx.resilient_parsing:
return
@ -54,10 +54,10 @@ def verbosity_option(function: F) -> F:
"-v",
"--verbosity",
type=click.Choice(
["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
["PERF", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
case_sensitive=False,
),
help="Verbosity of CLI output.",
help="Verbosity of CLI output",
default=None,
expose_value=False,
envvar="MANIM_SLIDES_VERBOSITY",

View File

@ -1,57 +1,54 @@
import json
import hashlib
import os
import shutil
import subprocess
import tempfile
from enum import Enum
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
from typing import Dict, List, Optional, Set, Tuple, Union
import rtoml
from pydantic import (
BaseModel,
Field,
FilePath,
PositiveInt,
PrivateAttr,
field_validator,
model_validator,
)
from pydantic_extra_types.color import Color
from pydantic import BaseModel, FilePath, PositiveInt, root_validator, validator
from pydantic.color import Color
from PySide6.QtCore import Qt
from .defaults import FFMPEG_BIN
from .logger import logger
Receiver = Callable[..., Any]
def merge_basenames(files: List[FilePath]) -> Path:
"""
Merge multiple filenames by concatenating basenames.
"""
logger.info(f"Generating a new filename for animations: {files}")
dirname: Path = files[0].parent
ext = files[0].suffix
basenames = (file.stem for file in files)
basenames_str = ",".join(f"{len(b)}:{b}" for b in basenames)
# We use hashes to prevent too-long filenames, see issue #123:
# https://github.com/jeertmans/manim-slides/issues/123
basename = hashlib.sha256(basenames_str.encode()).hexdigest()
return dirname.joinpath(basename + ext)
class Signal(BaseModel): # type: ignore[misc]
__receivers: List[Receiver] = PrivateAttr(default_factory=list)
def connect(self, receiver: Receiver) -> None:
self.__receivers.append(receiver)
def disconnect(self, receiver: Receiver) -> None:
self.__receivers.remove(receiver)
def emit(self, *args: Any) -> None:
for receiver in self.__receivers:
receiver(*args)
class Key(BaseModel): # type: ignore[misc]
class Key(BaseModel): # type: ignore
"""Represents a list of key codes, with optionally a name."""
ids: List[PositiveInt] = Field(unique=True)
ids: Set[int]
name: Optional[str] = None
__signal: Signal = PrivateAttr(default_factory=Signal)
@field_validator("ids")
@classmethod
def ids_is_non_empty_set(cls, ids: Set[Any]) -> Set[Any]:
if len(ids) <= 0:
raise ValueError("Key's ids must be a non-empty set")
return ids
def set_ids(self, *ids: int) -> None:
self.ids = list(set(ids))
self.ids = set(ids)
@validator("ids", each_item=True)
def id_is_posint(cls, v: int) -> int:
if v < 0:
raise ValueError("Key ids cannot be negative integers")
return v
def match(self, key_id: int) -> bool:
m = key_id in self.ids
@ -61,96 +58,71 @@ class Key(BaseModel): # type: ignore[misc]
return m
@property
def signal(self) -> Signal:
return self.__signal
def connect(self, function: Receiver) -> None:
self.__signal.connect(function)
class Config(BaseModel): # type: ignore
"""General Manim Slides config"""
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")
CONTINUE: Key = Key(ids=[Qt.Key_Right], name="CONTINUE / NEXT")
BACK: Key = Key(ids=[Qt.Key_Left], name="BACK")
REVERSE: Key = Key(ids=[Qt.Key_V], name="REVERSE")
REPLAY: Key = Key(ids=[Qt.Key_R], name="REPLAY")
FULL_SCREEN: Key = Key(ids=[Qt.Key_F], name="TOGGLE FULL SCREEN")
REWIND: Key = Key(ids=[Qt.Key_R], name="REWIND")
PLAY_PAUSE: Key = Key(ids=[Qt.Key_Space], name="PLAY / PAUSE")
HIDE_MOUSE: Key = Key(ids=[Qt.Key_H], name="HIDE / SHOW MOUSE")
@model_validator(mode="before")
@root_validator
def ids_are_unique_across_keys(cls, values: Dict[str, Key]) -> Dict[str, Key]:
ids: Set[int] = set()
for key in values.values():
if len(ids.intersection(key["ids"])) != 0:
if len(ids.intersection(key.ids)) != 0:
raise ValueError(
"Two or more keys share a common key code: please make sure each key has distinct key codes"
)
ids.update(key["ids"])
ids.update(key.ids)
return values
def merge_with(self, other: "Keys") -> "Keys":
def merge_with(self, other: "Config") -> "Config":
for key_name, key in self:
other_key = getattr(other, key_name)
key.ids = list(set(key.ids).union(other_key.ids))
key.ids.update(other_key.ids)
key.name = other_key.name or key.name
return self
def dispatch_key_function(self) -> Callable[[PositiveInt], None]:
_dispatch = {}
for _, key in self:
for _id in key.ids:
_dispatch[_id] = key.signal
def dispatch(key: PositiveInt) -> None:
if signal := _dispatch.get(key, None):
signal.emit()
return dispatch
class SlideType(str, Enum):
slide = "slide"
loop = "loop"
last = "last"
class Config(BaseModel): # type: ignore[misc]
"""General Manim Slides config"""
keys: Keys = Keys()
@classmethod
def from_file(cls, path: Path) -> "Config":
"""Reads a configuration from a file."""
return cls.model_validate(rtoml.load(path)) # type: ignore
def to_file(self, path: Path) -> None:
"""Dumps the configuration to a file."""
rtoml.dump(self.model_dump(), path, pretty=True)
def merge_with(self, other: "Config") -> "Config":
self.keys = self.keys.merge_with(other.keys)
return self
class PreSlideConfig(BaseModel): # type: ignore
class SlideConfig(BaseModel): # type: ignore
type: SlideType
start_animation: int
end_animation: int
loop: bool = False
number: int
terminated: bool = False
@field_validator("start_animation", "end_animation")
@classmethod
@validator("start_animation", "end_animation")
def index_is_posint(cls, v: int) -> int:
if v < 0:
raise ValueError("Animation index (start or end) cannot be negative")
return v
@model_validator(mode="after")
@validator("number")
def number_is_strictly_posint(cls, v: int) -> int:
if v <= 0:
raise ValueError("Slide number cannot be negative or zero")
return v
@root_validator
def start_animation_is_before_end(
cls, pre_slide_config: "PreSlideConfig"
) -> "PreSlideConfig":
if pre_slide_config.start_animation >= pre_slide_config.end_animation:
if pre_slide_config.start_animation == pre_slide_config.end_animation == 0:
cls, values: Dict[str, Union[SlideType, int, bool]]
) -> Dict[str, Union[SlideType, int, bool]]:
if values["start_animation"] >= values["end_animation"]: # type: ignore
if values["start_animation"] == values["end_animation"] == 0:
raise ValueError(
"You have to play at least one animation (e.g., `self.wait()`) before pausing. If you want to start paused, use the approriate command-line option when presenting. IMPORTANT: when using ManimGL, `self.wait()` is not considered to be an animation, so prefer to directly use `self.play(...)`."
)
@ -159,72 +131,129 @@ class PreSlideConfig(BaseModel): # type: ignore
"Start animation index must be strictly lower than end animation index"
)
return pre_slide_config
return values
def is_slide(self) -> bool:
return self.type == SlideType.slide
def is_loop(self) -> bool:
return self.type == SlideType.loop
def is_last(self) -> bool:
return self.type == SlideType.last
@property
def slides_slice(self) -> slice:
return slice(self.start_animation, self.end_animation)
class SlideConfig(BaseModel): # type: ignore[misc]
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)
class PresentationConfig(BaseModel): # type: ignore[misc]
slides: List[SlideConfig] = Field(min_length=1)
class PresentationConfig(BaseModel): # type: ignore
slides: List[SlideConfig]
files: List[FilePath]
resolution: Tuple[PositiveInt, PositiveInt] = (1920, 1080)
background_color: Color = "black"
@classmethod
def from_file(cls, path: Path) -> "PresentationConfig":
"""Reads a presentation configuration from a file."""
with open(path, "r") as f:
obj = json.load(f)
@root_validator
def animation_indices_match_files(
cls, values: Dict[str, Union[List[SlideConfig], List[FilePath]]]
) -> Dict[str, Union[List[SlideConfig], List[FilePath]]]:
files: List[FilePath] = values.get("files") # type: ignore
slides: List[SlideConfig] = values.get("slides") # type: ignore
slides = obj.setdefault("slides", [])
parent = path.parent.parent # Never fails, but parents[1] can fail
if files is None or slides is None:
return values
for slide in slides:
if file := slide.get("file", None):
slide["file"] = parent / file
n_files = len(files)
if rev_file := slide.get("rev_file", None):
slide["rev_file"] = parent / rev_file
for slide in slides:
if slide.end_animation > n_files:
raise ValueError(
f"The following slide's contains animations not listed in files {files}: {slide}"
)
return cls.model_validate(obj) # type: ignore
return values
def to_file(self, path: Path) -> None:
"""Dumps the presentation configuration to a file."""
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, dest: Path, use_cached: bool = True) -> "PresentationConfig":
"""
Copy the files to a given directory.
"""
for slide_config in self.slides:
file = slide_config.file
rev_file = slide_config.rev_file
n = len(self.files)
for i in range(n):
file = self.files[i]
dest_path = dest / self.files[i].name
self.files[i] = dest_path
if use_cached and dest_path.exists():
logger.debug(f"Skipping copy of {file}, using cached copy")
continue
logger.debug(f"Copying {file} to {dest_path}")
shutil.copy(file, dest_path)
dest = folder / file.name
rev_dest = folder / rev_file.name
return self
slide_config.file = dest
slide_config.rev_file = rev_dest
def concat_animations(
self, dest: Optional[Path] = None, use_cached: bool = True
) -> "PresentationConfig":
"""
Concatenate animations such that each slide contains one animation.
"""
if not use_cached or not dest.exists():
shutil.copy(file, dest)
dest_paths = []
if not use_cached or not rev_dest.exists():
shutil.copy(rev_file, rev_dest)
for i, slide_config in enumerate(self.slides):
files = self.files[slide_config.slides_slice]
slide_config.start_animation = i
slide_config.end_animation = i + 1
if len(files) > 1:
dest_path = merge_basenames(files)
dest_paths.append(dest_path)
if use_cached and dest_path.exists():
logger.debug(f"Concatenated animations already exist for slide {i}")
continue
f = tempfile.NamedTemporaryFile(mode="w", delete=False)
f.writelines(f"file '{os.path.abspath(path)}'\n" for path in files)
f.close()
command: List[str] = [
FFMPEG_BIN,
"-f",
"concat",
"-safe",
"0",
"-i",
f.name,
"-c",
"copy",
str(dest_path),
"-y",
]
logger.debug(" ".join(command))
process = subprocess.Popen(
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
output, error = process.communicate()
if output:
logger.debug(output.decode())
if error:
logger.debug(error.decode())
if not dest_path.exists():
raise ValueError(
"could not properly concatenate animations, use `-v INFO` for more details"
)
else:
dest_paths.append(files[0])
self.files = dest_paths
if dest:
return self.copy_to(dest)
return self

View File

@ -1,11 +1,9 @@
import mimetypes
import os
import platform
import subprocess
import sys
import tempfile
import webbrowser
from base64 import b64encode
from enum import Enum
from importlib import resources
from pathlib import Path
@ -16,17 +14,7 @@ import cv2
import pptx
from click import Context, Parameter
from lxml import etree
from PIL import Image
from pydantic import (
BaseModel,
ConfigDict,
FilePath,
GetCoreSchemaHandler,
PositiveFloat,
PositiveInt,
ValidationError,
)
from pydantic_core import CoreSchema, core_schema
from pydantic import BaseModel, FilePath, PositiveInt, ValidationError
from tqdm import tqdm
from . import data
@ -35,29 +23,6 @@ from .config import PresentationConfig
from .logger import logger
from .present import get_scenes_presentation_config
DATA_URI_FIX = r"""
// Fix found by @t-fritsch on GitHub
// see: https://github.com/hakimel/reveal.js/discussions/3362#discussioncomment-6651475.
function fixBase64VideoBackground(event) {
// event.previousSlide, event.currentSlide, event.indexh, event.indexv
if (event.currentSlide.getAttribute('data-background-video')) {
const background = Reveal.getSlideBackground(event.indexh, event.indexv),
video = background.querySelector('video'),
sources = video.querySelectorAll('source');
sources.forEach((source, i) => {
const src = source.getAttribute('src');
if(src.match(/^data:video.*;base64$/)){
const nextSrc = sources[i+1]?.getAttribute('src');
video.setAttribute('src', `${src},${nextSrc}`);
}
});
}
}
Reveal.on( 'ready', fixBase64VideoBackground );
Reveal.on( 'slidechanged', fixBase64VideoBackground );
"""
def open_with_default(file: Path) -> None:
system = platform.system()
@ -86,16 +51,6 @@ def validate_config_option(
return config
def data_uri(file: Path) -> str:
"""
Reads a video and returns the corresponding data-uri.
"""
b64 = b64encode(file.read_bytes()).decode("ascii")
mime_type = mimetypes.guess_type(file)[0] or "video/mp4"
return f"data:{mime_type};base64,{b64}"
class Converter(BaseModel): # type: ignore
presentation_configs: List[PresentationConfig] = []
assets_dir: str = "{basename}_assets"
@ -120,7 +75,6 @@ class Converter(BaseModel): # type: ignore
"""Returns the appropriate converter from a string name."""
return {
"html": RevealJS,
"pdf": PDF,
"pptx": PowerPoint,
}[s]
@ -131,12 +85,6 @@ class Str(str):
# This fixes pickling issue on Python 3.8
__reduce_ex__ = str.__reduce_ex__
@classmethod
def __get_pydantic_core_schema__(
cls, source_type: Any, handler: GetCoreSchemaHandler
) -> CoreSchema:
return core_schema.str_schema()
def __str__(self) -> str:
"""Ensures that the string is correctly quoted."""
if self in ["true", "false", "null"]:
@ -273,8 +221,6 @@ class RevealTheme(str, Enum):
class RevealJS(Converter):
# Export option: use data-uri
data_uri: bool = False
# Presentation size options from RevealJS
width: Union[Str, int] = Str("100%")
height: Union[Str, int] = Str("100%")
@ -356,28 +302,27 @@ class RevealJS(Converter):
reveal_version: str = "4.4.0"
reveal_theme: RevealTheme = RevealTheme.black
title: str = "Manim Slides"
model_config = ConfigDict(use_enum_values=True, extra="forbid")
class Config:
use_enum_values = True
extra = "forbid"
def get_sections_iter(self, assets_dir: Path) -> Generator[str, None, None]:
"""Generates a sequence of sections, one per slide, that will be included into the html template."""
for presentation_config in self.presentation_configs:
for slide_config in presentation_config.slides:
file = slide_config.file
file = presentation_config.files[slide_config.start_animation]
file = assets_dir / file.name
logger.debug(f"Writing video section with file {file}")
if self.data_uri:
file = data_uri(file)
else:
file = assets_dir / file.name
# TODO: document this
# Videos are muted because, otherwise, the first slide never plays correctly.
# This is due to a restriction in playing audio without the user doing anything.
# Later, this might be useful to only mute the first video, or to make it optional.
# Read more about this:
# https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide#autoplay_and_autoplay_blocking
if slide_config.loop:
if slide_config.is_loop():
yield f'<section data-background-size={self.background_size.value} data-background-color="{presentation_config.background_color}" data-background-video="{file}" data-background-video-muted data-background-video-loop></section>'
else:
yield f'<section data-background-size={self.background_size.value} data-background-color="{presentation_config.background_color}" data-background-video="{file}" data-background-video-muted></section>'
@ -397,94 +342,31 @@ class RevealJS(Converter):
def convert_to(self, dest: Path) -> None:
"""Converts this configuration into a RevealJS HTML presentation, saved to DEST."""
if self.data_uri:
assets_dir = Path("") # Actually we won't care.
else:
dirname = dest.parent
basename = dest.stem
ext = dest.suffix
dirname = dest.parent
basename = dest.stem
ext = dest.suffix
assets_dir = Path(
self.assets_dir.format(dirname=dirname, basename=basename, ext=ext)
)
full_assets_dir = dirname / assets_dir
assets_dir = Path(
self.assets_dir.format(dirname=dirname, basename=basename, ext=ext)
)
full_assets_dir = dirname / assets_dir
logger.debug(f"Assets will be saved to: {full_assets_dir}")
logger.debug(f"Assets will be saved to: {full_assets_dir}")
full_assets_dir.mkdir(parents=True, exist_ok=True)
os.makedirs(full_assets_dir, exist_ok=True)
for presentation_config in self.presentation_configs:
presentation_config.copy_to(full_assets_dir)
for presentation_config in self.presentation_configs:
presentation_config.concat_animations().copy_to(full_assets_dir)
with open(dest, "w") as f:
sections = "".join(self.get_sections_iter(assets_dir))
revealjs_template = self.load_template()
if self.data_uri:
data_uri_fix = DATA_URI_FIX
else:
data_uri_fix = ""
content = revealjs_template.format(
sections=sections, data_uri_fix=data_uri_fix, **self.dict()
)
content = revealjs_template.format(sections=sections, **self.dict())
f.write(content)
class FrameIndex(str, Enum):
first = "first"
last = "last"
class PDF(Converter):
frame_index: FrameIndex = FrameIndex.last
resolution: PositiveFloat = 100.0
model_config = ConfigDict(use_enum_values=True, extra="forbid")
def open(self, file: Path) -> None:
return open_with_default(file)
def convert_to(self, dest: Path) -> None:
"""Converts this configuration into a 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()
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):
for slide_config in tqdm(
presentation_config.slides,
desc=f"Generating video slides for config {i + 1}",
leave=False,
):
images.append(
read_image_from_video_file(slide_config.file, self.frame_index)
)
images[0].save(
dest,
"PDF",
resolution=self.resolution,
save_all=True,
append_images=images[1:],
)
class PowerPoint(Converter):
left: PositiveInt = 0
top: PositiveInt = 0
@ -492,7 +374,10 @@ class PowerPoint(Converter):
height: PositiveInt = 720
auto_play_media: bool = True
poster_frame_image: Optional[FilePath] = None
model_config = ConfigDict(use_enum_values=True, extra="forbid")
class Config:
use_enum_values = True
extra = "forbid"
def open(self, file: Path) -> None:
return open_with_default(file)
@ -527,7 +412,7 @@ class PowerPoint(Converter):
return etree.ElementBase.xpath(el, query, namespaces=nsmap)
def save_first_image_from_video_file(file: Path) -> Optional[str]:
cap = cv2.VideoCapture(file.as_posix())
cap = cv2.VideoCapture(str(file))
ret, frame = cap.read()
if ret:
@ -539,14 +424,13 @@ class PowerPoint(Converter):
return None
for i, presentation_config in enumerate(self.presentation_configs):
presentation_config.concat_animations()
for slide_config in tqdm(
presentation_config.slides,
desc=f"Generating video slides for config {i + 1}",
leave=False,
):
file = slide_config.file
mime_type = mimetypes.guess_type(file)[0]
file = presentation_config.files[slide_config.start_animation]
if self.poster_frame_image is None:
poster_frame_image = save_first_image_from_video_file(file)
@ -561,7 +445,7 @@ class PowerPoint(Converter):
self.width * 9525,
self.height * 9525,
poster_frame_image=poster_frame_image,
mime_type=mime_type,
mime_type="video/mp4",
)
if self.auto_play_media:
auto_play_media(movie, loop=slide_config.is_loop())
@ -629,7 +513,7 @@ 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),
type=click.Choice(["html", "pptx"], case_sensitive=False),
default="html",
show_default=True,
help="Set the conversion format to use.",

View File

@ -30,260 +30,261 @@
<!-- <script src="index.js"></script> -->
<script>
Reveal.initialize({{
// The "normal" size of the presentation, aspect ratio will
// be preserved when the presentation is scaled to fit different
// resolutions. Can be specified using percentage units.
width: {width},
height: {height},
Reveal.initialize({{
// Factor of the display size that should remain empty around
// the content
margin: {margin},
// 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},
// Bounds for smallest/largest possible scale to apply to content
minScale: {min_scale},
maxScale: {max_scale},
// Factor of the display size that should remain empty around
// the content
margin: {margin},
// Display presentation control arrows
controls: {controls},
// Bounds for smallest/largest possible scale to apply to content
minScale: {min_scale},
maxScale: {max_scale},
// 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},
// Display presentation control arrows
controls: {controls},
// Determines where controls appear, "edges" or "bottom-right"
controlsLayout: {controls_layout},
// 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},
// Visibility rule for backwards navigation arrows; "faded", "hidden"
// or "visible"
controlsBackArrows: {controls_back_arrows},
// Determines where controls appear, "edges" or "bottom-right"
controlsLayout: {controls_layout},
// Display a presentation progress bar
progress: {progress},
// Visibility rule for backwards navigation arrows; "faded", "hidden"
// or "visible"
controlsBackArrows: {controls_back_arrows},
// 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},
// Display a presentation progress bar
progress: {progress},
// 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},
// 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},
// Use 1 based indexing for # links to match slide number (default is zero
// based)
hashOneBasedIndex: {hash_one_based_index},
// 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},
// 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},
// Use 1 based indexing for # links to match slide number (default is zero
// based)
hashOneBasedIndex: {hash_one_based_index},
// Flags if we should monitor the hash and change slides accordingly
respondToHashChanges: {respond_to_hash_changes},
// 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},
// Push each slide change to the browser history. Implies `hash: true`
history: {history},
// Flags if we should monitor the hash and change slides accordingly
respondToHashChanges: {respond_to_hash_changes},
// Enable keyboard shortcuts for navigation
keyboard: {keyboard},
// Push each slide change to the browser history. Implies `hash: true`
history: {history},
// 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},
// Enable keyboard shortcuts for navigation
keyboard: {keyboard},
// Disables the default reveal.js slide layout (scaling and centering)
// so that you can use custom CSS layout
disableLayout: {disable_layout},
// 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},
// Enable the slide overview mode
overview: {overview},
// Disables the default reveal.js slide layout (scaling and centering)
// so that you can use custom CSS layout
disableLayout: {disable_layout},
// Vertical centering of slides
center: {center},
// Enable the slide overview mode
overview: {overview},
// Enables touch navigation on devices with touch input
touch: {touch},
// Vertical centering of slides
center: {center},
// Loop the presentation
loop: {loop},
// Enables touch navigation on devices with touch input
touch: {touch},
// Change the presentation direction to be RTL
rtl: {rtl},
// Loop the presentation
loop: {loop},
// 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},
// Change the presentation direction to be RTL
rtl: {rtl},
// Randomizes the order of slides each time the presentation loads
shuffle: {shuffle},
// 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},
// Turns fragments on and off globally
fragments: {fragments},
// Randomizes the order of slides each time the presentation loads
shuffle: {shuffle},
// Flags whether to include the current fragment in the URL,
// so that reloading brings you to the same fragment position
fragmentInURL: {fragment_in_url},
// Turns fragments on and off globally
fragments: {fragments},
// Flags if the presentation is running in an embedded mode,
// i.e. contained within a limited portion of the screen
embedded: {embedded},
// Flags 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 we should show a help overlay when the question-mark
// key is pressed
help: {help},
// Flags if the presentation is running in an embedded mode,
// i.e. contained within a limited portion of the screen
embedded: {embedded},
// Flags if it should be possible to pause the presentation (blackout)
pause: {pause},
// Flags if we should show a help overlay when the question-mark
// key is pressed
help: {help},
// Flags if speaker notes should be visible to all viewers
showNotes: {show_notes},
// Flags if it should be possible to pause the presentation (blackout)
pause: {pause},
// 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},
// Flags if speaker notes should be visible to all viewers
showNotes: {show_notes},
// 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},
// 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},
// Can be used to globally disable auto-animation
autoAnimate: {auto_animate},
// 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},
// Optionally provide a custom element matcher that will be
// used to dictate which elements we can animate between.
autoAnimateMatcher: {auto_animate_matcher},
// Can be used to globally disable auto-animation
autoAnimate: {auto_animate},
// 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},
// Optionally provide a custom element matcher that will be
// used to dictate which elements we can animate between.
autoAnimateMatcher: {auto_animate_matcher},
// 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},
// 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},
// 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},
// 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},
// Stop auto-sliding after user input
autoSlideStoppable: {auto_slide_stoppable},
// 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},
// Use this method for navigation when auto-sliding (defaults to navigateNext)
autoSlideMethod: {auto_slide_method},
// Stop auto-sliding after user input
autoSlideStoppable: {auto_slide_stoppable},
// 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},
// Use this method for navigation when auto-sliding (defaults to navigateNext)
autoSlideMethod: {auto_slide_method},
// Enable slide navigation via mouse wheel
mouseWheel: {mouse_wheel},
// 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},
// Opens links in an iframe preview overlay
// Add `data-preview-link` and `data-preview-link="false"` to customise each link
// individually
previewLinks: {preview_links},
// Enable slide navigation via mouse wheel
mouseWheel: {mouse_wheel},
// Exposes the reveal.js API through window.postMessage
postMessage: {post_message},
// Opens links in an iframe preview overlay
// Add `data-preview-link` and `data-preview-link="false"` to customise each link
// individually
previewLinks: {preview_links},
// Dispatches all reveal.js events to the parent window through postMessage
postMessageEvents: {post_message_events},
// Exposes the reveal.js API through window.postMessage
postMessage: {post_message},
// Focuses body when page changes visibility to ensure keyboard shortcuts work
focusBodyOnPageVisibilityChange: {focus_body_on_page_visibility_change},
// Dispatches all reveal.js events to the parent window through postMessage
postMessageEvents: {post_message_events},
// Transition style
transition: {transition}, // none/fade/slide/convex/concave/zoom
// Focuses body when page changes visibility to ensure keyboard shortcuts work
focusBodyOnPageVisibilityChange: {focus_body_on_page_visibility_change},
// Transition speed
transitionSpeed: {transition_speed}, // default/fast/slow
// Transition style
transition: {transition}, // none/fade/slide/convex/concave/zoom
// Transition style for full page slide backgrounds
backgroundTransition: {background_transition}, // none/fade/slide/convex/concave/zoom
// Transition speed
transitionSpeed: {transition_speed}, // default/fast/slow
// The maximum number of pages a single slide can expand onto when printing
// to PDF, unlimited by default
pdfMaxPagesPerSlide: {pdf_max_pages_per_slide},
// Transition style for full page slide backgrounds
backgroundTransition: {background_transition}, // none/fade/slide/convex/concave/zoom
// Prints each fragment on a separate slide
pdfSeparateFragments: {pdf_separate_fragments},
// The maximum number of pages a single slide can expand onto when printing
// to PDF, unlimited by default
pdfMaxPagesPerSlide: {pdf_max_pages_per_slide},
// 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},
// Prints each fragment on a separate slide
pdfSeparateFragments: {pdf_separate_fragments},
// Number of slides away from the current that are visible
viewDistance: {view_distance},
// 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 on mobile
// devices. It is advisable to set this to a lower number than
// viewDistance in order to save resources.
mobileViewDistance: {mobile_view_distance},
// Number of slides away from the current that are visible
viewDistance: {view_distance},
// The display mode that will be used to show slides
display: {display},
// 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},
// Hide cursor if inactive
hideInactiveCursor: {hide_inactive_cursor},
// The display mode that will be used to show slides
display: {display},
// Time before the cursor is hidden (in ms)
hideCursorTime: {hide_cursor_time}
}});
// Hide cursor if inactive
hideInactiveCursor: {hide_inactive_cursor},
{data_uri_fix}
// Time before the cursor is hidden (in ms)
hideCursorTime: {hide_cursor_time}
}});
</script>
</body>

View File

@ -1,5 +1,3 @@
from pathlib import Path
FOLDER_PATH: Path = Path("./slides")
CONFIG_PATH: Path = Path(".manim-slides.toml")
FFMPEG_BIN: Path = Path("ffmpeg")
FOLDER_PATH: str = "./slides"
CONFIG_PATH: str = ".manim-slides.json"
FFMPEG_BIN: str = "ffmpeg"

View File

@ -1,426 +0,0 @@
# type: ignore
r"""
A directive for including Manim slides in a Sphinx document
===========================================================
.. warning::
This Sphinx extension requires Manim to be installed,
and won't probably work on ManimGL examples.
.. note::
The current implementation is highly inspired from Manim's own
sphinx directive, from v0.17.3.
When rendering the HTML documentation, the ``.. manim-slides::``
directive implemented here allows to include rendered videos.
This directive requires three additional dependencies:
``manim``, ``docutils`` and ``jinja2``. The last two are usually bundled
with Sphinx.
You can install them manually, or with the extra keyword:
pip install manim-slides[sphinx-directive]
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>`_.
Usage
-----
First, you must include the directive in the Sphinx configuration file:
.. code-block:: python
:caption: Sphinx configuration file (usually :code:`docs/source/conf.py`).
:emphasize-lines: 3
extensions = [
# ...
"manim_slides.docs.manim_slides_directive",
]
Its basic usage that allows processing **inline content**
looks as follows::
.. manim-slides:: MySlide
from manim import *
from manim_slides import Slide
class MySlide(Slide):
def construct(self):
...
It is required to pass the name of the class representing the
scene to be rendered to the directive.
As a second application, the directive can also be used to
render scenes that are defined within doctests, for example::
.. manim-slides:: DirectiveDoctestExample
:ref_classes: Dot
>>> from manim import Create, Dot, RED
>>> from manim_slides import Slide
>>> dot = Dot(color=RED)
>>> dot.color
<Color #fc6255>
>>> class DirectiveDoctestExample(Slide):
... def construct(self):
... self.play(Create(dot))
Options
-------
Options can be passed as follows::
.. manim-slides:: <Class name>
:<option name>: <value>
The following configuration options are supported by the
directive:
hide_source
If this flag is present without argument,
the source code is not displayed above the rendered video.
quality : {'low', 'medium', 'high', 'fourk'}
Controls render quality of the video, in analogy to
the corresponding command line flags.
ref_classes
A list of classes, separated by spaces, that is
rendered in a reference block after the source code.
ref_functions
A list of functions, separated by spaces,
that is rendered in a reference block after the source code.
ref_methods
A list of methods, separated by spaces,
that is rendered in a reference block after the source code.
"""
from __future__ import annotations
import csv
import itertools as it
import re
import sys
from pathlib import Path
from timeit import timeit
import jinja2
from docutils import nodes
from docutils.parsers.rst import Directive, directives
from docutils.statemachine import StringList
from manim import QUALITIES
from ..convert import RevealJS
from ..present import get_scenes_presentation_config
classnamedict = {}
class SkipManimNode(nodes.Admonition, nodes.Element):
"""Auxiliary node class that is used when the ``skip-manim-slides`` tag is
present or ``.pot`` files are being built.
Skips rendering the manim-slides directive and outputs a placeholder instead.
"""
pass
def visit(self, node, name=""):
self.visit_admonition(node, name)
if not isinstance(node[0], nodes.title):
node.insert(0, nodes.title("skip-manim-slides", "Example Placeholder"))
def depart(self, node):
self.depart_admonition(node)
def process_name_list(option_input: str, reference_type: str) -> list[str]:
r"""Reformats a string of space separated class names
as a list of strings containing valid Sphinx references.
Tests
-----
::
>>> process_name_list("Tex TexTemplate", "class")
[':class:`~.Tex`', ':class:`~.TexTemplate`']
>>> process_name_list("Scene.play Mobject.rotate", "func")
[':func:`~.Scene.play`', ':func:`~.Mobject.rotate`']
"""
return [f":{reference_type}:`~.{name}`" for name in option_input.split()]
class ManimSlidesDirective(Directive):
r"""The manim-slides directive, rendering videos while building
the documentation.
See the module docstring for documentation.
"""
has_content = True
required_arguments = 1
optional_arguments = 0
option_spec = {
"hide_source": bool,
"quality": lambda arg: directives.choice(
arg,
("low", "medium", "high", "fourk"),
),
"ref_modules": lambda arg: process_name_list(arg, "mod"),
"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"),
}
final_argument_whitespace = True
def run(self):
# Rendering is skipped if the tag skip-manim is present,
# or if we are making the pot-files
should_skip = (
"skip-manim-slides"
in self.state.document.settings.env.app.builder.tags.tags
or self.state.document.settings.env.app.builder.name == "gettext"
)
if should_skip:
node = SkipManimNode()
self.state.nested_parse(
StringList(
[
f"Placeholder block for ``{self.arguments[0]}``.",
"",
".. code-block:: python",
"",
]
+ [" " + line for line in self.content]
),
self.content_offset,
node,
)
return [node]
from manim import config, tempconfig
global classnamedict
clsname = self.arguments[0]
if clsname not in classnamedict:
classnamedict[clsname] = 1
else:
classnamedict[clsname] += 1
hide_source = "hide_source" in self.options
ref_content = (
self.options.get("ref_modules", [])
+ self.options.get("ref_classes", [])
+ self.options.get("ref_functions", [])
+ self.options.get("ref_methods", [])
)
if ref_content:
ref_block = "References: " + " ".join(ref_content)
else:
ref_block = ""
if "quality" in self.options:
quality = f'{self.options["quality"]}_quality'
else:
quality = "example_quality"
frame_rate = QUALITIES[quality]["frame_rate"]
pixel_height = QUALITIES[quality]["pixel_height"]
pixel_width = QUALITIES[quality]["pixel_width"]
state_machine = self.state_machine
document = state_machine.document
source_file_name = Path(document.attributes["source"])
source_rel_name = source_file_name.relative_to(setup.confdir)
source_rel_dir = source_rel_name.parents[0]
dest_dir = Path(setup.app.builder.outdir, source_rel_dir).absolute()
if not dest_dir.exists():
dest_dir.mkdir(parents=True, exist_ok=True)
source_block = [
".. code-block:: python",
"",
*(" " + line for line in self.content),
]
source_block = "\n".join(source_block)
config.media_dir = (Path(setup.confdir) / "media").absolute()
config.images_dir = "{media_dir}/images"
config.video_dir = "{media_dir}/videos/{quality}"
output_file = f"{clsname}-{classnamedict[clsname]}"
config.assets_dir = Path("_static")
config.progress_bar = "none"
config.verbosity = "WARNING"
example_config = {
"frame_rate": frame_rate,
"pixel_height": pixel_height,
"pixel_width": pixel_width,
"output_file": output_file,
}
user_code = self.content
if user_code[0].startswith(">>> "): # check whether block comes from doctest
user_code = [
line[4:] for line in user_code if line.startswith((">>> ", "... "))
]
code = [
"from manim import *",
*user_code,
f"{clsname}().render()",
]
try:
with tempconfig(example_config):
run_time = timeit(lambda: exec("\n".join(code), globals()), number=1)
video_dir = config.get_dir("video_dir")
except Exception as e:
raise RuntimeError(f"Error while rendering example {clsname}") from e
_write_rendering_stats(
clsname,
run_time,
self.state.document.settings.env.docname,
)
# copy video file to output directory
filename = f"{output_file}.html"
filesrc = video_dir / filename
destfile = Path(dest_dir, filename)
presentation_configs = get_scenes_presentation_config(
[clsname], Path("./slides")
)
RevealJS(presentation_configs=presentation_configs, controls="true").convert_to(
destfile
)
# shutil.copyfile(filesrc, destfile)
print("CLASS NAME:", clsname)
rendered_template = jinja2.Template(TEMPLATE).render(
clsname=clsname,
clsname_lowercase=clsname.lower(),
hide_source=hide_source,
filesrc_rel=Path(filesrc).relative_to(setup.confdir).as_posix(),
output_file=output_file,
source_block=source_block,
ref_block=ref_block,
)
state_machine.insert_input(
rendered_template.split("\n"),
source=document.attributes["source"],
)
return []
rendering_times_file_path = Path("../rendering_times.csv")
def _write_rendering_stats(scene_name, run_time, file_name):
with rendering_times_file_path.open("a") as file:
csv.writer(file).writerow(
[
re.sub(r"^(reference\/)|(manim\.)", "", file_name),
scene_name,
"%.3f" % run_time,
],
)
def _log_rendering_times(*args):
if rendering_times_file_path.exists():
with rendering_times_file_path.open() as file:
data = list(csv.reader(file))
if len(data) == 0:
sys.exit()
print("\nRendering Summary\n-----------------\n")
max_file_length = max(len(row[0]) for row in data)
for key, group in it.groupby(data, key=lambda row: row[0]):
key = key.ljust(max_file_length + 1, ".")
group = list(group)
if len(group) == 1:
row = group[0]
print(f"{key}{row[2].rjust(7, '.')}s {row[1]}")
continue
time_sum = sum(float(row[2]) for row in group)
print(
f"{key}{f'{time_sum:.3f}'.rjust(7, '.')}s => {len(group)} EXAMPLES",
)
for row in group:
print(f"{' '*(max_file_length)} {row[2].rjust(7)}s {row[1]}")
print("")
def _delete_rendering_times(*args):
if rendering_times_file_path.exists():
rendering_times_file_path.unlink()
def setup(app):
app.add_node(SkipManimNode, html=(visit, depart))
setup.app = app
setup.config = app.config
setup.confdir = app.confdir
app.add_directive("manim-slides", ManimSlidesDirective)
app.connect("builder-inited", _delete_rendering_times)
app.connect("build-finished", _log_rendering_times)
metadata = {"parallel_read_safe": False, "parallel_write_safe": True}
return metadata
TEMPLATE = r"""
{% if not hide_source %}
.. raw:: html
<div id="{{ clsname_lowercase }}" class="admonition admonition-manim-example">
<p class="admonition-title">Example: {{ clsname }} <a class="headerlink" href="#{{ clsname_lowercase }}">¶</a></p>
{% endif %}
.. raw:: 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="./{{ output_file }}.html">
</iframe>
</div>
{% if not hide_source %}
{{ source_block }}
{{ ref_block }}
.. raw:: html
</div>
{% endif %}
"""

View File

@ -1,265 +0,0 @@
"""
Utilities for using Manim Slides with IPython (in particular: Jupyter notebooks).
=================================================================================
.. toctree::
:hidden:
magic_example
.. note::
The current implementation is highly inspired from Manim's own
IPython magics, from v0.17.3.
This magic requires two additional dependencies: ``manim`` and ``IPython``.
You can install them manually, or with the extra keyword:
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>`_.
"""
from __future__ import annotations
import logging
import mimetypes
import shutil
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
from IPython import get_ipython
from IPython.core.interactiveshell import InteractiveShell
from IPython.core.magic import Magics, line_cell_magic, magics_class, needs_local_scope
from IPython.display import HTML, display
from manim import config, logger, tempconfig
from manim.__main__ import main
from manim.constants import RendererType
from manim.renderer.shader import shader_program_cache
from ..convert import RevealJS
from ..present import get_scenes_presentation_config
@magics_class
class ManimSlidesMagic(Magics): # type: ignore
def __init__(self, shell: InteractiveShell) -> None:
super().__init__(shell)
self.rendered_files: Dict[Path, Path] = {}
@needs_local_scope
@line_cell_magic
def manim_slides(
self,
line: str,
cell: Optional[str] = None,
local_ns: Dict[str, Any] = {},
) -> None:
r"""Render Manim Slides contained in IPython cells.
Works as a line or cell magic.
.. note::
This magic works pretty much like the one from Manim, except that it
will render Manim Slides using RevealJS. For passing arguments to
Manim Slides' convert module, use ``-manim-slides key=value``.
Everything that is after ``--manim-slides`` will be send to
Manim Slides' command. E.g., use ``--manim-slides controls=true``
to display control buttons.
.. hint::
This line and cell magic works best when used in a JupyterLab
environment: while all of the functionality is available for
classic Jupyter notebooks as well, it is possible that videos
sometimes don't update on repeated execution of the same cell
if the scene name stays the same.
This problem does not occur when using JupyterLab.
Please refer to `<https://jupyter.org/>`_ for more information about JupyterLab
and Jupyter notebooks.
Usage in line mode::
%manim_slides [CLI options] MyAwesomeSlide
Usage in cell mode::
%%manim_slides [CLI options] MyAwesomeSlide
class MyAweseomeSlide(Slide):
def construct(self):
...
Run ``%manim_slides --help`` and ``%manim_slides render --help``
for possible command line interface options.
.. note::
The maximal width of the rendered videos that are displayed in the notebook can be
configured via the ``media_width`` configuration option. The default is set to ``25vw``,
which is 25% of your current viewport width. To allow the output to become as large
as possible, set ``config.media_width = "100%"``.
The ``media_embed`` option will embed the image/video output in the notebook. This is
generally undesirable as it makes the notebooks very large, but is required on some
platforms (notably Google's CoLab, where it is automatically enabled unless suppressed
by ``config.embed = False``) and needed in cases when the notebook (or converted HTML
file) will be moved relative to the video locations. Use-cases include building
documentation with Sphinx and JupyterBook. See also the
:mod:`Manim Slides directive for Sphinx
<manim_slides.docs.manim_slides_directive>`.
Examples
--------
First make sure to put ``from manim_slides import ManimSlidesMagic``,
or even ``from manim_slides import *``
in a cell and evaluate it. Then, a typical Jupyter notebook cell for Manim Slides
could look as follows::
%%manim_slides -v WARNING --progress_bar None MySlide --manim-slides controls=true data_uri=true
class MySlide(Slide):
def construct(self):
square = Square()
circle = Circle()
self.play(Create(square))
self.next_slide()
self.play(Transform(square, circle))
Evaluating this cell will render and display the ``MySlide`` slide
defined in the body of the cell.
.. note::
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 cell:
exec(cell, local_ns)
split_args = line.split("--manim-slides", 2)
manim_args = split_args[0].split()
if len(split_args) == 2:
manim_slides_args = split_args[1].split()
else:
manim_slides_args = []
args = manim_args
if not len(args) or "-h" in args or "--help" in args or "--version" in args:
main(args, standalone_mode=False, prog_name="manim")
return
modified_args = self.add_additional_args(args)
args = main(modified_args, standalone_mode=False, prog_name="manim")
with tempconfig(local_ns.get("config", {})):
config.digest_args(args)
logging.getLogger("manim-slides").setLevel(logging.getLogger("manim").level)
renderer = None
if config.renderer == RendererType.OPENGL:
from manim.renderer.opengl_renderer import OpenGLRenderer
renderer = OpenGLRenderer()
try:
SceneClass = local_ns[config["scene_names"][0]]
scene = SceneClass(renderer=renderer)
scene.render()
finally:
# Shader cache becomes invalid as the context is destroyed
shader_program_cache.clear()
# Close OpenGL window here instead of waiting for the main thread to
# finish causing the window to stay open and freeze
if renderer is not None and renderer.window is not None:
renderer.window.close()
if config["output_file"] is None:
logger.info("No output file produced")
return
local_path = Path(config["output_file"]).relative_to(Path.cwd())
tmpfile = (
Path(config["media_dir"]) / "jupyter" / f"{_generate_file_name()}.html"
)
if local_path in self.rendered_files:
self.rendered_files[local_path].unlink()
pass
self.rendered_files[local_path] = tmpfile
tmpfile.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(local_path, tmpfile)
file_type = mimetypes.guess_type(config["output_file"])[0] or "video/mp4"
embed = config["media_embed"]
if embed is None:
# videos need to be embedded when running in google colab.
# do this automatically in case config.media_embed has not been
# set explicitly.
embed = "google.colab" in str(get_ipython())
if not file_type.startswith("video"):
raise ValueError(
f"Manim Slides only supports video files, not {file_type}"
)
clsname = config["scene_names"][0]
kwargs = dict(arg.split("=", 1) for arg in manim_slides_args)
if embed: # Embedding implies data-uri
kwargs["data_uri"] = "true"
# TODO: FIXME
# Seems like files are blocked so date-uri is the only working option...
if kwargs.get("data_uri", "false").lower().strip() == "false":
logger.warn(
"data_uri option is currently automatically enabled, "
"because using local video files does not seem to work properly."
)
kwargs["data_uri"] = "true"
presentation_configs = get_scenes_presentation_config(
[clsname], Path("./slides")
)
RevealJS(presentation_configs=presentation_configs, **kwargs).convert_to(
tmpfile
)
if embed:
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" srcdoc="{srcdoc}"></iframe></div>""".format(
srcdoc=tmpfile.read_text().replace('"', "'")
)
)
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()
)
)
display(result)
def add_additional_args(self, args: list[str]) -> list[str]:
additional_args = ["--jupyter"]
# Use webm to support transparency
if "-t" in args and "--format" not in args:
additional_args += ["--format", "webm"]
return additional_args + args[:-1] + [""] + [args[-1]]
def _generate_file_name() -> str:
return config["scene_names"][0] + "@" + datetime.now().strftime("%Y-%m-%d@%H-%M-%S") # type: ignore

View File

@ -7,6 +7,7 @@ import logging
from rich.console import Console
from rich.logging import RichHandler
from rich.theme import Theme
__all__ = ["logger", "make_logger"]
@ -24,7 +25,6 @@ HIGHLIGHTED_KEYWORDS = [ # these keywords are highlighted specially
"File",
"Rendering",
"Rendered",
"Pressed key",
]
@ -35,10 +35,10 @@ def make_logger() -> logging.Logger:
RichHandler.KEYWORDS = HIGHLIGHTED_KEYWORDS
rich_handler = RichHandler(
show_time=True,
console=Console(),
console=Console(theme=Theme({"logging.level.perf": "magenta"})),
)
logging.addLevelName(5, "PERF")
logger = logging.getLogger("manim-slides")
logger.setLevel(logging.getLogger("manim").level)
logger.addHandler(rich_handler)
return logger

View File

@ -1,10 +1,10 @@
import os
import sys
from contextlib import contextmanager
from importlib.util import find_spec
from typing import Iterator
__all__ = [
# Constants
"FFMPEG_BIN",
"LEFT",
"MANIM",
"MANIM_PACKAGE_NAME",
"MANIM_AVAILABLE",
@ -13,19 +13,25 @@ __all__ = [
"MANIMGL_PACKAGE_NAME",
"MANIMGL_AVAILABLE",
"MANIMGL_IMPORTED",
# Classes
"AnimationGroup",
"FadeIn",
"FadeOut",
"Mobject",
"logger",
"Scene",
"ThreeDScene",
# Objects
"logger",
"config",
"FFMPEG_BIN",
]
@contextmanager
def suppress_stdout() -> Iterator[None]:
with open(os.devnull, "w") as devnull:
old_stdout = sys.stdout
sys.stdout = devnull
try:
yield
finally:
sys.stdout = old_stdout
MANIM_PACKAGE_NAME = "manim"
MANIM_AVAILABLE = find_spec(MANIM_PACKAGE_NAME) is not None
MANIM_IMPORTED = MANIM_PACKAGE_NAME in sys.modules
@ -37,8 +43,8 @@ MANIMGL_IMPORTED = MANIMGL_PACKAGE_NAME in sys.modules
if MANIM_IMPORTED and MANIMGL_IMPORTED:
from manim import logger
logger.warning(
"Both manim and manimgl are imported, therefore `manim-slide` needs to know which one to use. Please only import one of the two modules so that `manim-slide` knows which one to use. Here, manim is used by default"
logger.warn(
"Both manim and manimgl are installed, therefore `manim-slide` needs to need which one to use. Please only import one of the two modules so that `manim-slide` knows which one to use. Here, manim is used by default"
)
MANIM = True
MANIMGL = False
@ -61,33 +67,15 @@ else:
if MANIMGL:
from manimlib import (
LEFT,
AnimationGroup,
FadeIn,
FadeOut,
Mobject,
Scene,
ThreeDScene,
config,
)
from manimlib import Scene, ThreeDScene, config
from manimlib.constants import FFMPEG_BIN
from manimlib.logger import log as logger
else:
from manim import (
LEFT,
AnimationGroup,
FadeIn,
FadeOut,
Mobject,
Scene,
ThreeDScene,
config,
logger,
)
with suppress_stdout(): # Avoids printing "Manim Community v..."
from manim import Scene, ThreeDScene, config, logger
try: # For manim<v0.16.0.post0
from manim.constants import FFMPEG_BIN
except ImportError:
FFMPEG_BIN = config.ffmpeg_executable
try: # For manim<v0.16.0.post0
from manim.constants import FFMPEG_BIN
except ImportError:
FFMPEG_BIN = config.ffmpeg_executable

1107
manim_slides/present.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,303 +0,0 @@
import signal
import sys
from pathlib import Path
from typing import List, Optional, Tuple
import click
from click import Context, Parameter
from pydantic import ValidationError
from PySide6.QtCore import Qt
from 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,
}
@click.command()
@folder_path_option
@click.help_option("-h", "--help")
@verbosity_option
def list_scenes(folder: Path) -> None:
"""List available scenes."""
for i, scene in enumerate(_list_scenes(folder), start=1):
click.secho(f"{i}: {scene}", fg="green")
def _list_scenes(folder: Path) -> List[str]:
"""Lists available scenes in given directory."""
scenes = []
for filepath in folder.glob("*.json"):
try:
_ = PresentationConfig.from_file(filepath)
scenes.append(filepath.stem)
except (
Exception
) as e: # Could not parse this file as a proper presentation config
logger.warn(
f"Something went wrong with parsing presentation config `{filepath}`: {e}"
)
logger.debug(f"Found {len(scenes)} valid scene configuration files in `{folder}`.")
return scenes
def prompt_for_scenes(folder: Path) -> List[str]:
"""Prompts the user to select scenes within a given folder."""
scene_choices = dict(enumerate(_list_scenes(folder), start=1))
for i, scene in scene_choices.items():
click.secho(f"{i}: {scene}", fg="green")
click.echo()
click.echo("Choose number corresponding to desired scene/arguments.")
click.echo("(Use comma separated list for multiple entries)")
def value_proc(value: Optional[str]) -> List[str]:
indices = list(map(int, (value or "").strip().replace(" ", "").split(",")))
if not all(0 < i <= len(scene_choices) for i in indices):
raise click.UsageError("Please only enter numbers displayed on the screen.")
return [scene_choices[i] for i in indices]
if len(scene_choices) == 0:
raise click.UsageError(
"No scenes were found, are you in the correct directory?"
)
while True:
try:
scenes = click.prompt("Choice(s)", value_proc=value_proc)
return scenes # type: ignore
except ValueError as e:
raise click.UsageError(str(e))
def get_scenes_presentation_config(
scenes: List[str], folder: Path
) -> List[PresentationConfig]:
"""Returns a list of presentation configurations based on the user input."""
if len(scenes) == 0:
scenes = prompt_for_scenes(folder)
presentation_configs = []
for scene in scenes:
config_file = folder / f"{scene}.json"
if not config_file.exists():
raise click.UsageError(
f"File {config_file} does not exist, check the scene name and make sure to use Slide as your scene base class"
)
try:
presentation_configs.append(PresentationConfig.from_file(config_file))
except ValidationError as e:
raise click.UsageError(str(e))
return presentation_configs
def start_at_callback(
ctx: Context, param: Parameter, values: str
) -> Tuple[Optional[int], ...]:
if values == "(None, None)":
return (None, None)
def str_to_int_or_none(value: str) -> Optional[int]:
if value.lower().strip() == "":
return None
else:
try:
return int(value)
except ValueError:
raise click.BadParameter(
f"start index can only be an integer or an empty string, not `{value}`",
ctx=ctx,
param=param,
)
values_tuple = values.split(",")
n_values = len(values_tuple)
if n_values == 2:
return tuple(map(str_to_int_or_none, values_tuple))
raise click.BadParameter(
f"exactly 2 arguments are expected but you gave {n_values}, please use commas to separate them",
ctx=ctx,
param=param,
)
@click.command()
@click.argument("scenes", nargs=-1)
@config_path_option
@folder_path_option
@click.option("--start-paused", is_flag=True, help="Start paused.")
@click.option(
"-F",
"--full-screen",
"--fullscreen",
"full_screen",
is_flag=True,
help="Toggle full screen mode.",
)
@click.option(
"-s",
"--skip-all",
is_flag=True,
help="Skip all slides, useful the test if slides are working. "
"Automatically sets `--exit-after-last-slide` to True.",
)
@click.option(
"--exit-after-last-slide",
is_flag=True,
help="At the end of last slide, the application will be exited.",
)
@click.option(
"-H",
"--hide-mouse",
is_flag=True,
help="Hide mouse cursor.",
)
@click.option(
"--aspect-ratio",
type=click.Choice(["keep", "ignore"], case_sensitive=False),
default="keep",
help="Set the aspect ratio mode to be used when rescaling the video.",
show_default=True,
)
@click.option(
"--sa",
"--start-at",
"start_at",
metavar="<SCENE,SLIDE>",
type=str,
callback=start_at_callback,
default=(None, None),
help="Start presenting at (x, y), equivalent to --sacn x --sasn y, "
"and overrides values if not None.",
)
@click.option(
"--sacn",
"--start-at-scene-number",
"start_at_scene_number",
metavar="INDEX",
type=int,
default=0,
help="Start presenting at a given scene number (0 is first, -1 is last).",
)
@click.option(
"--sasn",
"--start-at-slide-number",
"start_at_slide_number",
metavar="INDEX",
type=int,
default=0,
help="Start presenting at a given slide number (0 is first, -1 is last).",
)
@click.option(
"-S",
"--screen",
"screen_number",
metavar="NUMBER",
type=int,
default=None,
help="Presents content on the given screen (a.k.a. display).",
)
@click.help_option("-h", "--help")
@verbosity_option
def present(
scenes: List[str],
config_path: Path,
folder: Path,
start_paused: bool,
full_screen: bool,
skip_all: bool,
exit_after_last_slide: bool,
hide_mouse: bool,
aspect_ratio: str,
start_at: Tuple[Optional[int], Optional[int], Optional[int]],
start_at_scene_number: int,
start_at_slide_number: int,
screen_number: Optional[int] = None,
) -> None:
"""
Present SCENE(s), one at a time, in order.
Each SCENE parameter must be the name of a Manim scene,
with existing SCENE.json config file.
You can present the same SCENE multiple times by repeating the parameter.
Use ``manim-slide list-scenes`` to list all available
scenes in a given folder.
"""
if skip_all:
exit_after_last_slide = True
presentation_configs = get_scenes_presentation_config(scenes, folder)
if config_path.exists():
try:
config = Config.from_file(config_path)
except ValidationError as e:
raise click.UsageError(str(e))
else:
logger.debug("No configuration file found, using default configuration.")
config = Config()
if start_at[0]:
start_at_scene_number = start_at[0]
if start_at[1]:
start_at_scene_number = start_at[1]
if maybe_app := QApplication.instance():
app = maybe_app
else:
app = QApplication(sys.argv)
app.setApplicationName("Manim Slides")
if screen_number is not None:
try:
screen = app.screens()[screen_number]
except IndexError:
logger.error(
f"Invalid screen number {screen_number}, "
f"allowed values are from 0 to {len(app.screens())-1} (incl.)"
)
screen = None
else:
screen = None
player = Player(
config,
presentation_configs,
start_paused=start_paused,
full_screen=full_screen,
skip_all=skip_all,
exit_after_last_slide=exit_after_last_slide,
hide_mouse=hide_mouse,
aspect_ratio_mode=ASPECT_RATIO_MODES[aspect_ratio],
presentation_index=start_at_scene_number,
slide_index=start_at_slide_number,
screen=screen,
)
player.show()
signal.signal(signal.SIGINT, signal.SIG_DFL)
sys.exit(app.exec_())

View File

@ -1,346 +0,0 @@
from pathlib import Path
from typing import Any, 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 ..config import Config, PresentationConfig, SlideConfig
from ..logger import logger
from ..resources import * # noqa: F401, F403
WINDOW_NAME = "Manim Slides"
class Info(QDialog): # type: ignore[misc]
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
layout = QGridLayout()
self.scene_label = QLabel()
self.slide_label = QLabel()
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)
if parent := self.parent():
self.closeEvent = parent.closeEvent
self.keyPressEvent = parent.keyPressEvent
class Player(QMainWindow): # type: ignore[misc]
presentation_changed: Signal = Signal()
slide_changed: Signal = Signal()
def __init__(
self,
config: Config,
presentation_configs: List[PresentationConfig],
*,
start_paused: bool = False,
full_screen: bool = False,
skip_all: bool = False,
exit_after_last_slide: bool = False,
hide_mouse: bool = False,
aspect_ratio_mode: Qt.AspectRatioMode = Qt.KeepAspectRatio,
presentation_index: int = 0,
slide_index: int = 0,
screen: Optional[QScreen] = None,
):
super().__init__()
# Wizard's config
self.config = config
# Presentation configs
self.presentation_configs = presentation_configs
self.__current_presentation_index = 0
self.__current_slide_index = 0
self.__current_file: Path = self.current_slide_config.file
self.current_presentation_index = presentation_index
self.current_slide_index = slide_index
self.__playing_reversed_slide = False
# Widgets
if screen:
self.setScreen(screen)
self.move(screen.geometry().topLeft())
if full_screen:
self.setWindowState(Qt.WindowFullScreen)
else:
w, h = self.current_presentation_config.resolution
geometry = self.geometry()
geometry.setWidth(w)
geometry.setHeight(h)
self.setGeometry(geometry)
if hide_mouse:
self.setCursor(Qt.BlankCursor)
self.setWindowTitle(WINDOW_NAME)
self.icon = QIcon(":/icon.png")
self.setWindowIcon(self.icon)
self.video_widget = QVideoWidget()
self.video_widget.setAspectRatioMode(aspect_ratio_mode)
self.setCentralWidget(self.video_widget)
self.media_player = QMediaPlayer(self)
self.media_player.setVideoOutput(self.video_widget)
self.presentation_changed.connect(self.presentation_changed_callback)
self.slide_changed.connect(self.slide_changed_callback)
self.info = Info(parent=self)
# Connecting key callbacks
self.config.keys.QUIT.connect(self.quit)
self.config.keys.PLAY_PAUSE.connect(self.play_pause)
self.config.keys.NEXT.connect(self.next)
self.config.keys.PREVIOUS.connect(self.previous)
self.config.keys.REVERSE.connect(self.reverse)
self.config.keys.REPLAY.connect(self.replay)
self.config.keys.FULL_SCREEN.connect(self.full_screen)
self.config.keys.HIDE_MOUSE.connect(self.hide_mouse)
self.dispatch = self.config.keys.dispatch_key_function()
# Misc
self.exit_after_last_slide = exit_after_last_slide
# Setting-up everything
if skip_all:
def media_status_changed(status: QMediaPlayer.MediaStatus) -> None:
self.media_player.setLoops(1) # Otherwise looping slides never end
if status == QMediaPlayer.EndOfMedia:
self.load_next_slide()
self.media_player.mediaStatusChanged.connect(media_status_changed)
if self.current_slide_config.loop:
self.media_player.setLoops(-1)
self.load_current_media(start_paused=start_paused)
self.presentation_changed.emit()
self.slide_changed.emit()
"""
Properties
"""
@property
def presentations_count(self) -> int:
return len(self.presentation_configs)
@property
def current_presentation_index(self) -> int:
return self.__current_presentation_index
@current_presentation_index.setter
def current_presentation_index(self, index: int) -> None:
if 0 <= index < self.presentations_count:
self.__current_presentation_index = index
elif -self.presentations_count <= index < 0:
self.__current_presentation_index = index + self.presentations_count
else:
logger.warn(f"Could not set presentation index to {index}")
return
self.presentation_changed.emit()
@property
def current_presentation_config(self) -> PresentationConfig:
return self.presentation_configs[self.current_presentation_index]
@property
def current_slides_count(self) -> int:
return len(self.current_presentation_config.slides)
@property
def current_slide_index(self) -> int:
return self.__current_slide_index
@current_slide_index.setter
def current_slide_index(self, index: int) -> None:
if 0 <= index < self.current_slides_count:
self.__current_slide_index = index
elif -self.current_slides_count <= index < 0:
self.__current_slide_index = index + self.current_slides_count
else:
logger.warn(f"Could not set slide index to {index}")
return
self.slide_changed.emit()
@property
def current_slide_config(self) -> SlideConfig:
return self.current_presentation_config.slides[self.current_slide_index]
@property
def current_file(self) -> Path:
return self.__current_file
@current_file.setter
def current_file(self, file: Path) -> None:
self.__current_file = file
@property
def playing_reversed_slide(self) -> bool:
return self.__playing_reversed_slide
@playing_reversed_slide.setter
def playing_reversed_slide(self, playing_reversed_slide: bool) -> None:
self.__playing_reversed_slide = playing_reversed_slide
"""
Loading slides
"""
def load_current_media(self, start_paused: bool = False) -> None:
url = QUrl.fromLocalFile(self.current_file)
self.media_player.setSource(url)
if start_paused:
self.media_player.pause()
else:
self.media_player.play()
def load_current_slide(self) -> None:
slide_config = self.current_slide_config
self.current_file = slide_config.file
if slide_config.loop:
self.media_player.setLoops(-1)
else:
self.media_player.setLoops(1)
self.load_current_media()
def load_previous_slide(self) -> None:
self.playing_reversed_slide = False
if self.current_slide_index > 0:
self.current_slide_index -= 1
elif self.current_presentation_index > 0:
self.current_presentation_index -= 1
self.current_slide_index = self.current_slides_count - 1
else:
logger.info("No previous slide.")
return
self.load_current_slide()
def load_next_slide(self) -> None:
if self.playing_reversed_slide:
self.playing_reversed_slide = False
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()
else:
logger.info("No more slide to play.")
return
self.load_current_slide()
def load_reversed_slide(self) -> None:
self.playing_reversed_slide = True
self.current_file = self.current_slide_config.rev_file
self.load_current_media()
"""
Key callbacks and slots
"""
@Slot()
def presentation_changed_callback(self) -> None:
index = self.current_presentation_index
count = self.presentations_count
self.info.scene_label.setText(f"{index+1:4d}/{count:4<d}")
@Slot()
def slide_changed_callback(self) -> None:
index = self.current_slide_index
count = self.current_slides_count
self.info.slide_label.setText(f"{index+1:4d}/{count:4<d}")
def show(self) -> None:
super().show()
self.info.show()
@Slot()
def quit(self) -> None:
logger.info("Closing gracefully...")
self.info.deleteLater()
self.deleteLater()
@Slot()
def next(self) -> None:
if self.media_player.playbackState() == QMediaPlayer.PausedState:
self.media_player.play()
else:
self.load_next_slide()
@Slot()
def previous(self) -> None:
self.load_previous_slide()
@Slot()
def reverse(self) -> None:
self.load_reversed_slide()
@Slot()
def replay(self) -> None:
self.media_player.setPosition(0)
self.media_player.play()
@Slot()
def play_pause(self) -> None:
state = self.media_player.playbackState()
if state == QMediaPlayer.PausedState:
self.media_player.play()
elif state == QMediaPlayer.PlayingState:
self.media_player.pause()
@Slot()
def full_screen(self) -> None:
if self.windowState() == Qt.WindowFullScreen:
self.setWindowState(Qt.WindowNoState)
else:
self.setWindowState(Qt.WindowFullScreen)
@Slot()
def hide_mouse(self) -> None:
if self.cursor().shape() == Qt.BlankCursor:
self.setCursor(Qt.ArrowCursor)
else:
self.setCursor(Qt.BlankCursor)
def closeEvent(self, event: QCloseEvent) -> None:
self.quit()
def keyPressEvent(self, event: QKeyEvent) -> None:
key = event.key()
self.dispatch(key)
event.accept()

View File

@ -1,47 +1,42 @@
import os
import platform
from pathlib import Path
from typing import (
Any,
List,
Mapping,
MutableMapping,
Optional,
Sequence,
Tuple,
ValuesView,
)
import shutil
import subprocess
from typing import Any, List, Optional, Tuple
from warnings import warn
import numpy as np
from tqdm import tqdm
from .config import PresentationConfig, PreSlideConfig, SlideConfig
from .config import PresentationConfig, SlideConfig, SlideType
from .defaults import FOLDER_PATH
from .manim import (
LEFT,
MANIMGL,
AnimationGroup,
FadeIn,
FadeOut,
Mobject,
Scene,
ThreeDScene,
config,
logger,
)
from .utils import concatenate_video_files, merge_basenames, reverse_video_file
from .manim import FFMPEG_BIN, MANIMGL, Scene, ThreeDScene, config, logger
def reverse_video_file(src: str, dst: str) -> None:
"""Reverses a video file, writting the result to `dst`."""
command = [FFMPEG_BIN, "-i", src, "-vf", "reverse", dst]
logger.debug(" ".join(command))
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output, error = process.communicate()
if output:
logger.debug(output.decode())
if error:
logger.debug(error.decode())
class Slide(Scene): # type:ignore
"""
Inherits from :class:`Scene<manim.scene.scene.Scene>` and provide necessary tools for slides rendering.
Inherits from :class:`manim.scene.scene.Scene` or :class:`manimlib.scene.scene.Scene` and provide necessary tools for slides rendering.
"""
def __init__(
self, *args: Any, output_folder: Path = FOLDER_PATH, **kwargs: Any
self, *args: Any, output_folder: str = FOLDER_PATH, **kwargs: Any
) -> None:
if MANIMGL:
Path("videos").mkdir(exist_ok=True)
if not os.path.isdir("videos"):
os.mkdir("videos")
kwargs["file_writer_config"] = {
"break_into_partial_movies": True,
"output_directory": "",
@ -52,30 +47,12 @@ class Slide(Scene): # type:ignore
super().__init__(*args, **kwargs)
self.__output_folder: Path = output_folder
self.__slides: List[PreSlideConfig] = []
self.__output_folder = output_folder
self.__slides: List[SlideConfig] = []
self.__current_slide = 1
self.__current_animation = 0
self.__loop_start_animation: Optional[int] = None
self.__pause_start_animation = 0
self.__canvas: MutableMapping[str, Mobject] = {}
self.__wait_time_between_slides = 0.0
@property
def __frame_height(self) -> float:
"""Returns the scene's frame height."""
if MANIMGL:
return self.frame_height # type: ignore
else:
return config["frame_height"] # type: ignore
@property
def __frame_width(self) -> float:
"""Returns the scene's frame width."""
if MANIMGL:
return self.frame_width # type: ignore
else:
return config["frame_width"] # type: ignore
@property
def __background_color(self) -> str:
@ -94,7 +71,7 @@ class Slide(Scene): # type:ignore
return config["pixel_width"], config["pixel_height"]
@property
def __partial_movie_files(self) -> List[Path]:
def __partial_movie_files(self) -> List[str]:
"""Returns a list of partial movie files, a.k.a animations."""
if MANIMGL:
from manimlib.utils.file_ops import get_sorted_integer_files
@ -103,13 +80,11 @@ class Slide(Scene): # type:ignore
"remove_non_integer_files": True,
"extension": self.file_writer.movie_file_extension,
}
files = get_sorted_integer_files(
return get_sorted_integer_files( # type: ignore
self.file_writer.partial_movie_directory, **kwargs
)
else:
files = self.renderer.file_writer.partial_movie_files
return [Path(file) for file in files]
return self.renderer.file_writer.partial_movie_files # type: ignore
@property
def __show_progress_bar(self) -> bool:
@ -134,172 +109,6 @@ class Slide(Scene): # type:ignore
else:
return config["from_animation_number"] # type: ignore
@property
def canvas(self) -> MutableMapping[str, Mobject]:
"""
Returns the canvas associated to the current slide.
The canvas is a mapping between names and Mobjects,
for objects that are assumed to stay in multiple slides.
For example, a section title or a slide number.
Examples
--------
.. manim-slides:: CanvasExample
from manim import *
from manim_slides import Slide
class CanvasExample(Slide):
def update_canvas(self):
self.counter += 1
old_slide_number = self.canvas["slide_number"]
new_slide_number = Text(f"{self.counter}").move_to(old_slide_number)
self.play(Transform(old_slide_number, new_slide_number))
def construct(self):
title = Text("My Title").to_corner(UL)
self.counter = 1
slide_number = Text("1").to_corner(DL)
self.add_to_canvas(title=title, slide_number=slide_number)
self.play(FadeIn(title), FadeIn(slide_number))
self.next_slide()
circle = Circle(radius=2)
dot = Dot()
self.update_canvas()
self.play(Create(circle))
self.play(MoveAlongPath(dot, circle))
self.next_slide()
self.update_canvas()
square = Square()
self.play(self.wipe(self.mobjects_without_canvas, square))
self.next_slide()
self.update_canvas()
self.play(
Transform(
self.canvas["title"],
Text("New Title").to_corner(UL)
)
)
self.next_slide()
self.remove_from_canvas("title", "slide_number")
self.play(self.wipe(self.mobjects_without_canvas, []))
"""
return self.__canvas
def add_to_canvas(self, **objects: Mobject) -> Mobject:
"""
Adds objects to the canvas, using key values as names.
:param objects: A mapping between names and Mobjects.
.. note::
This method does not actually do anything in terms of
animations. You must still call :code:`self.add` or
play some animation that introduces each Mobject for
it to appear. The same applies when removing objects.
"""
self.__canvas.update(objects)
def remove_from_canvas(self, *names: str) -> None:
"""
Removes objects from the canvas.
"""
for name in names:
self.__canvas.pop(name)
@property
def canvas_mobjects(self) -> ValuesView[Mobject]:
"""
Returns Mobjects contained in the canvas.
"""
return self.canvas.values()
@property
def mobjects_without_canvas(self) -> Sequence[Mobject]:
"""
Returns the list of objects contained in the scene,
minus those present in the canvas.
"""
return [
mobject for mobject in self.mobjects if mobject not in self.canvas_mobjects
]
@property
def wait_time_between_slides(self) -> float:
r"""
Returns the wait duration (in seconds) added between two slides.
By default, this value is set to 0.
Setting this value to something bigger than 0 will result in a
:code:`self.wait` animation called at the end of every slide.
.. note::
This is useful because animations are usually only terminated
when a new animation is played. You can observe the small difference
in the examples below: the circle is not fully complete in the first
slide of the first example, but well in the second example.
Examples
--------
.. manim-slides:: WithoutWaitExample
from manim import *
from manim_slides import Slide
class WithoutWaitExample(Slide):
def construct(self):
circle = Circle(radius=2)
arrow = Arrow().next_to(circle, RIGHT).scale(-1)
text = Text("Small\ngap").next_to(arrow, RIGHT)
self.play(Create(arrow), FadeIn(text))
self.play(Create(circle))
self.next_slide()
self.play(FadeOut(circle))
.. manim-slides:: WithWaitExample
from manim import *
from manim_slides import Slide
class WithWaitExample(Slide):
def construct(self):
self.wait_time_between_slides = 0.1 # A small value > 1 / FPS
circle = Circle(radius=2)
arrow = Arrow().next_to(circle, RIGHT).scale(-1)
text = Text("No more\ngap").next_to(arrow, RIGHT)
self.play(Create(arrow), FadeIn(text))
self.play(Create(circle))
self.next_slide()
self.play(FadeOut(circle))
"""
return self.__wait_time_between_slides
@wait_time_between_slides.setter
def wait_time_between_slides(self, wait_time: float) -> None:
self.__wait_time_between_slides = max(wait_time, 0.0)
def play(self, *args: Any, **kwargs: Any) -> None:
"""Overloads `self.play` and increment animation count."""
super().play(*args, **kwargs)
@ -331,15 +140,16 @@ class Slide(Scene): # type:ignore
#. the second with "Hello World!" fading in;
#. and the last with the text fading out;
.. manim-slides:: NextSlideExample
.. code-block:: python
from manim import *
from manim_slides import Slide
class NextSlideExample(Slide):
class Example(Slide):
def construct(self):
text = Text("Hello World!")
self.next_slide()
self.play(FadeIn(text))
self.next_slide()
@ -349,13 +159,12 @@ class Slide(Scene): # type:ignore
self.__loop_start_animation is None
), "You cannot call `self.next_slide()` inside a loop"
if self.wait_time_between_slides > 0.0:
self.wait(self.wait_time_between_slides)
self.__slides.append(
PreSlideConfig(
SlideConfig(
type=SlideType.slide,
start_animation=self.__pause_start_animation,
end_animation=self.__current_animation,
number=self.__current_slide,
)
)
self.__current_slide += 1
@ -382,13 +191,15 @@ class Slide(Scene): # type:ignore
len(self.__slides) > 0
and self.__current_animation == self.__slides[-1].end_animation
):
self.__slides[-1].type = SlideType.last
return
self.__slides.append(
PreSlideConfig(
SlideConfig(
type=SlideType.last,
start_animation=self.__pause_start_animation,
end_animation=self.__current_animation,
loop=self.__loop_start_animation is not None,
number=self.__current_slide,
)
)
@ -399,35 +210,25 @@ class Slide(Scene): # type:ignore
A loop will automatically replay the slide, i.e., everything between
:func:`start_loop` and :func:`end_loop`, upon reaching end.
.. warning::
When rendered with RevealJS, loops cannot be in the first nor
the last slide.
Examples
--------
The following contains one slide that will loop endlessly.
.. manim-slides:: LoopExample
.. code-block:: python
from manim import *
from manim_slides import Slide
class LoopExample(Slide):
class Example(Slide):
def construct(self):
dot = Dot(color=BLUE, radius=1)
self.play(FadeIn(dot))
self.next_slide()
dot = Dot(color=BLUE)
self.start_loop()
self.play(Indicate(dot, scale_factor=2))
self.play(Indicate(dot))
self.end_loop()
self.play(FadeOut(dot))
"""
assert self.__loop_start_animation is None, "You cannot nest loops"
self.__loop_start_animation = self.__current_animation
@ -438,10 +239,11 @@ class Slide(Scene): # type:ignore
self.__loop_start_animation is not None
), "You have to start a loop before ending it"
self.__slides.append(
PreSlideConfig(
SlideConfig(
type=SlideType.loop,
start_animation=self.__loop_start_animation,
end_animation=self.__current_animation,
loop=True,
number=self.__current_slide,
)
)
self.__current_slide += 1
@ -456,70 +258,86 @@ class Slide(Scene): # type:ignore
"""
self.__add_last_slide()
files_folder = self.__output_folder / "files"
if not os.path.exists(self.__output_folder):
os.mkdir(self.__output_folder)
files_folder = os.path.join(self.__output_folder, "files")
if not os.path.exists(files_folder):
os.mkdir(files_folder)
scene_name = str(self)
scene_files_folder = files_folder / scene_name
scene_files_folder = os.path.join(files_folder, scene_name)
scene_files_folder.mkdir(parents=True, exist_ok=True)
old_animation_files = set()
# When rendering with -na,b (manim only)
# the animations not in [a,b] will be skipped,
# but animation before a will have a None source file.
files: List[Path] = list(filter(None, self.__partial_movie_files))
if not os.path.exists(scene_files_folder):
os.mkdir(scene_files_folder)
elif not use_cache:
shutil.rmtree(scene_files_folder)
os.mkdir(scene_files_folder)
else:
old_animation_files.update(os.listdir(scene_files_folder))
# We must filter slides that end before the animation offset
if offset := self.__start_at_animation_number:
self.__slides = [
slide for slide in self.__slides if slide.end_animation > offset
]
for slide in self.__slides:
slide.start_animation = max(0, slide.start_animation - offset)
slide.end_animation -= offset
slides: List[SlideConfig] = []
for pre_slide_config in tqdm(
self.__slides,
desc=f"Concatenating animation files to '{scene_files_folder}' and generating reversed animations",
files = []
for src_file in tqdm(
self.__partial_movie_files,
desc=f"Copying animation files to '{scene_files_folder}' and generating reversed animations",
leave=self.__leave_progress_bar,
ascii=True if platform.system() == "Windows" else None,
disable=not self.__show_progress_bar,
):
slide_files = files[pre_slide_config.slides_slice]
if src_file is None and not MANIMGL:
# This happens if rendering with -na,b (manim only)
# where animations not in [a,b] will be skipped
# but animations before a will have a None src_file
continue
file = merge_basenames(slide_files)
dst_file = scene_files_folder / file.name
rev_file = scene_files_folder / f"{file.stem}_reversed{file.suffix}"
filename = os.path.basename(src_file)
rev_filename = "{}_reversed{}".format(*os.path.splitext(filename))
# We only concat animations if it was not present
if not use_cache or not dst_file.exists():
concatenate_video_files(slide_files, dst_file)
dst_file = os.path.join(scene_files_folder, filename)
# We only copy animation if it was not present
if filename in old_animation_files:
old_animation_files.remove(filename)
else:
shutil.copyfile(src_file, dst_file)
# We only reverse video if it was not present
if not use_cache or not rev_file.exists():
reverse_video_file(dst_file, rev_file)
if rev_filename in old_animation_files:
old_animation_files.remove(rev_filename)
else:
rev_file = os.path.join(scene_files_folder, rev_filename)
reverse_video_file(src_file, rev_file)
slides.append(
SlideConfig.from_pre_slide_config_and_files(
pre_slide_config, dst_file, rev_file
)
files.append(dst_file)
if offset := self.__start_at_animation_number:
self.__slides = [
slide for slide in self.__slides if slide.end_animation > offset
]
for slide in self.__slides:
slide.start_animation -= offset
slide.end_animation -= offset
logger.info(
f"Copied {len(files)} animations to '{os.path.abspath(scene_files_folder)}' and generated reversed animations"
)
slide_path = os.path.join(self.__output_folder, "%s.json" % (scene_name,))
with open(slide_path, "w") as f:
f.write(
PresentationConfig(
slides=self.__slides,
files=files,
resolution=self.__resolution,
background_color=self.__background_color,
).json(indent=2)
)
logger.info(
f"Generated {len(slides)} slides to '{scene_files_folder.absolute()}'"
)
slide_path = self.__output_folder / f"{scene_name}.json"
PresentationConfig(
slides=slides,
resolution=self.__resolution,
background_color=self.__background_color,
).to_file(slide_path)
logger.info(
f"Slide '{scene_name}' configuration written in '{slide_path.absolute()}'"
f"Slide '{scene_name}' configuration written in '{os.path.abspath(slide_path)}'"
)
def run(self, *args: Any, **kwargs: Any) -> None:
@ -539,185 +357,12 @@ class Slide(Scene): # type:ignore
self.__save_slides()
def wipe(
self,
current: Sequence[Mobject] = [],
future: Sequence[Mobject] = [],
direction: np.ndarray = LEFT,
fade_in_kwargs: Mapping[str, Any] = {},
fade_out_kwargs: Mapping[str, Any] = {},
**kwargs: Any,
) -> AnimationGroup:
"""
Returns a wipe animation that will shift all the current objects outside
of the current scene's scope, and all the future objects inside.
:param current: A sequence of mobjects to remove from the scene.
:param future: A sequence of mobjects to add to the scene.
:param direction: The wipe direction.
:param fade_in_kwargs: Keyword arguments passed to
:class:`FadeIn<manim.animation.fading.FadeIn>`.
:param fade_out_kwargs: Keyword arguments passed to
:class:`FadeOut<manim.animation.fading.FadeOut>`.
:param kwargs: Keyword arguments passed to
:class:`AnimationGroup<manim.animation.composition.AnimationGroup>`.
Examples
--------
.. manim-slides:: WipeExample
from manim import *
from manim_slides import Slide
class WipeExample(Slide):
def construct(self):
circle = Circle(radius=3, color=BLUE)
square = Square()
text = Text("This is a wipe example").next_to(square, DOWN)
beautiful = Text("Beautiful, no?")
self.play(FadeIn(circle))
self.next_slide()
self.play(self.wipe(circle, Group(square, text)))
self.next_slide()
self.play(self.wipe(Group(square, text), beautiful, direction=UP))
self.next_slide()
self.play(self.wipe(beautiful, circle, direction=DOWN + RIGHT))
"""
shift_amount = np.asarray(direction) * np.array(
[self.__frame_width, self.__frame_height, 0.0]
)
animations = []
for mobject in future:
animations.append(FadeIn(mobject, shift=shift_amount, **fade_in_kwargs))
for mobject in current:
animations.append(FadeOut(mobject, shift=shift_amount, **fade_out_kwargs))
return AnimationGroup(*animations, **kwargs)
def zoom(
self,
current: Sequence[Mobject] = [],
future: Sequence[Mobject] = [],
scale: float = 4.0,
out: bool = False,
fade_in_kwargs: Mapping[str, Any] = {},
fade_out_kwargs: Mapping[str, Any] = {},
**kwargs: Any,
) -> AnimationGroup:
"""
Returns a zoom animation that will fade out all the current objects,
and fade in all the future objects. Objects are faded in a direction
that goes towards the camera.
:param current: A sequence of mobjects to remove from the scene.
:param future: A sequence of mobjects to add to the scene.
:param scale: How much the objects are scaled (up or down).
:param out: If set, the objects fade in the opposite direction.
:param fade_in_kwargs: Keyword arguments passed to
:class:`FadeIn<manim.animation.fading.FadeIn>`.
:param fade_out_kwargs: Keyword arguments passed to
:class:`FadeOut<manim.animation.fading.FadeOut>`.
:param kwargs: Keyword arguments passed to
:class:`AnimationGroup<manim.animation.composition.AnimationGroup>`.
Examples
--------
.. manim-slides:: ZoomExample
from manim import *
from manim_slides import Slide
class ZoomExample(Slide):
def construct(self):
circle = Circle(radius=3, color=BLUE)
square = Square()
self.play(FadeIn(circle))
self.next_slide()
self.play(self.zoom(circle, square))
self.next_slide()
self.play(self.zoom(square, circle, out=True, scale=10.))
"""
scale_in = 1.0 / scale
scale_out = scale
if out:
scale_in, scale_out = scale_out, scale_in
animations = []
for mobject in future:
animations.append(FadeIn(mobject, scale=scale_in, **fade_in_kwargs))
for mobject in current:
animations.append(FadeOut(mobject, scale=scale_out, **fade_out_kwargs))
return AnimationGroup(*animations, **kwargs)
class ThreeDSlide(Slide, ThreeDScene): # type: ignore
"""
Inherits from :class:`Slide` and :class:`ThreeDScene<manim.scene.three_d_scene.ThreeDScene>` and provide necessary tools for slides rendering.
Inherits from :class:`Slide` and :class:`manim.scene.three_d_scene.ThreeDScene` or :class:`manimlib.scene.three_d_scene.ThreeDScene` and provide necessary tools for slides rendering.
.. note:: ManimGL does not need ThreeDScene for 3D rendering in recent versions, see `example.py`.
Examples
--------
.. manim-slides:: ThreeDExample
from manim import *
from manim_slides import ThreeDSlide
class ThreeDExample(ThreeDSlide):
def construct(self):
title = Text("A 2D Text")
self.play(FadeIn(title))
self.next_slide()
sphere = Sphere([0, 0, -3])
self.move_camera(phi=PI/3, theta=-PI/4, distance=7)
self.play(
GrowFromCenter(sphere),
Transform(title, Text("A 3D Text"))
)
self.next_slide()
bye = Text("Bye!")
self.start_loop()
self.play(
self.wipe(
self.mobjects_without_canvas,
[bye],
direction=UP
)
)
self.wait(.5)
self.play(
self.wipe(
self.mobjects_without_canvas,
[title, sphere],
direction=DOWN
)
)
self.wait(.5)
self.end_loop()
self.play(*[FadeOut(mobject) for mobject in self.mobjects])
"""
pass

View File

@ -1,80 +0,0 @@
import hashlib
import subprocess
import tempfile
from pathlib import Path
from typing import List
from .manim import FFMPEG_BIN, logger
def concatenate_video_files(files: List[Path], dest: Path) -> None:
"""
Concatenate multiple video files into one.
"""
f = tempfile.NamedTemporaryFile(mode="w", delete=False)
f.writelines(f"file '{path.absolute()}'\n" for path in files)
f.close()
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()
if output:
logger.debug(output.decode())
if error:
logger.debug(error.decode())
if not dest.exists():
raise ValueError(
"could not properly concatenate files, use `-v DEBUG` for more details"
)
def merge_basenames(files: List[Path]) -> Path:
"""
Merge multiple filenames by concatenating basenames.
"""
dirname: Path = files[0].parent
ext = files[0].suffix
basenames = list(file.stem for file in files)
basenames_str = ",".join(f"{len(b)}:{b}" for b in basenames)
# We use hashes to prevent too-long filenames, see issue #123:
# https://github.com/jeertmans/manim-slides/issues/123
basename = hashlib.sha256(basenames_str.encode()).hexdigest()
logger.debug(f"Generated a new basename for basenames: {basenames} -> '{basename}'")
return dirname.joinpath(basename + ext)
def reverse_video_file(src: Path, dst: Path) -> None:
"""Reverses a video file, writting the result to `dst`."""
command = [str(FFMPEG_BIN), "-y", "-i", str(src), "-vf", "reverse", str(dst)]
logger.debug(" ".join(command))
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output, error = process.communicate()
if output:
logger.debug(output.decode())
if error:
logger.debug(error.decode())

View File

@ -1,6 +1,6 @@
import os
import sys
from functools import partial
from pathlib import Path
from typing import Any
import click
@ -68,11 +68,11 @@ class Wizard(QWidget): # type: ignore
self.layout = QGridLayout()
for i, (key, value) in enumerate(self.config.keys.dict().items()):
for i, (key, value) in enumerate(self.config.dict().items()):
# Create label for key name information
label = QLabel()
key_info = value["name"] or key
label.setText(key_info.title())
label.setText(key_info)
self.layout.addWidget(label, i, 0)
# Create button that will pop-up a dialog and ask to input a new key
@ -83,7 +83,7 @@ class Wizard(QWidget): # type: ignore
)
self.buttons.append(button)
button.clicked.connect(
partial(self.openDialog, i, getattr(self.config.keys, key))
partial(self.openDialog, i, getattr(self.config, key))
)
self.layout.addWidget(button, i, 1)
@ -102,7 +102,7 @@ class Wizard(QWidget): # type: ignore
def saveConfig(self) -> None:
try:
Config.model_validate(self.config.dict())
Config.parse_obj(self.config.dict())
except ValueError:
msg = QMessageBox()
msg.setIcon(QMessageBox.Critical)
@ -130,7 +130,7 @@ class Wizard(QWidget): # type: ignore
@config_options
@click.help_option("-h", "--help")
@verbosity_option
def wizard(config_path: Path, force: bool, merge: bool) -> None:
def wizard(config_path: str, force: bool, merge: bool) -> None:
"""Launch configuration wizard."""
return _init(config_path, force, merge, skip_interactive=False)
@ -140,18 +140,18 @@ def wizard(config_path: Path, force: bool, merge: bool) -> None:
@click.help_option("-h", "--help")
@verbosity_option
def init(
config_path: Path, force: bool, merge: bool, skip_interactive: bool = False
config_path: str, force: bool, merge: bool, skip_interactive: bool = False
) -> None:
"""Initialize a new default configuration file."""
return _init(config_path, force, merge, skip_interactive=True)
def _init(
config_path: Path, force: bool, merge: bool, skip_interactive: bool = False
config_path: str, force: bool, merge: bool, skip_interactive: bool = False
) -> None:
"""Actual initialization code for configuration file, with optional interactive mode."""
if config_path.exists():
if os.path.exists(config_path):
click.secho(f"The `{CONFIG_PATH}` configuration file exists")
if not force and not merge:
@ -175,8 +175,8 @@ def _init(
logger.debug("Merging new config into `{config_path}`")
if not skip_interactive:
if config_path.exists():
config = Config.from_file(config_path)
if os.path.exists(config_path):
config = Config.parse_file(config_path)
app = QApplication(sys.argv)
app.setApplicationName("Manim Slides Wizard")
@ -187,8 +187,9 @@ def _init(
config = window.config
if merge:
config = Config.from_file(config_path).merge_with(config)
config = Config.parse_file(config_path).merge_with(config)
config.to_file(config_path)
with open(config_path, "w") as config_file:
config_file.write(config.json(indent=2))
click.secho(f"Configuration file successfully saved to `{config_path}`")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 4.3 MiB

View File

@ -59,9 +59,7 @@ evolved very little since its inception and does not work with ManimGL.
In 2022, Manim Slides has been created from manim-presentation, with the aim
to make it a more complete tool, better documented, and usable on all platforms
and with ManimCE or ManimGL. After almost a year of existence, Manim Slides has
evolved a lot (see
[comparison section](#comparison-with-manim-presentation)),
has built a small community of contributors, and continues to
evolved a lot, has built a small community of contributors, and continues to
provide new features on a regular basis.
# Easy to Use Commitment
@ -135,34 +133,9 @@ share your slides, which we discuss on our
[Sharing your slides](https://jeertmans.github.io/manim-slides/reference/sharing.html)
page.
## Comparison with manim-presentation
Starting from @manim-presentation's original work, Manim Slides now provides
numerous additional features.
A non-exhaustive list of those new features is as follows:
* ManimGL compatibility;
* playing slides in reverse;
* exporting slides to HTML and PowerPoint;
* 3D scene support;
* multiple key inputs can map to the same action
(e.g., useful when using a pointer);
* optionally hiding mouse cursor when presenting;
* recording your presentation;
* multiple video scaling methods (for speed-vs-quality tradeoff);
* and automatic detection of some scene parameters
(e.g., resolution or background color).
The complete and up-to-date set of features Manim Slide supports is
available in the
[online documentation](https://jeertmans.github.io/manim-slides/).
For new feature requests, we highly encourage users to
[create an issue](https://github.com/jeertmans/manim-slides/issues/new/choose)
with the appropriate template.
# Acknowledgements
We acknowledge the work of @manim-presentation that paved the initial structure
We acknowledge the work of [@manim-presentation] that paved the initial structure
of Manim Slides with the manim-presentation Python package.
We also acknowledge Grant Sanderson for his tremendous work on Manim, as

5717
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -43,39 +43,27 @@ packages = [
]
readme = "README.md"
repository = "https://github.com/jeertmans/manim-slides"
version = "5.0.0-rc1"
version = "4.13.1"
[tool.poetry.dependencies]
click = "^8.1.3"
click-default-group = "^1.2.2"
docutils = {version = "^0.20.1", optional = true}
ipython = {version = ">=8.12.2", optional = true}
jinja2 = {version = "^3.1.2", optional = true}
lxml = "^4.9.2"
manim = {version = "^0.17.3", optional = true}
manim = {version = "^0.17.0", optional = true}
manimgl = {version = "^1.6.1", optional = true}
notebook = {version = "^7.0.2", optional = true}
numpy = "^1.19"
opencv-python = "^4.6.0.66"
pillow = "^9.5.0"
pydantic = "^2.0.1"
pydantic-extra-types = "^2.0.0"
pyside6 = "^6.5.1.1"
pydantic = "^1.10.2"
pyside6 = "^6.4.1"
python = ">=3.8.1,<3.12"
python-pptx = "^0.6.21"
requests = "^2.28.1"
rich = "^13.3.2"
rtoml = "^0.9.0"
tqdm = "^4.64.1"
[tool.poetry.extras]
magic = ["manim", "ipython"]
manim = ["manim"]
manimgl = ["manimgl"]
sphinx-directive = ["docutils", "jinja2", "manim"]
[tool.poetry.group.dev]
optional = true
[tool.poetry.group.dev.dependencies]
black = "^22.10.0"
@ -85,46 +73,24 @@ 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"
furo = "^2022.9.29"
manim = "^0.17.0"
myst-parser = "^0.18.1"
sphinx = "^5.3.0"
sphinx-click = "^4.4.0"
sphinx-copybutton = "^0.5.1"
sphinxext-opengraph = "^0.7.5"
[tool.poetry.group.test]
optional = true
[tool.poetry.group.test.dependencies]
manim = "^0.17.3"
manim = "^0.17.0"
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.pytest.ini_options]
env = [
"QT_QPA_PLATFORM=offscreen"
]
filterwarnings = [
"error",
"ignore::DeprecationWarning"
]
[tool.ruff]
ignore = [
"E501"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 485 KiB

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -1,62 +0,0 @@
import random
import string
from pathlib import Path
from typing import Generator, Iterator, List
import pytest
from manim_slides.config import PresentationConfig
from manim_slides.logger import make_logger
_ = make_logger() # This is run so that logger is created
@pytest.fixture
def data_folder() -> Iterator[Path]:
path = (Path(__file__).parent / "data").resolve()
assert path.exists()
yield path
@pytest.fixture
def slides_folder(data_folder: Path) -> Iterator[Path]:
path = (data_folder / "slides").resolve()
assert path.exists()
yield path
@pytest.fixture
def slides_file(data_folder: Path) -> Iterator[Path]:
path = (data_folder / "slides.py").resolve()
assert path.exists()
yield path
def random_path(
length: int = 20,
dirname: Path = Path("./media/videos/example"),
suffix: str = ".mp4",
touch: bool = False,
) -> Path:
basename = "".join(random.choices(string.ascii_letters, k=length))
filepath = dirname.joinpath(basename + suffix)
if touch:
filepath.touch()
return filepath
@pytest.fixture
def paths() -> Generator[List[Path], None, None]:
random.seed(1234)
yield [random_path() for _ in range(20)]
@pytest.fixture
def presentation_config(
slides_folder: Path,
) -> Generator[PresentationConfig, None, None]:
yield PresentationConfig.from_file(slides_folder / "BasicSlide.json")

View File

@ -1,24 +0,0 @@
# flake8: noqa: F403, F405
# type: ignore
from manim import *
from manim_slides import Slide
class BasicSlide(Slide):
def construct(self):
circle = Circle(radius=3, color=BLUE)
dot = Dot()
self.play(GrowFromCenter(circle))
self.next_slide()
self.start_loop()
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
self.wait(2.0)
self.end_loop()
self.play(dot.animate.move_to(ORIGIN))
self.next_slide()
self.play(self.wipe(Group(dot, circle), []))

View File

@ -1,29 +0,0 @@
{
"slides": [
{
"file": "slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4.mp4",
"rev_file": "slides/files/BasicSlide/275756d906c706ca0125660866bb925b8927e2b2589d31a7a578079b70076ef4_reversed.mp4",
"loop": false
},
{
"file": "slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da.mp4",
"rev_file": "slides/files/BasicSlide/05b94f634a049cd83daa9b47e483183d1ccdbc485687cee79c6ffbd4f02698da_reversed.mp4",
"loop": true
},
{
"file": "slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810.mp4",
"rev_file": "slides/files/BasicSlide/d09707faa0d68c55e98c628c5da51d66c92d0f79ac48647526817c377f843810_reversed.mp4",
"loop": false
},
{
"file": "slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9.mp4",
"rev_file": "slides/files/BasicSlide/c10cc5deb3630a8259712288913b2bd6e79d093356d379d518cc929812194bd9_reversed.mp4",
"loop": false
}
],
"resolution": [
854,
480
],
"background_color": "black"
}

View File

@ -1,142 +0,0 @@
from pathlib import Path
import click
import pytest
from click.testing import CliRunner
from manim_slides.commons import (
config_options,
config_path_option,
folder_path_option,
verbosity_option,
)
def test_config_options() -> None:
@click.command()
@config_options
def main(config_path: Path, force: bool, merge: bool) -> None:
pass
runner = CliRunner()
with runner.isolated_filesystem():
with open("config.json", "w") as f:
f.write("Hello world!")
result = runner.invoke(main, ["--config", "config.json", "--force", "--merge"])
assert result.exit_code == 0
result = runner.invoke(main, ["-c", "config.json", "-f", "-m"])
def test_config_path_option() -> None:
@click.command()
@config_path_option
def main(config_path: Path) -> None:
pass
runner = CliRunner()
with runner.isolated_filesystem() as temp_dir:
with open("config.json", "w") as f:
f.write("Hello world!")
result = runner.invoke(main, ["--config", "config.json"])
assert result.exit_code == 0
result = runner.invoke(main, ["-c", "config.json"])
assert result.exit_code == 0
result = runner.invoke(main, ["--config", "unexisting.json"])
assert result.exit_code == 0
result = runner.invoke(main, ["--config", "unexisting"])
assert result.exit_code == 0
result = runner.invoke(main, ["--config", temp_dir])
assert result.exit_code != 0
def test_folder_path_option() -> None:
@click.command()
@folder_path_option
def main(folder: Path) -> None:
pass
runner = CliRunner()
with runner.isolated_filesystem() as temp_dir:
with open("file.txt", "w") as f:
f.write("Hello world!")
result = runner.invoke(main, ["--folder", "file.txt"])
assert result.exit_code != 0
result = runner.invoke(main, ["--folder", "unexisting.txt"])
assert result.exit_code != 0
result = runner.invoke(main, ["--folder", "unexisting"])
assert result.exit_code != 0
result = runner.invoke(main, ["--folder", temp_dir])
assert result.exit_code == 0
@pytest.mark.parametrize(
("verbosity",),
[("DEBUG",), ("info",), ("waRNING",), ("eRRor",), ("CrItIcAl",)],
)
def test_valid_verbosity_option(verbosity: str) -> None:
@click.command()
@verbosity_option
def main() -> None:
pass
runner = CliRunner()
result = runner.invoke(main, ["-v", verbosity])
assert result.exit_code == 0
result = runner.invoke(main, ["--verbosity", verbosity])
assert result.exit_code == 0
with runner.isolation(env={"MANIM_SLIDES_VERBOSITY": verbosity}):
result = runner.invoke(main)
assert result.exit_code == 0
@pytest.mark.parametrize(
("verbosity",), [("test",), ("deebug",), ("warn",), ("errors",)]
)
def test_invalid_verbosity_option(verbosity: str) -> None:
@click.command()
@verbosity_option
def main() -> None:
pass
runner = CliRunner()
result = runner.invoke(main, ["-v", verbosity])
assert result.exit_code != 0
result = runner.invoke(main, ["--verbosity", verbosity])
assert result.exit_code != 0
with runner.isolation(env={"MANIM_SLIDES_VERBOSITY": verbosity}):
result = runner.invoke(main)
assert result.exit_code != 0

View File

@ -1,32 +0,0 @@
from typing import Any
import pytest
from pydantic import ValidationError
from manim_slides.config import Key, PresentationConfig
class TestKey:
@pytest.mark.parametrize(("ids", "name"), [([1], None), ([1], "some key name")])
def test_valid_keys(self, ids: Any, name: Any) -> None:
_ = Key(ids=ids, name=name)
@pytest.mark.parametrize(
("ids", "name"), [([], None), ([-1], None), ([1], {"an": " invalid name"})]
)
def test_invalid_keys(self, ids: Any, name: Any) -> None:
with pytest.raises(ValidationError):
_ = Key(ids=ids, name=name)
class TestPresentationConfig:
def test_validate(self, presentation_config: PresentationConfig) -> None:
obj = presentation_config.model_dump()
_ = PresentationConfig.model_validate(obj)
def test_bump_to_json(self, presentation_config: PresentationConfig) -> None:
_ = presentation_config.model_dump_json(indent=2)
def test_empty_presentation_config(self) -> None:
with pytest.raises(ValidationError):
_ = PresentationConfig(slides=[], files=[])

View File

@ -1,11 +0,0 @@
import pytest
from manim_slides.convert import PDF, Converter, PowerPoint, RevealJS
class TestConverter:
@pytest.mark.parametrize(
("name", "converter"), [("html", RevealJS), ("pdf", PDF), ("pptx", PowerPoint)]
)
def test_from_string(self, name: str, converter: type) -> None:
assert Converter.from_string(name) == converter

View File

@ -1,15 +0,0 @@
from pathlib import Path
from manim_slides.defaults import CONFIG_PATH, FFMPEG_BIN, FOLDER_PATH
def test_folder_path() -> None:
assert FOLDER_PATH == Path("./slides")
def test_config_path() -> None:
assert CONFIG_PATH == Path(".manim-slides.toml")
def test_ffmpeg_bin() -> None:
assert FFMPEG_BIN == Path("ffmpeg")

View File

@ -1,93 +0,0 @@
from pathlib import Path
from click.testing import CliRunner
from manim_slides.__main__ import cli
def test_help() -> None:
runner = CliRunner()
results = runner.invoke(cli, ["-S", "--help"])
assert results.exit_code == 0
results = runner.invoke(cli, ["-S", "-h"])
assert results.exit_code == 0
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"]
)
assert results.exit_code == 0
def test_present(slides_folder: Path) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
results = runner.invoke(
cli, ["present", "BasicSlide", "--folder", str(slides_folder), "-s"]
)
assert results.exit_code == 0
def test_convert(slides_folder: Path) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
results = runner.invoke(
cli,
[
"convert",
"BasicSlide",
"basic_example.html",
"--folder",
str(slides_folder),
],
)
assert results.exit_code == 0
def test_init() -> None:
runner = CliRunner()
with runner.isolated_filesystem():
results = runner.invoke(
cli,
[
"init",
"--force",
],
)
assert results.exit_code == 0
def test_list_scenes(slides_folder: Path) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
results = runner.invoke(
cli,
[
"list-scenes",
"--folder",
str(slides_folder),
],
)
assert results.exit_code == 0
assert "BasicSlide" in results.output
def test_wizard() -> None:
# TODO
pass

View File

@ -1,143 +0,0 @@
import importlib
import sys
from contextlib import contextmanager
from importlib.abc import MetaPathFinder
from importlib.machinery import ModuleSpec
from types import ModuleType
from typing import Iterator, Optional, Sequence
import pytest
import manim_slides.manim as msm
@contextmanager
def suppress_module_finder() -> Iterator[None]:
meta_path = sys.meta_path
try:
class PathFinder(MetaPathFinder):
@classmethod
def find_spec(
cls,
fullname: str,
path: Optional[Sequence[str]],
target: Optional[ModuleType] = None,
) -> Optional[ModuleSpec]:
if fullname in ["manim", "manimlib"]:
return None
for finder in meta_path:
spec = finder.find_spec(fullname, path, target=target)
if spec is not None:
return spec
return None
sys.meta_path = [PathFinder]
yield
finally:
sys.meta_path = meta_path
def assert_import(
*,
manim: bool,
manim_available: bool,
manim_imported: bool,
manimgl: bool,
manimgl_available: bool,
manimgl_imported: bool,
) -> None:
importlib.reload(msm)
assert msm.MANIM == manim
assert msm.MANIM_AVAILABLE == manim_available
assert msm.MANIM_IMPORTED == manim_imported
assert msm.MANIMGL == manimgl
assert msm.MANIMGL_AVAILABLE == manim_available
assert msm.MANIMGL_IMPORTED == manimgl_imported
@pytest.mark.filterwarnings("ignore:assert_import")
def test_manim_and_manimgl_imported() -> None:
import manim # noqa: F401
import manimlib # noqa: F401
assert_import(
manim=True,
manim_available=True,
manim_imported=True,
manimgl=False,
manimgl_available=True,
manimgl_imported=True,
)
def test_manim_imported() -> None:
import manim # noqa: F401
if "manimlib" in sys.modules:
del sys.modules["manimlib"]
assert_import(
manim=True,
manim_available=True,
manim_imported=True,
manimgl=False,
manimgl_available=True,
manimgl_imported=False,
)
def test_manimgl_imported() -> None:
import manimlib # noqa: F401
if "manim" in sys.modules:
del sys.modules["manim"]
assert_import(
manim=False,
manim_available=True,
manim_imported=False,
manimgl=True,
manimgl_available=True,
manimgl_imported=True,
)
def test_nothing_imported() -> None:
if "manim" in sys.modules:
del sys.modules["manim"]
if "manimlib" in sys.modules:
del sys.modules["manimlib"]
assert_import(
manim=True,
manim_available=True,
manim_imported=False,
manimgl=False,
manimgl_available=True,
manimgl_imported=False,
)
def test_no_package_available() -> None:
with suppress_module_finder():
if "manim" in sys.modules:
del sys.modules["manim"]
if "manimlib" in sys.modules:
del sys.modules["manimlib"]
with pytest.raises(ModuleNotFoundError):
# Actual values are not important
assert_import(
manim=False,
manim_available=False,
manim_imported=False,
manimgl=False,
manimgl_available=False,
manimgl_imported=False,
)

View File

@ -1,147 +0,0 @@
from pathlib import Path
import pytest
from click.testing import CliRunner
from manim import Text
from manim.__main__ import main as cli
from pydantic import ValidationError
from manim_slides.config import PresentationConfig
from manim_slides.slide import Slide
def assert_construct(cls: type) -> type:
class Wrapper:
@classmethod
def test_construct(_) -> None:
cls().construct()
return Wrapper
def test_render_basic_examples(
slides_file: Path, presentation_config: PresentationConfig
) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
results = runner.invoke(cli, [str(slides_file), "BasicSlide", "-ql"])
assert results.exit_code == 0
local_slides_folder = Path("slides")
assert local_slides_folder.exists()
local_config_file = local_slides_folder / "BasicSlide.json"
assert local_config_file.exists()
local_presentation_config = PresentationConfig.from_file(local_config_file)
assert len(local_presentation_config.slides) == len(presentation_config.slides)
assert (
local_presentation_config.background_color
== presentation_config.background_color
)
assert (
local_presentation_config.background_color
== presentation_config.background_color
)
assert local_presentation_config.resolution == presentation_config.resolution
class TestSlide:
@assert_construct
class TestLoop(Slide):
def construct(self) -> None:
text = Text("Some text")
self.add(text)
self.start_loop()
self.play(text.animate.scale(2))
self.end_loop()
with pytest.raises(AssertionError):
self.end_loop()
self.start_loop()
with pytest.raises(AssertionError):
self.start_loop()
with pytest.raises(ValidationError):
self.end_loop()
@assert_construct
class TestWipe(Slide):
def construct(self) -> None:
text = Text("Some text")
bye = Text("Bye")
self.add(text)
assert text in self.mobjects
assert bye not in self.mobjects
self.play(self.wipe([text], [bye]))
assert text not in self.mobjects
assert bye in self.mobjects
@assert_construct
class TestZoom(Slide):
def construct(self) -> None:
text = Text("Some text")
bye = Text("Bye")
self.add(text)
assert text in self.mobjects
assert bye not in self.mobjects
self.play(self.zoom([text], [bye]))
assert text not in self.mobjects
assert bye in self.mobjects
@assert_construct
class TestCanvas(Slide):
def construct(self) -> None:
text = Text("Some text")
bye = Text("Bye")
assert len(self.canvas) == 0
self.add(text)
assert len(self.canvas) == 0
self.add_to_canvas(text=text)
assert len(self.canvas) == 1
self.add(bye)
assert len(self.canvas) == 1
assert text not in self.mobjects_without_canvas
assert bye in self.mobjects_without_canvas
self.remove(text)
assert len(self.canvas) == 1
self.add_to_canvas(bye=bye)
assert len(self.canvas) == 2
self.remove_from_canvas("text", "bye")
assert len(self.canvas) == 0
with pytest.raises(KeyError):
self.remove_from_canvas("text")

View File

@ -1,23 +0,0 @@
from pathlib import Path
from typing import List
from manim_slides.utils import merge_basenames
def test_merge_basenames(paths: List[Path]) -> None:
path = merge_basenames(paths)
assert path.suffix == paths[0].suffix
assert path.parent == paths[0].parent
def test_merge_basenames_same_with_different_parent_directories(
paths: List[Path],
) -> None:
d1 = Path("a/b/c")
d2 = Path("d/e/f")
p1 = d1 / "one.txt"
p2 = d1 / "a/b/c/two.txt"
p3 = d2 / "d/e/f/one.txt"
p4 = d2 / "d/e/f/two.txt"
assert merge_basenames([p1, p2]).name == merge_basenames([p3, p4]).name