Merge branch 'main' into reorganize

This commit is contained in:
Jérome Eertmans
2025-01-29 19:28:57 +01:00
56 changed files with 6425 additions and 1565 deletions

View File

@ -27,7 +27,9 @@ body:
label: Terms
description: 'By submitting this issue, I have:'
options:
- label: Checked the [existing issues](https://github.com/jeertmans/manim-slides/issues?q=is%3Aissue+label%3Adocumentation+) and [discussions](https://github.com/jeertmans/manim-slides/discussions) to see if my issue had not already been reported;
- label: Checked the [existing issues](https://github.com/jeertmans/manim-slides/issues?q=is%3Aissue+label%3Abug+) and [discussions](https://github.com/jeertmans/manim-slides/discussions) to see if my issue had not already been reported;
required: true
- label: Checked the [frequently asked questions](https://manim-slides.eertmans.be/latest/faq.html);
required: true
- label: Read the [installation instructions](https://manim-slides.eertmans.be/latest/installation.html);
required: true
@ -73,22 +75,22 @@ body:
description: |
Please copy and paste the output of `python --version`.
Make sure to activate your virtual environment first (if any).
This will be automatically formatted into code, so no need for backticks.
placeholder: Python 3.11.8
validations:
required: false
required: true
- type: textarea
id: venv
attributes:
label: Python environment
description: |
Please copy and paste the output of `pip freeze`.
Please copy and paste the output of `manim-slides checkhealth`.
Make sure to activate your virtual environment first (if any).
This will be automatically formatted into code, so no need for backticks.
If Manim Slides installation failed, enter 'N/A' instead.
render: shell
validations:
required: false
required: true
- type: dropdown
id: platform
@ -120,7 +122,7 @@ body:
This will be automatically formatted into code, so no need for backticks.
placeholder: |
from manim import *
from manim_slides.slide import Slide
from manim_slides import Slide
class MWE(Slide):

View File

@ -18,7 +18,7 @@ body:
label: Terms
description: 'By submitting this issue, I have:'
options:
- label: Checked the [existing issues](https://github.com/jeertmans/manim-slides/issues?q=is%3Aissue+label%3Adocumentation+) and [discussions](https://github.com/jeertmans/manim-slides/discussions) to see if my issue had not already been reported;
- label: Checked the [existing issues](https://github.com/jeertmans/manim-slides/issues?q=is%3Aissue+label%3Aenhancement+) and [discussions](https://github.com/jeertmans/manim-slides/discussions) to see if my issue had not already been reported;
required: true
- type: textarea

View File

@ -18,18 +18,13 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Rye
env:
RYE_INSTALL_OPTION: --yes
run: |
curl -sSf https://rye.astral.sh/get | bash
echo "$HOME/.rye/shims" >> $GITHUB_PATH
- name: Configure Rye
run: rye config --set-bool behavior.use-uv=true
- name: Setup uv
uses: astral-sh/setup-uv@v4
with:
enable-cache: true
- name: Build package
run: rye build
run: uv build
- name: Publish to PyPI
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
@ -41,7 +36,6 @@ jobs:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
steps:
- name: Checkout
uses: actions/checkout@v4
@ -72,7 +66,7 @@ jobs:
with:
platforms: linux/arm64,linux/amd64
file: docker/Dockerfile
push: true
push: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags') }}
tags: |
ghcr.io/jeertmans/manim-slides:latest
ghcr.io/jeertmans/manim-slides:${{ steps.create_release.outputs.tag_name }}

View File

@ -16,7 +16,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
os: [macos-13, ubuntu-latest, windows-latest]
pyversion: ['3.9', '3.10', '3.11', '3.12']
extras: [pyside6-full, manimgl]
exclude:
@ -41,7 +41,9 @@ jobs:
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install build-essential python3-dev libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev xvfb
sudo apt install software-properties-common
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt-get install build-essential python${{ matrix.pyversion }}-dev libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev xvfb
nohup Xvfb $DISPLAY &
- name: Install Windows dependencies
@ -63,36 +65,16 @@ jobs:
env:
QT_QPA_PLATFORM: offscreen
MANIM_SLIDES_VERBOSITY: error
PYTHONFAULTHANDLER: 1
DISPLAY: :99
GITHUB_WORKFLOWS: 1
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Rye
if: matrix.os != 'windows-latest'
env:
RYE_TOOLCHAIN_VERSION: ${{ matrix.pyversion}}
RYE_INSTALL_OPTION: --yes
run: |
curl -sSf https://rye.astral.sh/get | bash
echo "$HOME/.rye/shims" >> $GITHUB_PATH
# Stolen from https://github.com/bluss/pyproject-local-kernel/blob/2b641290694adc998fb6bceea58d3737523a68b7/.github/workflows/ci.yaml
- name: Install Rye (Windows)
if: matrix.os == 'windows-latest'
shell: bash
run: |
C:/msys64/usr/bin/wget.exe -q 'https://github.com/astral-sh/rye/releases/latest/download/rye-x86_64-windows.exe' -O rye-x86_64-windows.exe
./rye-x86_64-windows.exe self install --toolchain-version ${{ matrix.pyversion }} --modify-path -y
echo "$HOME\\.rye\\shims" >> $GITHUB_PATH
- name: Configure Rye
shell: bash
run: |
rye config --set-bool behavior.use-uv=true
rye pin ${{ matrix.pyversion }}
- name: Setup uv
uses: astral-sh/setup-uv@v4
with:
enable-cache: true
- name: Install manim dependencies on MacOS
if: matrix.os == 'macos-latest'
@ -113,16 +95,11 @@ jobs:
if: matrix.os == 'windows-latest'
uses: ssciwr/setup-mesa-dist-win@v2
- name: Install Manim Slides
shell: bash
run: rye sync
- name: Run pytest
shell: bash
run: rye run pytest
run: uv run --python ${{ matrix.pyversion }} --frozen --extra tests pytest
- name: Upload to codecov.io
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:

3
.gitignore vendored
View File

@ -4,7 +4,6 @@ __pycache__/
/build
/dist
*.egg-info/
.pdm-python
# Manim files
images/
@ -45,7 +44,7 @@ paper/paper.pdf
paper/media/
# Others
.coverage
.coverage*
coverage.xml
rendering_times.csv

View File

@ -6,7 +6,7 @@ ci:
autoupdate_commit_msg: 'chore(deps): pre-commit autoupdate'
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
rev: v5.0.0
hooks:
- id: check-yaml
- id: check-toml
@ -20,23 +20,19 @@ repos:
- id: pretty-format-toml
exclude: poetry.lock
args: [--autofix, --trailing-commas]
- repo: https://github.com/keewis/blackdoc
rev: v0.3.9
hooks:
- id: blackdoc
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.1
rev: v0.9.3
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.1
rev: v1.14.1
hooks:
- id: mypy
additional_dependencies: [types-requests, types-setuptools]
- repo: https://github.com/codespell-project/codespell
rev: v2.3.0
rev: v2.4.0
hooks:
- id: codespell
additional_dependencies:

View File

@ -1 +1 @@
3.11.8
3.11

View File

@ -2,13 +2,10 @@ version: 2
build:
os: ubuntu-22.04
tools:
python: '3.10'
python: '3.11'
apt_packages:
- libpango1.0-dev
- ffmpeg
jobs:
post_install:
- ipython kernel install --name "manim-slides" --user
sphinx:
builder: html
configuration: docs/source/conf.py

View File

@ -8,12 +8,185 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!-- start changelog -->
(unreleased)=
## [Unreleased](https://github.com/jeertmans/manim-slides/compare/v5.1.7...HEAD)
## [Unreleased](https://github.com/jeertmans/manim-slides/compare/v5.4.2...HEAD)
(unreleased-added)=
### Added
- Added `max_duration_before_split_reverse` and `num_processes` class variables.
[#439](https://github.com/jeertmans/manim-slides/pull/439)
- Added `src = ...` filepath argument to allow inserting external
videos as slides.
[#526](https://github.com/jeertmans/manim-slides/pull/526)
(unreleased-changed)=
### Changed
- Automatically split large video animations into smaller chunks
for lightweight (and potentially faster) reversed animations generation.
[#439](https://github.com/jeertmans/manim-slides/pull/439)
(unreleased-chore)=
### Chore
- Pin `rtoml==0.9.0` on Windows platforms,
- Trimmed whitespaces in HTML template.
[#443](https://github.com/jeertmans/manim-slides/pull/443)
(v5.4.2)=
## [v5.4.2](https://github.com/jeertmans/manim-slides/compare/v5.4.1...v5.4.2)
(v5.4.2-fixed)=
### Fixed
- Fixed `start_skip_animations` to actually pass argument to ManimCE,
otherwise video animations were still rendered, just excluded from
the final output.
[#524](https://github.com/jeertmans/manim-slides/pull/524)
(v5.4.1)=
## [v5.4.1](https://github.com/jeertmans/manim-slides/compare/v5.4.0...v5.4.1)
(v5.4.1-added)=
### Added
- Added `start_skip_animations` and `stop_skip_animations` methods.
[#523](https://github.com/jeertmans/manim-slides/pull/523)
(v5.4.0)=
## [v5.4.0](https://github.com/jeertmans/manim-slides/compare/v5.3.1...v5.4.0)
(v5.4.0-added)=
### Added
- Added `skip_animations` compatibility with ManimCE.
[@Rapsssito](https://github.com/Rapsssito) [#516](https://github.com/jeertmans/manim-slides/pull/516)
(v5.4.0-chore)=
### Chore
- Bumped Manim to `>=0.19`, as it fixed OpenGL renderer issue.
[#522](https://github.com/jeertmans/manim-slides/pull/522)
(v5.4.0-fixed)=
### Fixed
- Fixed OpenGL renderer having no partial movie files with Manim bindings.
[#522](https://github.com/jeertmans/manim-slides/pull/522)
- Fixed `ConvertExample` example as `manim>=0.19` changed the `Code` class.
[#522](https://github.com/jeertmans/manim-slides/pull/522)
(v5.3.1)=
## [v5.3.1](https://github.com/jeertmans/manim-slides/compare/v5.3.0...v5.3.1)
(v5.3.1-fixed)=
### Fixed
- Fixed HTML template to avoid missing slides when exporting with `--one-file`.
[@Rapsssito](https://github.com/Rapsssito) [#515](https://github.com/jeertmans/manim-slides/pull/515)
(v5.3.0)=
## [v5.3.0](https://github.com/jeertmans/manim-slides/compare/v5.2.0...v5.3.0)
(v5.3.0-added)=
### Added
- Added CSS and JS inline for `manim-slides convert` if `--offline`
and `--one-file` (`-cone_file`) are used for HTML output.
[@Rapsssito](https://github.com/Rapsssito) [#505](https://github.com/jeertmans/manim-slides/pull/505)
(v5.3.0-changed)=
### Changed
- Deprecate `-cdata_uri` in favor of `-cone_file` for `manim-slides convert`.
[@Rapsssito](https://github.com/Rapsssito) [#505](https://github.com/jeertmans/manim-slides/pull/505)
- Changed template to avoid micro-stuttering with `--one-file` in HTML presentation.
[@Rapsssito](https://github.com/Rapsssito) [#508](https://github.com/jeertmans/manim-slides/pull/508)
(v5.2.0)=
## [v5.2.0](https://github.com/jeertmans/manim-slides/compare/v5.1.10...v5.2.0)
(v5.2.0-changed)=
### Changed
- The info window is now only shown in presentations when there
are multiple monitors. However, the `--show-info-window` option
was added to `manim-slides present` to force the info window.
When there are multiple monitors, the info window will no longer
be on the same monitor as the main window, unless overridden.
[@taibeled](https://github.com/taibeled)
[#482](https://github.com/jeertmans/manim-slides/pull/482)
(v5.2.0-chore)=
### Chore
- Bumped ManimGL to `>=1.7.1`, to remove conflicting dependencies
with Manim's.
[#499](https://github.com/jeertmans/manim-slides/pull/499)
- Bumped ManimGL to `>=1.7.2`, to remove `pyrr` from dependencies,
and to avoid complex code for supporting both `1.7.1` and `>=1.7.2`,
as the latter includes many breaking changes.
[#506](https://github.com/jeertmans/manim-slides/pull/506)
(v5.1.10)=
## [v5.1.10](https://github.com/jeertmans/manim-slides/compare/v5.1.9...v5.1.10)
(v5.1.10-added)=
### Added
- Added `--offline` option to `manim-slides convert` for offline
HTML presentations.
[#440](https://github.com/jeertmans/manim-slides/pull/440)
- Added documentation to config option to `manim-slides convert`
when using `--show-config`.
[#485](https://github.com/jeertmans/manim-slides/pull/485)
(v5.1.10-changed)=
### Changed
- Allow multiple slide reverses by going backward [@taibeled](https://github.com/taibeled).
[#488](https://github.com/jeertmans/manim-slides/pull/488)
(v5.1.10-fixed)=
### Fixed
- Fixed PyAV issue by pinning its version to `<14`.
A future release will contain a fix that supports both `av>=14`
and `av<14`, as their syntax differ, but the former doesn't
provide binary wheels for Python 3.9.
[#494](https://github.com/jeertmans/manim-slides/pull/494)
- Fixed blank web page when converting multiple slides into HTML.
[#497](https://github.com/jeertmans/manim-slides/pull/497)
(v5.1.9)=
## [v5.1.9](https://github.com/jeertmans/manim-slides/compare/v5.1.8...v5.1.9)
(v5.1.9-fixed)=
## Chore
- Fixed failing docker builds.
[#481](https://github.com/jeertmans/manim-slides/pull/481)
(v5.1.8)=
## [v5.1.8](https://github.com/jeertmans/manim-slides/compare/v5.1.7...v5.1.8)
(v5.1.8-added)=
### Added
- Added `manim-slides checkhealth` command to easily obtain important information
for debug purposes.
[#458](https://github.com/jeertmans/manim-slides/pull/458)
- Added support for `disable_caching` and `flush_cache` options from Manim, and
also the possibility to configure them through class options.
[#452](https://github.com/jeertmans/manim-slides/pull/452)
- Added `--to=zip` convert format to generate an archive with HTML output
and asset files.
[#470](https://github.com/jeertmans/manim-slides/pull/470)
(v5.1.8-chore)=
### Chore
- Pinned `rtoml==0.9.0` on Windows platforms,
see [#398](https://github.com/jeertmans/manim-slides/pull/398),
until
[samuelcolvin/rtoml#74](https://github.com/samuelcolvin/rtoml/issues/74)
@ -27,18 +200,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#447](https://github.com/jeertmans/manim-slides/pull/447)
- Improved issue templates.
[#456](https://github.com/jeertmans/manim-slides/pull/456)
- Enhancer the error message when the slides folder does not exist.
- Enhanced the error message when the slides folder does not exist.
[#462](https://github.com/jeertmans/manim-slides/pull/462)
- Fixed deprecation warnings.
[#467](https://github.com/jeertmans/manim-slides/pull/467)
- Documented potential fix for PPTX issue.
[#475](https://github.com/jeertmans/manim-slides/pull/475)
- Changed project manager from Rye to uv.
[#476](https://github.com/jeertmans/manim-slides/pull/476)
(unreleased-fixed)=
(v5.1.8-fixed)=
### Fixed
- Fix combining assets from multiple scenes to avoid filename collision.
[#429](https://github.com/jeertmans/manim-slides/pull/429)
- Fixed whitespace issue in default RevealJS template.
[#442](https://github.com/jeertmans/manim-slides/pull/442)
- Fixed black screen issue on recent Qt versions and device loss detected,
thanks to [@taibeled](https://github.com/taibeled)!
[#465](https://github.com/jeertmans/manim-slides/pull/465)
(unreleased-removed)=
(v5.1.8-removed)=
### Removed
- Removed `full-gl` extra, because it does not make sense to ship both

View File

@ -26,7 +26,7 @@ keywords:
- PowerPoint
- Python
license: MIT
version: v5.1.7
version: v5.4.2
preferred-citation:
publisher:
name: The Open Journal

View File

@ -1,3 +1,9 @@
> [!IMPORTANT]
> Take the [**Manim Slides Survey**](https://forms.gle/i4scrwPQghbTQwQs5)
> to help improve this tool! Thanks in advance to all the people taking the time
> to answer this short survey! The form is open until **January 31st 2025**,
> and results will be communicated in the GitHub discussions.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/logo_dark_transparent.png">
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/logo_light_transparent.png">

View File

@ -1,2 +1,2 @@
style:
camera:
background_color: '#000000'

View File

@ -1,9 +1,8 @@
# Mostly a copy from https://github.com/ManimCommunity/manim/blob/68bd79093e1ebc1ed9f8051942ffe6e72a9e66a7/docker/Dockerfile
# Mostly a copy from https://github.com/ManimCommunity/manim/blob/v0.18.1/docker/Dockerfile
FROM python:3.11-slim
RUN apt-get update -qq \
&& apt-get install --no-install-recommends -y \
ffmpeg \
build-essential \
gcc \
cmake \
@ -24,21 +23,22 @@ RUN wget -O /tmp/install-tl-unx.tar.gz http://mirror.ctan.org/systems/texlive/tl
tar -xzf /tmp/install-tl-unx.tar.gz -C /tmp/install-tl --strip-components=1 && \
/tmp/install-tl/install-tl --profile=/tmp/texlive-profile.txt \
&& tlmgr install \
amsmath babel-english cbfonts-fd cm-super ctex doublestroke dvisvgm everysel \
amsmath babel-english cbfonts-fd cm-super count1to ctex doublestroke dvisvgm everysel \
fontspec frcursive fundus-calligra gnu-freefont jknapltx latex-bin \
mathastext microtype ms physics preview ragged2e relsize rsfs \
mathastext microtype multitoc physics prelim2e preview ragged2e relsize rsfs \
setspace standalone tipa wasy wasysym xcolor xetex xkeyval
# clone and build manim-slides
COPY . /opt/manim-slides
WORKDIR /opt/manim-slides
RUN pip install --no-cache manim[jupyterlab] .[sphinx-directive]
RUN pip install --no-cache-dir manim[jupyterlab] .[sphinx-directive]
ARG NB_USER=manimslidesuser
ARG NB_UID=1000
ENV USER ${NB_USER}
ENV NB_UID ${NB_UID}
ENV HOME /manim-slides
ENV USER=${NB_USER}
ENV NB_UID=${NB_UID}
ENV HOME=/manim-slides
RUN adduser --disabled-password \
--gecos "Default user" \

View File

@ -18,82 +18,75 @@
<body>
<div class="reveal">
<div class="slides">
{%- for presentation_config in presentation_configs -%}
{% set outer_loop = loop %}
{%- for slide_config in presentation_config.slides -%}
{%- if data_uri -%}
{% for presentation_config in presentation_configs -%}
{%- set outer_loop = loop %}
{% for slide_config in presentation_config.slides %}
{% if one_file %}
{% set file = file_to_data_uri(slide_config.file) %}
{%- else -%}
{% set file = assets_dir / slide_config.file.name %}
{%- endif -%}
<section
data-background-size={{ background_size }}
data-background-color="{{ presentation_config.background_color }}"
data-background-video="{{ file }}"
{% if loop.index == 1 and outer_loop.index == 1 -%}
data-background-video-muted
{%- endif %}
{% if slide_config.loop -%}
data-background-video-loop
{%- endif -%}
{% if slide_config.auto_next -%}
data-autoslide="{{ get_duration_ms(slide_config.file) }}"
{%- endif -%}>
{% if slide_config.notes != "" -%}
<aside class="notes" data-markdown>{{ slide_config.notes }}</aside>
{%- endif %}
</section>
{%- endfor -%}
{%- endfor -%}
{% else %}
{% set file = assets_dir / (prefix(outer_loop.index0) + slide_config.file.name) %}
{% endif %}
<section
data-background-size={{ background_size }}
data-background-color="{{ presentation_config.background_color }}"
data-background-video="{{ file }}"
{% if loop.index == 1 and outer_loop.index == 1 %}
data-background-video-muted
{% endif %}
{% if slide_config.loop %}
data-background-video-loop
{% endif %}
{% if slide_config.auto_next %}
data-autoslide="{{ get_duration_ms(slide_config.file) }}"
{% endif %}
>
{% if slide_config.notes != "" %}
<aside class="notes" data-markdown>{{ slide_config.notes }}</aside>
{% endif %}
</section>
{% endfor %}
{% endfor %}
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/reveal.min.js"></script>
<!-- To include plugins, see: https://revealjs.com/plugins/ -->
{% if has_notes -%}
{% if has_notes %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/plugin/markdown/markdown.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/plugin/notes/notes.min.js"></script>
{%- endif -%}
{% endif %}
<!-- <script src="index.js"></script> -->
<script>
Reveal.initialize({
{% if has_notes -%}
plugins: [ RevealMarkdown, RevealNotes ],
{%- endif %}
{% if has_notes %}
/// The list of RevealJS plugins.
plugins: [ RevealMarkdown, RevealNotes ],
{% endif %}
// The "normal" size of the presentation, aspect ratio will
// be preserved when the presentation is scaled to fit different
// resolutions. Can be specified using percentage units.
width: {{ width }},
height: {{ height }},
// Factor of the display size that should remain empty around
// the content
margin: {{ margin }},
// Bounds for smallest/largest possible scale to apply to content
minScale: {{ min_scale }},
maxScale: {{ max_scale }},
// Display presentation control arrows
controls: {{ controls }},
// Help the user learn the controls by providing hints, for example by
// bouncing the down arrow when they first encounter a vertical slide
controlsTutorial: {{ controls_tutorial }},
// Determines where controls appear, "edges" or "bottom-right"
controlsLayout: {{ controls_layout }},
// Visibility rule for backwards navigation arrows; "faded", "hidden"
// or "visible"
controlsBackArrows: {{ controls_back_arrows }},
// Display a presentation progress bar
progress: {{ progress }},
// Display the page number of the current slide
// - true: Show slide number
// - false: Hide slide number
@ -109,55 +102,43 @@
// object and return an array with one string [slideNumber] or
// three strings [n1,delimiter,n2]. See #formatSlideNumber().
slideNumber: {{ slide_number }},
// Can be used to limit the contexts in which the slide number appears
// - "all": Always show the slide number
// - "print": Only when printing to PDF
// - "speaker": Only in the speaker view
showSlideNumber: {{ show_slide_number }},
// Use 1 based indexing for # links to match slide number (default is zero
// based)
hashOneBasedIndex: {{ hash_one_based_index }},
// Add the current slide number to the URL hash so that reloading the
// page/copying the URL will return you to the same slide
hash: {{ hash }},
// Flags if we should monitor the hash and change slides accordingly
respondToHashChanges: {{ respond_to_hash_changes }},
// Enable support for jump-to-slide navigation shortcuts
jumpToSlide: {{ jump_to_slide }},
// Push each slide change to the browser history. Implies `hash: true`
history: {{ history }},
// Enable keyboard shortcuts for navigation
keyboard: {{ keyboard }},
// Optional function that blocks keyboard events when retuning false
//
// If you set this to 'focused', we will only capture keyboard events
// for embedded decks when they are in focus
keyboardCondition: {{ keyboard_condition }},
// Disables the default reveal.js slide layout (scaling and centering)
// so that you can use custom CSS layout
disableLayout: {{ disable_layout }},
// Enable the slide overview mode
overview: {{ overview }},
// Vertical centering of slides
center: {{ center }},
// Enables touch navigation on devices with touch input
touch: {{ touch }},
// Loop the presentation
loop: {{ loop }},
// Change the presentation direction to be RTL
rtl: {{ rtl }},
// Changes the behavior of our navigation directions.
//
// "default"
@ -183,159 +164,146 @@
// from 1.3 -> 2.1. If "grid" is used, the same navigation takes you
// from 1.3 -> 2.3.
navigationMode: {{ navigation_mode }},
// Randomizes the order of slides each time the presentation loads
shuffle: {{ shuffle }},
// Turns fragments on and off globally
fragments: {{ fragments }},
// Flags whether to include the current fragment in the URL,
// so that reloading brings you to the same fragment position
fragmentInURL: {{ fragment_in_url }},
// Flags if the presentation is running in an embedded mode,
// i.e. contained within a limited portion of the screen
embedded: {{ embedded }},
// Flags if we should show a help overlay when the question-mark
// key is pressed
help: {{ help }},
// Flags if it should be possible to pause the presentation (blackout)
pause: {{ pause }},
// Flags if speaker notes should be visible to all viewers
showNotes: {{ show_notes }},
// Global override for autolaying embedded media (video/audio/iframe)
// - null: Media will only autoplay if data-autoplay is present
// - true: All media will autoplay, regardless of individual setting
// - false: No media will autoplay, regardless of individual setting
autoPlayMedia: {{ auto_play_media }},
// Global override for preloading lazy-loaded iframes
// - null: Iframes with data-src AND data-preload will be loaded when within
// the viewDistance, iframes with only data-src will be loaded when visible
// - true: All iframes with data-src will be loaded when within the viewDistance
// - false: All iframes with data-src will be loaded only when visible
preloadIframes: {{ preload_iframes }},
// Can be used to globally disable auto-animation
autoAnimate: {{ auto_animate }},
// Optionally provide a custom element matcher that will be
// used to dictate which elements we can animate between.
autoAnimateMatcher: {{ auto_animate_matcher }},
// Default settings for our auto-animate transitions, can be
// overridden per-slide or per-element via data arguments
autoAnimateEasing: {{ auto_animate_easing }},
autoAnimateDuration: {{ auto_animate_duration }},
autoAnimateUnmatched: {{ auto_animate_unmatched }},
// CSS properties that can be auto-animated. Position & scale
// is matched separately so there's no need to include styles
// like top/right/bottom/left, width/height or margin.
autoAnimateStyles: {{ auto_animate_styles }},
// Controls automatic progression to the next slide
// - 0: Auto-sliding only happens if the data-autoslide HTML attribute
// is present on the current slide or fragment
// - 1+: All slides will progress automatically at the given interval
// - false: No auto-sliding, even if data-autoslide is present
autoSlide: {{ auto_slide }},
// Stop auto-sliding after user input
autoSlideStoppable: {{ auto_slide_stoppable }},
// Use this method for navigation when auto-sliding (defaults to navigateNext)
autoSlideMethod: {{ auto_slide_method }},
// Specify the average time in seconds that you think you will spend
// presenting each slide. This is used to show a pacing timer in the
// speaker view
defaultTiming: {{ default_timing }},
// Enable slide navigation via mouse wheel
mouseWheel: {{ mouse_wheel }},
// Opens links in an iframe preview overlay
// Add `data-preview-link` and `data-preview-link="false"` to customize each link
// individually
previewLinks: {{ preview_links }},
// Exposes the reveal.js API through window.postMessage
postMessage: {{ post_message }},
// Dispatches all reveal.js events to the parent window through postMessage
postMessageEvents: {{ post_message_events }},
// Focuses body when page changes visibility to ensure keyboard shortcuts work
focusBodyOnPageVisibilityChange: {{ focus_body_on_page_visibility_change }},
// Transition style
transition: {{ transition }}, // none/fade/slide/convex/concave/zoom
// Transition speed
transitionSpeed: {{ transition_speed }}, // default/fast/slow
// Transition style for full page slide backgrounds
backgroundTransition: {{ background_transition }}, // none/fade/slide/convex/concave/zoom
// The maximum number of pages a single slide can expand onto when printing
// to PDF, unlimited by default
pdfMaxPagesPerSlide: {{ pdf_max_pages_per_slide }},
// Prints each fragment on a separate slide
pdfSeparateFragments: {{ pdf_separate_fragments }},
// Offset used to reduce the height of content within exported PDF pages.
// This exists to account for environment differences based on how you
// print to PDF. CLI printing options, like phantomjs and wkpdf, can end
// on precisely the total height of the document whereas in-browser
// printing has to end one pixel before.
pdfPageHeightOffset: {{ pdf_page_height_offset }},
// Number of slides away from the current that are visible
viewDistance: {{ view_distance }},
// Number of slides away from the current that are visible on mobile
// devices. It is advisable to set this to a lower number than
// viewDistance in order to save resources.
mobileViewDistance: {{ mobile_view_distance }},
// The display mode that will be used to show slides
display: {{ display }},
// Hide cursor if inactive
hideInactiveCursor: {{ hide_inactive_cursor }},
// Time before the cursor is hidden (in ms)
hideCursorTime: {{ hide_cursor_time }}
});
{% if one_file %}
// Fix found by @t-fritsch and @Rapsssito on GitHub
// see: https://github.com/hakimel/reveal.js/discussions/3362#discussioncomment-11733074.
function setVideoBase64(video) {
const sources = video.querySelectorAll('source');
// Update the source of the video
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}`);
}
});
}
{% if data_uri %}
// Fix found by @t-fritsch on GitHub
// see: https://github.com/hakimel/reveal.js/discussions/3362#discussioncomment-6651475.
function fixBase64VideoBackground(event) {
// event.previousSlide, event.currentSlide, event.indexh, event.indexv
if (event.currentSlide.getAttribute('data-background-video')) {
const background = Reveal.getSlideBackground(event.indexh, event.indexv),
video = background.querySelector('video'),
sources = video.querySelectorAll('source');
sources.forEach((source, i) => {
const src = source.getAttribute('src');
if(src.match(/^data:video.*;base64$/)) {
const nextSrc = sources[i+1]?.getAttribute('src');
video.setAttribute('src', `${src},${nextSrc}`);
function fixBase64VideoBackground(event) {
// Analyze all slides backgrounds
for (const slide of Reveal.getBackgroundsElement().querySelectorAll('.slide-background')) {
// Get the slide video and its sources for each background
const video = slide.querySelector('video');
if (video) {
setVideoBase64(video);
} else {
// Listen to the creation of the video element
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
for (const addedNode of mutation.addedNodes) {
if (addedNode.tagName === 'VIDEO') {
setVideoBase64(addedNode);
observer.disconnect(); // Stop observing once the video is handled
}
}
}
}
});
observer.observe(slide, { childList: true, subtree: true });
}
}
Reveal.on( 'ready', fixBase64VideoBackground );
Reveal.on( 'slidechanged', fixBase64VideoBackground );
}
// Setup base64 videos
Reveal.on( 'ready', fixBase64VideoBackground );
{% endif %}
</script>

View File

@ -30,9 +30,11 @@ extensions = [
# Additional
"nbsphinx",
"myst_parser",
"sphinxcontrib.programoutput",
"sphinxext.opengraph",
"sphinx_click",
"sphinx_copybutton",
"sphinx_design",
# Custom
"manim_slides.docs.manim_slides_directive",
]

View File

@ -24,25 +24,30 @@ the repository, and clone it locally.
As for every Python project, using virtual environment is recommended to avoid
conflicts between modules.
For this project, we use [Rye](https://rye.astral.sh/) to easily manage project
For this project, we use [uv](https://github.com/astral-sh/uv) to easily manage project
and development dependencies. If not already, please install this tool.
## Installing Python modules
With Rye, installation becomes straightforward:
With uv, installation becomes straightforward:
```bash
rye sync --all-features
uv sync --all-extras
```
:::{note}
You still need the same dependencies as to install Manim and ManimGL,
so please check their respective installation guides.
:::
## Running commands
Because modules are installed in a new Python environment,
you cannot use them directly in the shell.
Instead, you either need to prepend `rye run` to any command, e.g.:
Instead, you either need to prepend `uv run` to any command, e.g.:
```bash
rye run manim-slides wizard
uv run manim-slides wizard
```
## Testing your code
@ -51,7 +56,7 @@ Most of the tests are done with GitHub actions, thus not on your computer.
The only command you should run locally is:
```bash
rye run pre-commit run --all-files
uv run pre-commit run --all-files
```
This runs a few linter and formatter to make sure the code quality and style stay
@ -61,7 +66,7 @@ If a warning or an error is displayed, please fix it before going to next step.
For testing your code, simply run:
```bash
rye run pytest
uv run pytest
```
## Building the documentation
@ -73,7 +78,7 @@ To generate the documentation, run the following:
```bash
cd docs
rye run make html
uv run make html
```
Then, the output index file is located at `docs/build/html/index.html` and

View File

@ -29,13 +29,8 @@ ManimGL support is only guaranteed to work
on a very minimal set of versions, because it differs quite a lot from ManimCE,
and its development is not very active.
The typical issues are that (1) ManimGL needs an outdated NumPy version
and (2) ManimGL **should not** be installed from the GitHub repository,
at least not from the `main` branch, but from a version released to PyPI.
To solve the NumPy issue, you can safely downgrade NumPy to a version supported
by ManimGL,
while ignoring the possible *conflicting dependencies* messages from `pip` (or else).
The typical issue is that ManimGL `<1.7.1` needs an outdated NumPy version, but
can be resolved by manually downgrading NumPy, or upgrading ManimGL (**recommended**).
### Presenting
@ -54,9 +49,12 @@ with ManimCE or ManimGL.
### Slides go black when video finishes
This is an issue with Qt,
which cannot be solve on all platforms and Python versions,
which cannot be solved on all platforms and Python versions,
see [#293](https://github.com/jeertmans/manim-slides/issues/293).
Recent version of Manim Slides, i.e., `manim-slides>5.1.7`, come
with a fix that should work fine.
### How to increase quality on Windows
On Windows platform, one may encounter a lower image resolution than expected.
@ -104,7 +102,7 @@ Questions related to `manim-slides convert [SCENES]... output.html`.
### I moved my `.html` file and it stopped working
If you did not specify `-cdata_uri=true` when converting,
If you did not specify `--one-file` (or `-cone_file=true`) when converting,
then Manim Slides generated a folder containing all
the video files, in the same folder as the HTML
output. As the path to video files is a relative path,
@ -120,3 +118,7 @@ This issue is (probably) caused by PowerPoint never freeing
memory, causing memory allocation errors, and can be partially
solved by reducing the video quality or the number of slides,
see [#392](https://github.com/jeertmans/manim-slides/issues/392).
Another solution, suggested by [@Azercoco](https://github.com/Azercoco) in
[#392 (comment)](https://github.com/jeertmans/manim-slides/issues/392#issuecomment-2368198106),
is to disable hardware/GPU acceleration.

View File

@ -23,10 +23,10 @@ using Manim Slides presentations.
Daniel publishes his presentations on *Cosmology, String Theory and related*
topics on his
[personal website](https://panopepino.github.io/Web_Page/main_page/slides.html). https://panopepino.github.io/Web_Page/main_page/slides.html
[personal website](https://panopepino.github.io/web_page/main_page/slides.html). https://panopepino.github.io/web_page/main_page/slides.html
For example, below are the slides of a seminar he gave titled
[Our Universe on a (Dark) Bubble](https://panopepino.github.io/Web_Page/main_page/presentations/2023_11_long/LS.html).
[Our Universe on a (Dark) Bubble](https://panopepino.github.io/web_page/main_page/presentations/2023_11_long/LS.html).
<div style="position:relative;padding-bottom:56.25%;">
<iframe
@ -37,12 +37,12 @@ For example, below are the slides of a seminar he gave titled
height="100%"
allowfullscreen
allow="autoplay"
src="https://panopepino.github.io/Web_Page/main_page/presentations/2023_11_long/LS.html">
src="https://panopepino.github.io/web_page/main_page/presentations/2023_11_long/LS.html">
</iframe>
</div>
He also shares his code on a public
[GitHub repository](https://github.com/PanoPepino/Manim_Theoretical).
[GitHub repository](https://github.com/PanoPepino/mtheoretical).
### Jérome Eertmans

View File

@ -29,19 +29,6 @@ please refer to their specific installation guidelines:
- [Manim](https://docs.manim.community/en/stable/installation.html)
- [ManimGL](https://3b1b.github.io/manim/getting_started/installation.html)
:::{warning}
If you install Manim from its git repository, as suggested by ManimGL,
make sure to first check out a supported version (e.g., `git checkout tags/v1.6.1`
for ManimGL), otherwise it might install an unsupported version of Manim!
See [#314](https://github.com/jeertmans/manim-slides/issues/314).
Also, note that ManimGL uses outdated dependencies, and may
not work out-of-the-box. One example is NumPy: ManimGL
does not specify any restriction on this package, but
only `numpy<1.25` will work, see
[#2053](https://github.com/3b1b/manim/issues/2053).
:::
<!-- end deps -->
## Pip Install
@ -113,10 +100,7 @@ using optional dependencies:
and does not work with ManimGL;
- `manim` and `manimgl`, for installing the corresponding
dependencies;
- `pyqt6` to include PyQt6 Qt bindings. Those bindings are available
on most platforms and Python version, but produce a weird black
screen between slide with `manim-slides present`,
see [#QTBUG-118501](https://bugreports.qt.io/browse/QTBUG-118501);
- `pyqt6` to include PyQt6 Qt bindings;
- `pyqt6-full` to include `full` and `pyqt6`;
- `pyside6` to include PySide6 Qt bindings. Those bindings are available
on most platforms and Python version, except on Python 3.12[^2];
@ -140,12 +124,12 @@ Manim Slides is distributed under Nixpkgs >=24.05.
If you are using Nix or NixOS, you can find Manim Slides under:
- `nixpkgs.manim-slides`, which is meant to be a stand alone application and
includes pyqt6 (see above);
includes PyQt6 (see above);
- `nixpkgs.python3Packages.manim-slides`, which is meant to be used as a
module (for notebook magics), and includes IPython but not does not include
any Qt bindings.
module (for notebook magics), and includes IPython but does not include
any Qt binding.
You can try out the Manim Slides package with
You can try out the Manim Slides package with:
```sh
nix-shell -p manim ffmpeg manim-slides
@ -163,7 +147,7 @@ nix-shell -p manim ffmpeg "python3.withPackages(ps: with ps; [ manim-slides, ...
or bundle this into [your Nix environment](https://wiki.nixos.org/wiki/Python).
:::{note}
Nix current does not support `manimgl`.
Nix does not currently support `manimgl`.
:::
## When you need a Qt backend

View File

@ -18,6 +18,8 @@ use, not the methods used internally when rendering.
next_section,
next_slide,
remove_from_canvas,
start_skip_animations,
stop_skip_animations,
wait_time_between_slides,
wipe,
zoom,

View File

@ -8,3 +8,30 @@ This page contains an exhaustive list of all the commands available with `manim-
:prog: manim-slides
:nested: full
```
## All config options
Each converter has its own configuration options, which are listed below.
::::{dropdown} HTML
```{program-output} manim-slides convert --to=html --show-config
```
::::
::::{dropdown} Zip
:::{note}
The Zip converter inherits from the HTML converter.
:::
```{program-output} manim-slides convert --to=zip --show-config
```
::::
::::{dropdown} PDF
```{program-output} manim-slides convert --to=pdf --show-config
```
::::
::::{dropdown} HTML
```{program-output} manim-slides convert --to=pdf --show-config
```
::::

View File

@ -78,9 +78,9 @@
],
"metadata": {
"kernelspec": {
"display_name": "manim-slides",
"display_name": ".venv",
"language": "python",
"name": "manim-slides"
"name": "python3"
},
"language_info": {
"codemirror_mode": {
@ -92,7 +92,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.10.6"
"version": "3.11.8"
}
},
"nbformat": 4,

View File

@ -137,9 +137,10 @@ and it there to preserve the original aspect ratio (16:9).
### Sharing ONE HTML file
If you set the `data_uri` option to `true` (with `-cdata_uri=true`),
all animations will be data URI encoded, making the HTML a self-contained
presentation file that can be shared on its own.
If you set the `--one-file` flag, all animations will be data URI encoded,
making the HTML a self-contained presentation file that can be shared
on its own. If you also set the `--offline` flag, the JS and CSS files will
be included in the HTML file as well.
### Over the internet
@ -166,7 +167,7 @@ Pages. Please refer to the template page for usage instructions.
### With PowerPoint (*EXPERIMENTAL*)
A recent conversion feature is to the PowerPoint format, thanks to the
A convenient 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.

View File

@ -53,7 +53,7 @@ class ConvertExample(Slide):
self.next_slide()
code = Code(
code="""from manim import *
code_string="""from manim import *
class Example(Scene):
@ -72,7 +72,7 @@ class Example(Scene):
)
code_step_1 = Code(
code="""from manim import *
code_string="""from manim import *
from manim_slides import Slide
class Example(Scene):
@ -91,7 +91,7 @@ class Example(Scene):
)
code_step_2 = Code(
code="""from manim import *
code_string="""from manim import *
from manim_slides import Slide
class Example(Slide):
@ -110,7 +110,7 @@ class Example(Slide):
)
code_step_3 = Code(
code="""from manim import *
code_string="""from manim import *
from manim_slides import Slide
class Example(Slide):
@ -129,7 +129,7 @@ class Example(Slide):
)
code_step_4 = Code(
code="""from manim import *
code_string="""from manim import *
from manim_slides import Slide
class Example(Slide):
@ -148,19 +148,19 @@ class Example(Slide):
)
code_step_5 = Code(
code="manim-slide render example.py Example",
code_string="manim-slide render example.py Example",
language="console",
)
code_step_6 = Code(
code="manim-slides Example",
code_string="manim-slides Example",
language="console",
)
or_text = Text("or generate HTML presentation").scale(0.5)
code_step_7 = Code(
code="manim-slides convert Example slides.html --open",
code_string="manim-slides convert Example slides.html --open",
language="console",
).shift(DOWN)

View File

@ -2,5 +2,72 @@
from .cli.commands import main
from .__version__ import __version__
from .checkhealth import checkhealth
from .convert import convert
from .logger import logger
from .present import list_scenes, present
from .render import render
from .wizard import init, wizard
@click.group(cls=DefaultGroup, default="present", default_if_no_args=True)
@click.option(
"--notify-outdated-version/--silent",
" /-S",
is_flag=True,
default=True,
help="Check if a new version of Manim Slides is available.",
)
@click.version_option(__version__, "-v", "--version")
@click.help_option("-h", "--help")
def cli(notify_outdated_version: bool) -> None:
"""
Manim Slides command-line utilities.
If no command is specified, defaults to `present`.
"""
# Code below is mostly a copy from:
# https://github.com/ManimCommunity/manim/blob/main/manim/cli/render/commands.py
if notify_outdated_version:
manim_info_url = "https://pypi.org/pypi/manim-slides/json"
warn_prompt = "Cannot check if latest release of Manim Slides is installed"
try:
req_info: requests.models.Response = requests.get(manim_info_url, timeout=2)
req_info.raise_for_status()
stable = req_info.json()["info"]["version"]
if stable != __version__:
click.echo(
"You are using Manim Slides version "
+ click.style(f"v{__version__}", fg="red")
+ ", but version "
+ click.style(f"v{stable}", fg="green")
+ " is available."
)
click.echo(
"You should consider upgrading via "
+ click.style("pip install -U manim-slides", fg="yellow")
)
except requests.exceptions.HTTPError:
logger.debug(f"HTTP Error: {warn_prompt}")
except requests.exceptions.ConnectionError:
logger.debug(f"Connection Error: {warn_prompt}")
except requests.exceptions.Timeout:
logger.debug(f"Timed Out: {warn_prompt}")
except json.JSONDecodeError:
logger.debug(warn_prompt)
logger.debug(f"Error decoding JSON from {manim_info_url}")
except Exception:
logger.debug(f"Something went wrong: {warn_prompt}")
cli.add_command(convert)
cli.add_command(checkhealth)
cli.add_command(init)
cli.add_command(list_scenes)
cli.add_command(present)
cli.add_command(render)
cli.add_command(wizard)
if __name__ == "__main__":
main()

View File

@ -1,3 +1,3 @@
"""Manim Slides' version."""
__version__ = "5.1.7"
__version__ = "5.4.2"

View File

@ -0,0 +1,37 @@
import sys
import click
from .__version__ import __version__
@click.command()
def checkhealth() -> None:
"""Check Manim Slides' installation."""
click.echo(f"Manim Slides version: {__version__}")
click.echo(f"Python executable: {sys.executable}")
click.echo("Manim bindings:")
try:
from manim import __version__ as manimce_version
click.echo(f"\tmanim (version: {manimce_version})")
except ImportError:
click.secho("\tmanim not found", bold=True)
try:
from manimlib import __version__ as manimlib_version
click.echo(f"\tmanimgl (version: {manimlib_version})")
except ImportError:
click.secho("\tmanimgl not found", bold=True)
try:
from qtpy import API, QT_VERSION
click.echo(f"Qt API: {API} (version: {QT_VERSION})")
except ImportError:
click.secho(
"No Qt API found, some Manim Slides commands will not be available",
bold=True,
)

View File

@ -1,8 +1,11 @@
import mimetypes
import os
import platform
import shutil
import subprocess
import tempfile
import textwrap
import warnings
import webbrowser
from base64 import b64encode
from collections import deque
@ -14,6 +17,8 @@ from typing import Any, Callable, Optional, Union
import av
import click
import pptx
import requests
from bs4 import BeautifulSoup
from click import Context, Parameter
from jinja2 import Template
from lxml import etree
@ -27,7 +32,6 @@ from pydantic import (
PositiveFloat,
PositiveInt,
ValidationError,
conlist,
)
from pydantic_core import CoreSchema, core_schema
from pydantic_extra_types.color import Color
@ -103,9 +107,12 @@ def read_image_from_video_file(file: Path, frame_index: "FrameIndex") -> Image:
class Converter(BaseModel): # type: ignore
presentation_configs: conlist(PresentationConfig, min_length=1) # type: ignore[valid-type]
assets_dir: str = "{basename}_assets"
template: Optional[Path] = None
presentation_configs: list[PresentationConfig]
assets_dir: str = Field(
"{basename}_assets",
description="Assets folder.\nThis is a template string that accepts 'dirname', 'basename', and 'ext' as variables.\nThose variables are obtained from the output filename.",
)
template: Optional[Path] = Field(None, description="Custom template file to use.")
def convert_to(self, dest: Path) -> None:
"""Convert self, i.e., a list of presentations, into a given format."""
@ -119,9 +126,9 @@ class Converter(BaseModel): # type: ignore
"""
return ""
def open(self, file: Path) -> Any:
def open(self, file: Path) -> None:
"""Open a file, generated with converter, using appropriate application."""
raise NotImplementedError
open_with_default(file)
@classmethod
def from_string(cls, s: str) -> type["Converter"]:
@ -130,6 +137,7 @@ class Converter(BaseModel): # type: ignore
"html": RevealJS,
"pdf": PDF,
"pptx": PowerPoint,
"zip": HtmlZip,
}[s]
@ -290,49 +298,153 @@ class RevealTheme(str, StrEnum):
class RevealJS(Converter):
# Export option: use data-uri
data_uri: bool = False
"""
RevealJS options.
Please check out https://revealjs.com/config/ for more details.
"""
# Export option:
one_file: bool = Field(
False, description="Embed all assets (e.g., animations) inside the HTML."
)
offline: bool = Field(
False, description="Download remote assets for offline presentation."
)
# Presentation size options from RevealJS
width: Union[Str, int] = Str("100%")
height: Union[Str, int] = Str("100%")
margin: float = 0.04
min_scale: float = 0.2
max_scale: float = 2.0
width: Union[Str, int] = Field(
Str("100%"), description="Width of the presentation."
)
height: Union[Str, int] = Field(
Str("100%"), description="Height of the presentation."
)
margin: float = Field(0.04, description="Margin to use around the content.")
min_scale: float = Field(
0.2, description="Bound for smallest possible scale to apply to content."
)
max_scale: float = Field(
2.0, description="Bound for large possible scale to apply to content."
)
# Configuration options from RevealJS
controls: JsBool = JsBool.false
controls_tutorial: JsBool = JsBool.true
controls_layout: ControlsLayout = ControlsLayout.bottom_right
controls_back_arrows: ControlsBackArrows = ControlsBackArrows.faded
progress: JsBool = JsBool.false
slide_number: SlideNumber = SlideNumber.false
show_slide_number: Union[ShowSlideNumber, Function] = ShowSlideNumber.all
hash_one_based_index: JsBool = JsBool.false
hash: JsBool = JsBool.false
respond_to_hash_changes: JsBool = JsBool.false
history: JsBool = JsBool.false
keyboard: JsBool = JsBool.true
keyboard_condition: Union[KeyboardCondition, Function] = KeyboardCondition.null
disable_layout: JsBool = JsBool.false
overview: JsBool = JsBool.true
center: JsBool = JsBool.true
touch: JsBool = JsBool.true
loop: JsBool = JsBool.false
rtl: JsBool = JsBool.false
navigation_mode: NavigationMode = NavigationMode.default
shuffle: JsBool = JsBool.false
fragments: JsBool = JsBool.true
fragment_in_url: JsBool = JsBool.true
embedded: JsBool = JsBool.false
help: JsBool = JsBool.true
pause: JsBool = JsBool.true
show_notes: JsBool = JsBool.false
auto_play_media: AutoPlayMedia = AutoPlayMedia.null
preload_iframes: PreloadIframes = PreloadIframes.null
auto_animate: JsBool = JsBool.true
auto_animate_matcher: Union[AutoAnimateMatcher, Function] = AutoAnimateMatcher.null
auto_animate_easing: AutoAnimateEasing = AutoAnimateEasing.ease
auto_animate_duration: float = 1.0
auto_animate_unmatched: JsBool = JsBool.true
controls: JsBool = Field(
JsBool.false, description="Display presentation control arrows."
)
controls_tutorial: JsBool = Field(
JsBool.true, description="Help the user learn the controls by providing hints."
)
controls_layout: ControlsLayout = Field(
ControlsLayout.bottom_right, description="Determine where controls appear."
)
controls_back_arrows: ControlsBackArrows = Field(
ControlsBackArrows.faded,
description="Visibility rule for backwards navigation arrows.",
)
progress: JsBool = Field(
JsBool.false, description="Display a presentation progress bar."
)
slide_number: SlideNumber = Field(
SlideNumber.false, description="Display the page number of the current slide."
)
show_slide_number: Union[ShowSlideNumber, Function] = Field(
ShowSlideNumber.all,
description="Can be used to limit the contexts in which the slide number appears.",
)
hash_one_based_index: JsBool = Field(
JsBool.false,
description="Use 1 based indexing for # links to match slide number (default is zero based).",
)
hash: JsBool = Field(
JsBool.false,
description="Add the current slide number to the URL hash so that reloading the page/copying the URL will return you to the same slide.",
)
respond_to_hash_changes: JsBool = Field(
JsBool.false,
description="Flags if we should monitor the hash and change slides accordingly.",
)
jump_to_slide: JsBool = Field(
JsBool.true,
description="Enable support for jump-to-slide navigation shortcuts.",
)
history: JsBool = Field(
JsBool.false,
description="Push each slide change to the browser history. Implies `hash: true`.",
)
keyboard: JsBool = Field(
JsBool.true, description="Enable keyboard shortcuts for navigation."
)
keyboard_condition: Union[KeyboardCondition, Function] = Field(
KeyboardCondition.null,
description="Optional function that blocks keyboard events when retuning false.",
)
disable_layout: JsBool = Field(
JsBool.false,
description="Disable the default reveal.js slide layout (scaling and centering) so that you can use custom CSS layout.",
)
overview: JsBool = Field(JsBool.true, description="Enable the slide overview mode.")
center: JsBool = Field(JsBool.true, description="Vertical centering of slides.")
touch: JsBool = Field(
JsBool.true, description="Enable touch navigation on devices with touch input."
)
loop: JsBool = Field(JsBool.false, description="Loop the presentation.")
rtl: JsBool = Field(
JsBool.false, description="Change the presentation direction to be RTL."
)
navigation_mode: NavigationMode = Field(
NavigationMode.default,
description="Change the behavior of our navigation directions.",
)
shuffle: JsBool = Field(
JsBool.false,
description="Randomize the order of slides each time the presentation loads.",
)
fragments: JsBool = Field(
JsBool.true, description="Turns fragment on and off globally."
)
fragment_in_url: JsBool = Field(
JsBool.true,
description="Flag whether to include the current fragment in the URL, so that reloading brings you to the same fragment position.",
)
embedded: JsBool = Field(
JsBool.false,
description="Flag if the presentation is running in an embedded mode, i.e. contained within a limited portion of the screen.",
)
help: JsBool = Field(
JsBool.true,
description="Flag if we should show a help overlay when the question-mark key is pressed.",
)
pause: JsBool = Field(
JsBool.true,
description="Flag if it should be possible to pause the presentation (blackout).",
)
show_notes: JsBool = Field(
JsBool.false,
description="Flag if speaker notes should be visible to all viewers.",
)
auto_play_media: AutoPlayMedia = Field(
AutoPlayMedia.null,
description="Global override for autolaying embedded media (video/audio/iframe).",
)
preload_iframes: PreloadIframes = Field(
PreloadIframes.null,
description="Global override for preloading lazy-loaded iframes.",
)
auto_animate: JsBool = Field(
JsBool.true, description="Can be used to globally disable auto-animation."
)
auto_animate_matcher: Union[AutoAnimateMatcher, Function] = Field(
AutoAnimateMatcher.null,
description="Optionally provide a custom element matcher that will be used to dictate which elements we can animate between.",
)
auto_animate_easing: AutoAnimateEasing = Field(
AutoAnimateEasing.ease,
description="Default settings for our auto-animate transitions, can be overridden per-slide or per-element via data arguments.",
)
auto_animate_duration: float = Field(
1.0, description="See 'auto_animate_easing' documentation."
)
auto_animate_unmatched: JsBool = Field(
JsBool.true, description="See 'auto_animate_easing' documentation."
)
auto_animate_styles: list[str] = Field(
default_factory=lambda: [
"opacity",
@ -347,34 +459,88 @@ class RevealJS(Converter):
"border-radius",
"outline",
"outline-offset",
]
],
description="CSS properties that can be auto-animated.",
)
auto_slide: AutoSlide = Field(
0, description="Control automatic progression to the next slide."
)
auto_slide_stoppable: JsBool = Field(
JsBool.true, description="Stop auto-sliding after user input."
)
auto_slide_method: Union[AutoSlideMethod, Function] = Field(
AutoSlideMethod.null,
description="Use this method for navigation when auto-sliding (defaults to navigateNext).",
)
default_timing: Union[JsNull, int] = Field(
JsNull.null,
description="Specify the average time in seconds that you think you will spend presenting each slide.",
)
mouse_wheel: JsBool = Field(
JsBool.false, description="Enable slide navigation via mouse wheel."
)
preview_links: JsBool = Field(
JsBool.false, description="Open links in an iframe preview overlay."
)
post_message: JsBool = Field(
JsBool.true, description="Expose the reveal.js API through window.postMessage."
)
post_message_events: JsBool = Field(
JsBool.false,
description="Dispatch all reveal.js events to the parent window through postMessage.",
)
focus_body_on_page_visibility_change: JsBool = Field(
JsBool.true,
description="Focus body when page changes visibility to ensure keyboard shortcuts work.",
)
transition: Transition = Field(Transition.none, description="Transition style.")
transition_speed: TransitionSpeed = Field(
TransitionSpeed.default, description="Transition speed."
)
background_size: BackgroundSize = Field(
BackgroundSize.contain, description="Background size attribute for each video."
) # Not in RevealJS
background_transition: BackgroundTransition = Field(
BackgroundTransition.none,
description="Transition style for full page slide backgrounds.",
)
pdf_max_pages_per_slide: Union[int, str] = Field(
"Number.POSITIVE_INFINITY",
description="The maximum number of pages a single slide can expand onto when printing to PDF, unlimited by default.",
)
pdf_separate_fragments: JsBool = Field(
JsBool.true, description="Print each fragment on a separate slide."
)
pdf_page_height_offset: int = Field(
-1,
description="Offset used to reduce the height of content within exported PDF pages.",
)
view_distance: int = Field(
3, description="Number of slides away from the current that are visible."
)
mobile_view_distance: int = Field(
2,
description="Number of slides away from the current that are visible on mobile devices.",
)
display: Display = Field(
Display.block, description="The display mode that will be used to show slides."
)
hide_inactive_cursor: JsBool = Field(
JsBool.true, description="Hide cursor if inactive."
)
hide_cursor_time: int = Field(
5000, description="Time before the cursor is hidden (in ms)."
)
auto_slide: AutoSlide = 0
auto_slide_stoppable: JsBool = JsBool.true
auto_slide_method: Union[AutoSlideMethod, Function] = AutoSlideMethod.null
default_timing: Union[JsNull, int] = JsNull.null
mouse_wheel: JsBool = JsBool.false
preview_links: JsBool = JsBool.false
post_message: JsBool = JsBool.true
post_message_events: JsBool = JsBool.false
focus_body_on_page_visibility_change: JsBool = JsBool.true
transition: Transition = Transition.none
transition_speed: TransitionSpeed = TransitionSpeed.default
background_size: BackgroundSize = BackgroundSize.contain # Not in RevealJS
background_transition: BackgroundTransition = BackgroundTransition.none
pdf_max_pages_per_slide: Union[int, str] = "Number.POSITIVE_INFINITY"
pdf_separate_fragments: JsBool = JsBool.true
pdf_page_height_offset: int = -1
view_distance: int = 3
mobile_view_distance: int = 2
display: Display = Display.block
hide_inactive_cursor: JsBool = JsBool.true
hide_cursor_time: int = 5000
# Appearance options from RevealJS
background_color: Color = "black"
reveal_version: str = "5.1.0"
reveal_theme: RevealTheme = RevealTheme.black
title: str = "Manim Slides"
background_color: Color = Field(
"black",
description="Background color used in slides, not relevant if videos fill the whole area.",
)
reveal_version: str = Field("5.1.0", description="RevealJS version.")
reveal_theme: RevealTheme = Field(
RevealTheme.black, description="RevealJS version."
)
title: str = Field("Manim Slides", description="Presentation title.")
# Pydantic options
model_config = ConfigDict(use_enum_values=True, extra="forbid")
@ -385,35 +551,32 @@ class RevealJS(Converter):
return resources.files(templates).joinpath("revealjs.html").read_text()
def open(self, file: Path) -> bool:
def open(self, file: Path) -> None:
"""
Open the HTML file inside a web browser.
:param path: The path to the HTML file.
"""
return webbrowser.open(file.absolute().as_uri())
webbrowser.open(file.absolute().as_uri())
def convert_to(self, dest: Path) -> None:
def convert_to(self, dest: Path) -> None: # noqa: C901
"""
Convert 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
if not self.one_file or self.offline:
logger.debug(f"Assets will be saved to: {full_assets_dir}")
full_assets_dir.mkdir(parents=True, exist_ok=True)
if not self.one_file:
num_presentation_configs = len(self.presentation_configs)
if num_presentation_configs > 1:
@ -432,6 +595,7 @@ class RevealJS(Converter):
def prefix(i: int) -> str:
return ""
full_assets_dir.mkdir(parents=True, exist_ok=True)
for i, presentation_config in enumerate(self.presentation_configs):
presentation_config.copy_to(
full_assets_dir, include_reversed=False, prefix=prefix(i)
@ -440,10 +604,14 @@ class RevealJS(Converter):
dest.parent.mkdir(parents=True, exist_ok=True)
with open(dest, "w") as f:
revealjs_template = Template(self.load_template())
revealjs_template = Template(
self.load_template(), trim_blocks=True, lstrip_blocks=True
)
options = self.dict()
options["assets_dir"] = assets_dir
options = self.model_dump()
if assets_dir is not None:
options["assets_dir"] = assets_dir
has_notes = any(
slide_config.notes != ""
@ -456,25 +624,89 @@ class RevealJS(Converter):
get_duration_ms=get_duration_ms,
has_notes=has_notes,
env=os.environ,
prefix=prefix if not self.one_file else None,
**options,
)
# If not offline, write the content to the file
if not self.offline:
f.write(content)
return
# If offline, download remote assets and store them in the assets folder
soup = BeautifulSoup(content, "html.parser")
session = requests.Session()
for tag, inner in [("link", "href"), ("script", "src")]:
for item in soup.find_all(tag):
if item.has_attr(inner) and (link := item[inner]).startswith(
"http"
):
asset_name = link.rsplit("/", 1)[1]
asset = session.get(link)
if self.one_file:
# If it is a CSS file, inline it
if tag == "link" and "stylesheet" in item["rel"]:
item.decompose()
style = soup.new_tag("style")
style.string = asset.text
soup.head.append(style)
# If it is a JS file, inline it
elif tag == "script":
item.decompose()
script = soup.new_tag("script")
script.string = asset.text
soup.head.append(script)
else:
raise ValueError(
f"Unable to inline {tag} asset: {link}"
)
else:
full_assets_dir.mkdir(parents=True, exist_ok=True)
with open(full_assets_dir / asset_name, "wb") as asset_file:
asset_file.write(asset.content)
item[inner] = str(assets_dir / asset_name)
content = str(soup)
f.write(content)
class HtmlZip(RevealJS):
def open(self, file: Path) -> None:
super(RevealJS, self).open(file) # Override opening with web browser
def convert_to(self, dest: Path) -> None:
"""
Convert this configuration into a zipped RevealJS HTML presentation, saved to
DEST.
"""
with tempfile.TemporaryDirectory() as directory_name:
directory = Path(directory_name)
html_file = directory / dest.with_suffix(".html").name
super().convert_to(html_file)
shutil.make_archive(str(dest.with_suffix("")), "zip", directory_name)
class FrameIndex(str, Enum):
first = "first"
last = "last"
def __repr__(self) -> str:
return self.value
class PDF(Converter):
frame_index: FrameIndex = FrameIndex.last
resolution: PositiveFloat = 100.0
frame_index: FrameIndex = Field(
FrameIndex.last,
description="What frame (first or last) is used to represent each slide.",
)
resolution: PositiveFloat = Field(
100.0, description="Image resolution use for saving frames."
)
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:
"""Convert this configuration into a PDF presentation, saved to DEST."""
images = []
@ -501,17 +733,30 @@ class PDF(Converter):
class PowerPoint(Converter):
left: PositiveInt = 0
top: PositiveInt = 0
width: PositiveInt = 1280
height: PositiveInt = 720
auto_play_media: bool = True
poster_frame_image: Optional[FilePath] = None
left: PositiveInt = Field(
0, description="Horizontal offset where the video is placed from left border."
)
top: PositiveInt = Field(
0, description="Vertical offset where the video is placed from top border."
)
width: PositiveInt = Field(
1280,
description="Width of the slides.\nThis should match the resolution of the presentation.",
)
height: PositiveInt = Field(
720,
description="Height of the slides.\nThis should match the resolution of the presentation.",
)
auto_play_media: bool = Field(
True, description="Automatically play animations when changing slide."
)
poster_frame_image: Optional[FilePath] = Field(
None,
description="Optional image to use when animations are not playing.\n"
"By default, the first frame of each animation is used.\nThis is important to avoid blinking effects between slides.",
)
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:
"""Convert this configuration into a PowerPoint presentation, saved to DEST."""
prs = pptx.Presentation()
@ -586,22 +831,39 @@ class PowerPoint(Converter):
def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
"""Wrap a function to add a `--show-config` option."""
"""Wrap a function to add a '--show-config' option."""
def callback(ctx: Context, param: Parameter, value: bool) -> None:
def callback(ctx: Context, _param: Parameter, value: bool) -> None:
if not value or ctx.resilient_parsing:
return
to = ctx.params.get("to", "html")
if "to" in ctx.params:
to = ctx.params["to"]
cls = Converter.from_string(to)
elif "dest" in ctx.params:
dest = Path(ctx.params["dest"])
fmt = dest.suffix[1:].lower()
try:
cls = Converter.from_string(fmt)
except KeyError:
logger.warning(
f"Could not guess conversion format from {dest!s}, defaulting to HTML."
)
cls = RevealJS
else:
cls = RevealJS
converter = Converter.from_string(to)
if doc := getattr(cls, "__doc__", ""):
click.echo(textwrap.dedent(doc))
for key, field in converter.model_fields.items():
for key, field in cls.model_fields.items():
if field.is_required():
continue
default = field.get_default(call_default_factory=True)
click.echo(f"{key}: {default}")
click.echo(click.style(key, bold=True) + f": {default}")
if description := field.description:
click.secho(textwrap.indent(description, prefix="# "), dim=True)
ctx.exit()
@ -617,18 +879,31 @@ def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
"""Wrap a function to add a `--show-template` option."""
"""Wrap a function to add a '--show-template' option."""
def callback(ctx: Context, param: Parameter, value: bool) -> None:
if not value or ctx.resilient_parsing:
return
to = ctx.params.get("to", "html")
if "to" in ctx.params:
to = ctx.params["to"]
cls = Converter.from_string(to)
elif "dest" in ctx.params:
dest = Path(ctx.params["dest"])
fmt = dest.suffix[1:].lower()
try:
cls = Converter.from_string(fmt)
except KeyError:
logger.warning(
f"Could not guess conversion format from {dest!s}, defaulting to HTML."
)
cls = RevealJS
else:
cls = RevealJS
template = ctx.params.get("template", None)
converter = Converter.from_string(to)(
presentation_configs=[PresentationConfig()], template=template
)
converter = cls(presentation_configs=[], template=template)
click.echo(converter.load_template())
ctx.exit()
@ -650,7 +925,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(["auto", "html", "pdf", "pptx"], case_sensitive=False),
type=click.Choice(["auto", "html", "pdf", "pptx", "zip"], case_sensitive=False),
metavar="FORMAT",
default="auto",
show_default=True,
@ -662,7 +937,6 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
is_flag=True,
help="Open the newly created file using the appropriate application.",
)
@click.option("-f", "--force", is_flag=True, help="Overwrite any existing file.")
@click.option(
"-c",
"--config",
@ -670,7 +944,7 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
multiple=True,
callback=validate_config_option,
help="Configuration options passed to the converter. "
"E.g., pass ``-cslide_number=true`` to display slide numbers.",
"E.g., pass '-cslide_number=true' to display slide numbers.",
)
@click.option(
"--use-template",
@ -678,7 +952,19 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
metavar="FILE",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
help="Use the template given by FILE instead of default one. "
"To echo the default template, use ``--show-template``.",
"To echo the default template, use '--show-template'.",
)
@click.option(
"--one-file",
is_flag=True,
help="Embed all local assets (e.g., video files) in the output file. "
"The is a convenient alias to '-cone_file=true'.",
)
@click.option(
"--offline",
is_flag=True,
help="Download any remote content and store it in the assets folder. "
"The is a convenient alias to '-coffline=true'.",
)
@show_template_option
@show_config_options
@ -689,9 +975,10 @@ def convert(
dest: Path,
to: str,
open_result: bool,
force: bool,
config_options: dict[str, str],
template: Optional[Path],
offline: bool,
one_file: bool,
) -> None:
"""Convert SCENE(s) into a given format and writes the result in DEST."""
presentation_configs = [PresentationConfig.from_file(scene) for scene in scenes]
@ -702,13 +989,42 @@ def convert(
try:
cls = Converter.from_string(fmt)
except KeyError:
logger.warn(
logger.warning(
f"Could not guess conversion format from {dest!s}, defaulting to HTML."
)
cls = RevealJS
else:
cls = Converter.from_string(to)
if (
one_file
and issubclass(cls, (RevealJS, HtmlZip))
and "one_file" not in config_options
):
config_options["one_file"] = "true"
# Change data_uri to one_file and print a warning if present
if "data_uri" in config_options:
warnings.warn(
"The 'data_uri' configuration option is deprecated and will be "
"removed in the next major version. "
"Use 'one_file' instead.",
DeprecationWarning,
stacklevel=2,
)
config_options["one_file"] = (
config_options["one_file"]
if "one_file" in config_options
else config_options.pop("data_uri")
)
if (
offline
and issubclass(cls, (RevealJS, HtmlZip))
and "offline" not in config_options
):
config_options["offline"] = "true"
converter = cls(
presentation_configs=presentation_configs,
template=template,

View File

@ -12,89 +12,81 @@
<!-- Theme used for syntax highlighting of code -->
<!-- <link rel="stylesheet" href="lib/css/zenburn.css"> -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.13.1/styles/zenburn.min.css">
<!-- <link rel="stylesheet" href="index.css"> -->
</head>
<body>
<div class="reveal">
<div class="slides">
{%- for presentation_config in presentation_configs -%}
{% set outer_loop = loop %}
{%- for slide_config in presentation_config.slides -%}
{%- if data_uri -%}
{% for presentation_config in presentation_configs -%}
{%- set outer_loop = loop %}
{% for slide_config in presentation_config.slides %}
{% if one_file %}
{% set file = file_to_data_uri(slide_config.file) %}
{%- else -%}
{% set file = assets_dir / slide_config.file.name %}
{%- endif -%}
<section
data-background-size={{ background_size }}
data-background-color="{{ presentation_config.background_color }}"
data-background-video="{{ file }}"
{% if loop.index == 1 and outer_loop.index == 1 -%}
data-background-video-muted
{%- endif %}
{% if slide_config.loop -%}
data-background-video-loop
{%- endif -%}
{% if slide_config.auto_next -%}
data-autoslide="{{ get_duration_ms(slide_config.file) }}"
{%- endif -%}>
{% if slide_config.notes != "" -%}
<aside class="notes" data-markdown>{{ slide_config.notes }}</aside>
{%- endif %}
</section>
{%- endfor -%}
{%- endfor -%}
{% else %}
{% set file = assets_dir / (prefix(outer_loop.index0) + slide_config.file.name) %}
{% endif %}
<section
data-background-size={{ background_size }}
data-background-color="{{ presentation_config.background_color }}"
data-background-video="{{ file }}"
{% if loop.index == 1 and outer_loop.index == 1 %}
data-background-video-muted
{% endif %}
{% if slide_config.loop %}
data-background-video-loop
{% endif %}
{% if slide_config.auto_next %}
data-autoslide="{{ get_duration_ms(slide_config.file) }}"
{% endif %}
>
{% if slide_config.notes != "" %}
<aside class="notes" data-markdown>{{ slide_config.notes }}</aside>
{% endif %}
</section>
{% endfor %}
{% endfor %}
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/reveal.min.js"></script>
<!-- To include plugins, see: https://revealjs.com/plugins/ -->
{% if has_notes -%}
{% if has_notes %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/plugin/markdown/markdown.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/plugin/notes/notes.min.js"></script>
{%- endif -%}
{% endif %}
<!-- <script src="index.js"></script> -->
<script>
Reveal.initialize({
{% if has_notes -%}
plugins: [ RevealMarkdown, RevealNotes ],
{%- endif %}
{% if has_notes %}
/// The list of RevealJS plugins.
plugins: [ RevealMarkdown, RevealNotes ],
{% endif %}
// The "normal" size of the presentation, aspect ratio will
// be preserved when the presentation is scaled to fit different
// resolutions. Can be specified using percentage units.
width: {{ width }},
height: {{ height }},
// Factor of the display size that should remain empty around
// the content
margin: {{ margin }},
// Bounds for smallest/largest possible scale to apply to content
minScale: {{ min_scale }},
maxScale: {{ max_scale }},
// Display presentation control arrows
controls: {{ controls }},
// Help the user learn the controls by providing hints, for example by
// bouncing the down arrow when they first encounter a vertical slide
controlsTutorial: {{ controls_tutorial }},
// Determines where controls appear, "edges" or "bottom-right"
controlsLayout: {{ controls_layout }},
// Visibility rule for backwards navigation arrows; "faded", "hidden"
// or "visible"
controlsBackArrows: {{ controls_back_arrows }},
// Display a presentation progress bar
progress: {{ progress }},
// Display the page number of the current slide
// - true: Show slide number
// - false: Hide slide number
@ -110,55 +102,43 @@
// object and return an array with one string [slideNumber] or
// three strings [n1,delimiter,n2]. See #formatSlideNumber().
slideNumber: {{ slide_number }},
// Can be used to limit the contexts in which the slide number appears
// - "all": Always show the slide number
// - "print": Only when printing to PDF
// - "speaker": Only in the speaker view
showSlideNumber: {{ show_slide_number }},
// Use 1 based indexing for # links to match slide number (default is zero
// based)
hashOneBasedIndex: {{ hash_one_based_index }},
// Add the current slide number to the URL hash so that reloading the
// page/copying the URL will return you to the same slide
hash: {{ hash }},
// Flags if we should monitor the hash and change slides accordingly
respondToHashChanges: {{ respond_to_hash_changes }},
// Enable support for jump-to-slide navigation shortcuts
jumpToSlide: {{ jump_to_slide }},
// Push each slide change to the browser history. Implies `hash: true`
history: {{ history }},
// Enable keyboard shortcuts for navigation
keyboard: {{ keyboard }},
// Optional function that blocks keyboard events when retuning false
//
// If you set this to 'focused', we will only capture keyboard events
// for embedded decks when they are in focus
keyboardCondition: {{ keyboard_condition }},
// Disables the default reveal.js slide layout (scaling and centering)
// so that you can use custom CSS layout
disableLayout: {{ disable_layout }},
// Enable the slide overview mode
overview: {{ overview }},
// Vertical centering of slides
center: {{ center }},
// Enables touch navigation on devices with touch input
touch: {{ touch }},
// Loop the presentation
loop: {{ loop }},
// Change the presentation direction to be RTL
rtl: {{ rtl }},
// Changes the behavior of our navigation directions.
//
// "default"
@ -184,168 +164,154 @@
// from 1.3 -> 2.1. If "grid" is used, the same navigation takes you
// from 1.3 -> 2.3.
navigationMode: {{ navigation_mode }},
// Randomizes the order of slides each time the presentation loads
shuffle: {{ shuffle }},
// Turns fragments on and off globally
fragments: {{ fragments }},
// Flags whether to include the current fragment in the URL,
// so that reloading brings you to the same fragment position
fragmentInURL: {{ fragment_in_url }},
// Flags if the presentation is running in an embedded mode,
// i.e. contained within a limited portion of the screen
embedded: {{ embedded }},
// Flags if we should show a help overlay when the question-mark
// key is pressed
help: {{ help }},
// Flags if it should be possible to pause the presentation (blackout)
pause: {{ pause }},
// Flags if speaker notes should be visible to all viewers
showNotes: {{ show_notes }},
// Global override for autolaying embedded media (video/audio/iframe)
// - null: Media will only autoplay if data-autoplay is present
// - true: All media will autoplay, regardless of individual setting
// - false: No media will autoplay, regardless of individual setting
autoPlayMedia: {{ auto_play_media }},
// Global override for preloading lazy-loaded iframes
// - null: Iframes with data-src AND data-preload will be loaded when within
// the viewDistance, iframes with only data-src will be loaded when visible
// - true: All iframes with data-src will be loaded when within the viewDistance
// - false: All iframes with data-src will be loaded only when visible
preloadIframes: {{ preload_iframes }},
// Can be used to globally disable auto-animation
autoAnimate: {{ auto_animate }},
// Optionally provide a custom element matcher that will be
// used to dictate which elements we can animate between.
autoAnimateMatcher: {{ auto_animate_matcher }},
// Default settings for our auto-animate transitions, can be
// overridden per-slide or per-element via data arguments
autoAnimateEasing: {{ auto_animate_easing }},
autoAnimateDuration: {{ auto_animate_duration }},
autoAnimateUnmatched: {{ auto_animate_unmatched }},
// CSS properties that can be auto-animated. Position & scale
// is matched separately so there's no need to include styles
// like top/right/bottom/left, width/height or margin.
autoAnimateStyles: {{ auto_animate_styles }},
// Controls automatic progression to the next slide
// - 0: Auto-sliding only happens if the data-autoslide HTML attribute
// is present on the current slide or fragment
// - 1+: All slides will progress automatically at the given interval
// - false: No auto-sliding, even if data-autoslide is present
autoSlide: {{ auto_slide }},
// Stop auto-sliding after user input
autoSlideStoppable: {{ auto_slide_stoppable }},
// Use this method for navigation when auto-sliding (defaults to navigateNext)
autoSlideMethod: {{ auto_slide_method }},
// Specify the average time in seconds that you think you will spend
// presenting each slide. This is used to show a pacing timer in the
// speaker view
defaultTiming: {{ default_timing }},
// Enable slide navigation via mouse wheel
mouseWheel: {{ mouse_wheel }},
// Opens links in an iframe preview overlay
// Add `data-preview-link` and `data-preview-link="false"` to customize each link
// individually
previewLinks: {{ preview_links }},
// Exposes the reveal.js API through window.postMessage
postMessage: {{ post_message }},
// Dispatches all reveal.js events to the parent window through postMessage
postMessageEvents: {{ post_message_events }},
// Focuses body when page changes visibility to ensure keyboard shortcuts work
focusBodyOnPageVisibilityChange: {{ focus_body_on_page_visibility_change }},
// Transition style
transition: {{ transition }}, // none/fade/slide/convex/concave/zoom
// Transition speed
transitionSpeed: {{ transition_speed }}, // default/fast/slow
// Transition style for full page slide backgrounds
backgroundTransition: {{ background_transition }}, // none/fade/slide/convex/concave/zoom
// The maximum number of pages a single slide can expand onto when printing
// to PDF, unlimited by default
pdfMaxPagesPerSlide: {{ pdf_max_pages_per_slide }},
// Prints each fragment on a separate slide
pdfSeparateFragments: {{ pdf_separate_fragments }},
// Offset used to reduce the height of content within exported PDF pages.
// This exists to account for environment differences based on how you
// print to PDF. CLI printing options, like phantomjs and wkpdf, can end
// on precisely the total height of the document whereas in-browser
// printing has to end one pixel before.
pdfPageHeightOffset: {{ pdf_page_height_offset }},
// Number of slides away from the current that are visible
viewDistance: {{ view_distance }},
// Number of slides away from the current that are visible on mobile
// devices. It is advisable to set this to a lower number than
// viewDistance in order to save resources.
mobileViewDistance: {{ mobile_view_distance }},
// The display mode that will be used to show slides
display: {{ display }},
// Hide cursor if inactive
hideInactiveCursor: {{ hide_inactive_cursor }},
// Time before the cursor is hidden (in ms)
hideCursorTime: {{ hide_cursor_time }}
});
{% if one_file %}
// Fix found by @t-fritsch and @Rapsssito on GitHub
// see: https://github.com/hakimel/reveal.js/discussions/3362#discussioncomment-11733074.
function setVideoBase64(video) {
const sources = video.querySelectorAll('source');
// Update the source of the video
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}`);
}
});
}
{% if data_uri -%}
// Fix found by @t-fritsch on GitHub
// see: https://github.com/hakimel/reveal.js/discussions/3362#discussioncomment-6651475.
function fixBase64VideoBackground(event) {
// event.previousSlide, event.currentSlide, event.indexh, event.indexv
if (event.currentSlide.getAttribute('data-background-video')) {
const background = Reveal.getSlideBackground(event.indexh, event.indexv),
video = background.querySelector('video'),
sources = video.querySelectorAll('source');
sources.forEach((source, i) => {
const src = source.getAttribute('src');
if(src.match(/^data:video.*;base64$/)) {
const nextSrc = sources[i+1]?.getAttribute('src');
video.setAttribute('src', `${src},${nextSrc}`);
}
});
// Analyze all slides backgrounds
for (const slide of Reveal.getBackgroundsElement().querySelectorAll('.slide-background')) {
// Get the slide video and its sources for each background
const video = slide.querySelector('video');
if (video) {
setVideoBase64(video);
} else {
// Listen to the creation of the video element
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
for (const addedNode of mutation.addedNodes) {
if (addedNode.tagName === 'VIDEO') {
setVideoBase64(addedNode);
observer.disconnect(); // Stop observing once the video is handled
}
}
}
}
});
observer.observe(slide, { childList: true, subtree: true });
}
}
}
// Setup base64 videos
Reveal.on( 'ready', fixBase64VideoBackground );
Reveal.on( 'slidechanged', fixBase64VideoBackground );
{%- endif %}
{% endif %}
</script>
{% if env['READTHEDOCS'] -%}
<style>
readthedocs-flyout, readthedocs-notification {
display: none;
}
</style>
{%- endif %}
{% if env['READTHEDOCS'] %}
<style>
readthedocs-flyout, readthedocs-notification {
display: none;
}
</style>
{% endif %}
</body>
</html>

View File

@ -1,7 +1,7 @@
import signal
import sys
from pathlib import Path
from typing import Optional
from typing import Literal, Optional
import click
from click import Context, Parameter
@ -16,23 +16,6 @@ from ..commons import (
verbosity_option,
)
PREFERRED_QT_VERSIONS = ("6.5.1", "6.5.2")
def warn_if_non_desirable_pyside6_version() -> None:
from qtpy import API, QT_VERSION
if sys.version_info < (3, 12) and (
API != "pyside6" or QT_VERSION not in PREFERRED_QT_VERSIONS
):
logger.warn(
f"You are using {API = }, {QT_VERSION = }, "
"but we recommend installing 'PySide6==6.5.2', mainly to avoid "
"flashing screens between slides, "
"see issue https://github.com/jeertmans/manim-slides/issues/293. "
"You can do so with `pip install 'manim-slides[pyside6]'`."
)
@click.command()
@folder_path_option
@ -46,6 +29,81 @@ def list_scenes(folder: Path) -> None:
click.secho(f"{i:{num_digits}d}: {scene_name}", fg="green")
def _list_scenes(folder: Path) -> list[str]:
"""List available scenes in given directory."""
scenes = []
for filepath in folder.glob("*.json"):
try:
_ = PresentationConfig.from_file(filepath)
scenes.append(filepath.stem)
except (
Exception
) as e: # Could not parse this file as a proper presentation config
logger.warning(
f"Something went wrong with parsing presentation config `{filepath}`: {e}"
)
logger.debug(f"Found {len(scenes)} valid scene configuration files in `{folder}`.")
return scenes
def prompt_for_scenes(folder: Path) -> list[str]:
"""Prompt the user to select scenes within a given folder."""
scene_choices = dict(enumerate(_list_scenes(folder), start=1))
for i, scene in scene_choices.items():
click.secho(f"{i}: {scene}", fg="green")
click.echo()
click.echo("Choose number corresponding to desired scene/arguments.")
click.echo("(Use comma separated list for multiple entries)")
def value_proc(value: Optional[str]) -> list[str]:
indices = list(map(int, (value or "").strip().replace(" ", "").split(",")))
if not all(0 < i <= len(scene_choices) for i in indices):
raise click.UsageError("Please only enter numbers displayed on the screen.")
return [scene_choices[i] for i in indices]
if len(scene_choices) == 0:
raise click.UsageError(
"No scenes were found, are you in the correct directory?"
)
while True:
try:
scenes = click.prompt("Choice(s)", value_proc=value_proc)
return scenes # type: ignore
except ValueError as e:
raise click.UsageError(str(e)) from None
def get_scenes_presentation_config(
scenes: list[str], folder: Path
) -> list[PresentationConfig]:
"""Return a list of presentation configurations based on the user input."""
if len(scenes) == 0:
scenes = prompt_for_scenes(folder)
presentation_configs = []
for scene in scenes:
config_file = folder / f"{scene}.json"
if not config_file.exists():
raise click.UsageError(
f"File {config_file} does not exist, check the scene name and make sure to use Slide as your scene base class"
)
try:
presentation_configs.append(PresentationConfig.from_file(config_file))
except ValidationError as e:
raise click.UsageError(str(e)) from None
return presentation_configs
def start_at_callback(
ctx: Context, param: Parameter, values: str
) -> tuple[Optional[int], ...]:
@ -171,8 +229,14 @@ def start_at_callback(
)
@click.option(
"--hide-info-window",
is_flag=True,
help="Hide info window.",
flag_value="always",
help="Hide info window. By default, hide the info window if there is only one screen.",
)
@click.option(
"--show-info-window",
"hide_info_window",
flag_value="never",
help="Force to show info window.",
)
@click.option(
"--info-window-screen",
@ -180,12 +244,14 @@ def start_at_callback(
metavar="NUMBER",
type=int,
default=None,
help="Put info window on the given screen (a.k.a. display).",
help="Put info window on the given screen (a.k.a. display). "
"If there is more than one screen, it will by default put the info window "
"on a different screen than the main player.",
)
@click.help_option("-h", "--help")
@verbosity_option
def present(
scenes: list[Path],
def present( # noqa: C901
scenes: list[str],
config_path: Path,
folder: Path,
start_paused: bool,
@ -200,7 +266,7 @@ def present(
screen_number: Optional[int],
playback_rate: float,
next_terminates_loop: bool,
hide_info_window: bool,
hide_info_window: Optional[Literal["always", "never"]],
info_window_screen_number: Optional[int],
) -> None:
"""
@ -234,8 +300,6 @@ def present(
if start_at[1]:
start_at_slide_number = start_at[1]
warn_if_non_desirable_pyside6_version()
from qtpy.QtCore import Qt
from qtpy.QtGui import QScreen
@ -245,22 +309,36 @@ def present(
app = qapp()
app.setApplicationName("Manim Slides")
screens = app.screens()
def get_screen(number: int) -> Optional[QScreen]:
try:
return app.screens()[number]
return screens[number]
except IndexError:
logger.error(
f"Invalid screen number {number}, "
f"allowed values are from 0 to {len(app.screens())-1} (incl.)"
f"allowed values are from 0 to {len(screens) - 1} (incl.)"
)
return None
should_hide_info_window = False
if hide_info_window is None:
should_hide_info_window = len(screens) == 1
elif hide_info_window == "always":
should_hide_info_window = True
if should_hide_info_window and info_window_screen_number is not None:
logger.warning(
f"Ignoring `--info-window-screen` because `--hide-info-window` is set to `{hide_info_window}`."
)
if screen_number is not None:
screen = get_screen(screen_number)
else:
screen = None
if info_window_screen_number is not None:
if info_window_screen_number is not None and not should_hide_info_window:
info_window_screen = get_screen(info_window_screen_number)
else:
info_window_screen = None
@ -284,11 +362,11 @@ def present(
screen=screen,
playback_rate=playback_rate,
next_terminates_loop=next_terminates_loop,
hide_info_window=hide_info_window,
hide_info_window=should_hide_info_window,
info_window_screen=info_window_screen,
)
player.show()
player.show(screens)
signal.signal(signal.SIGINT, signal.SIG_DFL)
sys.exit(app.exec())

View File

@ -4,7 +4,7 @@ from typing import Optional
from qtpy.QtCore import Qt, QTimer, QUrl, Signal, Slot
from qtpy.QtGui import QCloseEvent, QIcon, QKeyEvent, QScreen
from qtpy.QtMultimedia import QAudioOutput, QMediaPlayer
from qtpy.QtMultimedia import QAudioOutput, QMediaPlayer, QVideoFrame
from qtpy.QtMultimediaWidgets import QVideoWidget
from qtpy.QtWidgets import (
QHBoxLayout,
@ -28,7 +28,6 @@ class Info(QWidget): # type: ignore[misc]
def __init__(
self,
*,
full_screen: bool,
aspect_ratio_mode: Qt.AspectRatioMode,
screen: Optional[QScreen],
) -> None:
@ -38,9 +37,6 @@ class Info(QWidget): # type: ignore[misc]
self.setScreen(screen)
self.move(screen.geometry().topLeft())
if full_screen:
self.setWindowState(Qt.WindowFullScreen)
layout = QHBoxLayout()
# Current slide view
@ -226,6 +222,8 @@ class Player(QMainWindow): # type: ignore[misc]
self.icon = QIcon(":/icon.png")
self.setWindowIcon(self.icon)
self.frame = QVideoFrame()
self.audio_output = QAudioOutput()
self.video_widget = QVideoWidget()
self.video_sink = self.video_widget.videoSink()
@ -241,15 +239,12 @@ class Player(QMainWindow): # type: ignore[misc]
self.slide_changed.connect(self.slide_changed_callback)
self.info = Info(
full_screen=full_screen,
aspect_ratio_mode=aspect_ratio_mode,
screen=info_window_screen,
)
self.info.close_event.connect(self.closeEvent)
self.info.key_press_event.connect(self.keyPressEvent)
self.video_sink.videoFrameChanged.connect(
lambda frame: self.info.video_sink.setVideoFrame(frame)
)
self.video_sink.videoFrameChanged.connect(self.frame_changed)
self.hide_info_window = hide_info_window
# Connecting key callbacks
@ -319,7 +314,7 @@ class Player(QMainWindow): # type: ignore[misc]
elif -self.presentations_count <= index < 0:
self.__current_presentation_index = index + self.presentations_count
else:
logger.warn(f"Could not set presentation index to {index}.")
logger.warning(f"Could not set presentation index to {index}.")
return
self.presentation_changed.emit()
@ -343,7 +338,7 @@ class Player(QMainWindow): # type: ignore[misc]
elif -self.current_slides_count <= index < 0:
self.__current_slide_index = index + self.current_slides_count
else:
logger.warn(f"Could not set slide index to {index}.")
logger.warning(f"Could not set slide index to {index}.")
return
self.slide_changed.emit()
@ -468,13 +463,13 @@ class Player(QMainWindow): # type: ignore[misc]
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}")
self.info.scene_label.setText(f"{index + 1:4d}/{count:4<d}")
@Slot()
def slide_changed_callback(self) -> None:
index = self.current_slide_index
count = self.current_slides_count
self.info.slide_label.setText(f"{index+1:4d}/{count:4<d}")
self.info.slide_label.setText(f"{index + 1:4d}/{count:4<d}")
self.info.slide_notes.setText(self.current_slide_config.notes)
self.preview_next_slide()
@ -484,11 +479,28 @@ class Player(QMainWindow): # type: ignore[misc]
self.info.next_media_player.setSource(url)
self.info.next_media_player.play()
def show(self) -> None:
def show(self, screens: list[QScreen]) -> None:
"""Screens is necessary to prevent the info window from being shown on the same screen as the main window (especially in full screen mode)."""
super().show()
if not self.hide_info_window:
self.info.show()
if len(screens) > 1 and self.isFullScreen():
self.ensure_different_screens(screens)
if self.isFullScreen():
self.info.showFullScreen()
else:
self.info.show()
if (
len(screens) > 1 and self.info.screen() == self.screen()
): # It is better when Qt assigns the location, but if it fails to, this is a fallback
self.ensure_different_screens(screens)
def ensure_different_screens(self, screens: list[QScreen]) -> None:
target_screen = screens[1] if self.screen() == screens[0] else screens[0]
self.info.setScreen(target_screen)
self.info.move(target_screen.geometry().topLeft())
@Slot()
def close(self) -> None:
@ -515,6 +527,9 @@ class Player(QMainWindow): # type: ignore[misc]
@Slot()
def reverse(self) -> None:
if self.playing_reversed_slide and self.current_slide_index >= 1:
self.current_slide_index -= 1
self.load_reversed_slide()
self.preview_next_slide()
@ -535,8 +550,10 @@ class Player(QMainWindow): # type: ignore[misc]
def full_screen(self) -> None:
if self.windowState() == Qt.WindowFullScreen:
self.setWindowState(Qt.WindowNoState)
self.info.setWindowState(Qt.WindowNoState)
else:
self.setWindowState(Qt.WindowFullScreen)
self.info.setWindowState(Qt.WindowFullScreen)
@Slot()
def hide_mouse(self) -> None:
@ -545,6 +562,34 @@ class Player(QMainWindow): # type: ignore[misc]
else:
self.setCursor(Qt.BlankCursor)
def frame_changed(self, frame: QVideoFrame) -> None:
"""
Slot to handle possibly invalid frames.
This slot cannot be decorated with ``@Slot`` as
the video sinks are handled in different threads.
As of Qt>=6.5.3, the last frame of every video is "flushed",
resulting in a short black screen between each slide.
To avoid this issue, we check every frame, and avoid playing
invalid ones.
References
----------
1. https://github.com/jeertmans/manim-slides/issues/293
2. https://github.com/jeertmans/manim-slides/pull/464
:param frame: The most recent frame.
"""
if frame.isValid():
self.frame = frame
else:
self.video_sink.setVideoFrame(self.frame) # Reuse previous frame
self.info.video_sink.setVideoFrame(self.frame)
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
self.close()

View File

@ -48,7 +48,7 @@ def render(ce: bool, gl: bool, args: tuple[str, ...]) -> None:
if ce and gl:
raise click.UsageError("You cannot specify both --CE and --GL renderers.")
if gl:
subprocess.run([sys.executable, "-m", "manimlib", *args])
subprocess.run([sys.executable, "-m", "manimlib", "-w", *args])
else:
from manim.cli.render.commands import render as render_ce

View File

@ -63,7 +63,7 @@ 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.keys.model_dump().items()):
# Create label for key name information
label = QLabel()
key_info = value["name"] or key
@ -97,7 +97,7 @@ class Wizard(QWidget): # type: ignore
def save_config(self) -> None:
try:
Config.model_validate(self.config.dict())
Config.model_validate(self.config.model_dump())
except ValueError:
msg = QMessageBox()
msg.setIcon(QMessageBox.Critical)

View File

@ -16,6 +16,8 @@ from pydantic import (
PositiveInt,
PrivateAttr,
ValidationError,
conset,
field_serializer,
field_validator,
model_validator,
)
@ -76,20 +78,13 @@ def key_id(name: str) -> PositiveInt:
class Key(BaseModel): # type: ignore[misc]
"""Represent a list of key codes, with optionally a name."""
ids: list[PositiveInt] = Field(unique=True)
ids: conset(PositiveInt, min_length=1) # type: ignore[valid-type]
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)
def match(self, key_id: int) -> bool:
"""Return whether a given key id matches this key."""
@ -107,6 +102,10 @@ class Key(BaseModel): # type: ignore[misc]
def connect(self, function: Receiver) -> None:
self.__signal.connect(function)
@field_serializer("ids")
def serialize_dt(self, ids: set[int]) -> list[int]:
return list(self.ids)
class Keys(BaseModel): # type: ignore[misc]
QUIT: Key = Field(default_factory=lambda: Key(ids=[key_id("Q")], name="QUIT"))
@ -203,6 +202,8 @@ class BaseSlideConfig(BaseModel): # type: ignore
"""The notes attached to this slide."""
dedent_notes: bool = True
"""Whether to automatically remove any leading indentation in the notes."""
skip_animations: bool = False
src: Optional[FilePath] = None
@classmethod
def wrapper(cls, arg_name: str) -> Callable[..., Any]:
@ -226,7 +227,7 @@ class BaseSlideConfig(BaseModel): # type: ignore
fun_kwargs = {
key: value
for key, value in kwargs.items()
if key not in cls.__fields__
if key not in cls.model_fields
}
fun_kwargs[arg_name] = cls(**kwargs)
return fun(*args, **fun_kwargs)
@ -240,7 +241,7 @@ class BaseSlideConfig(BaseModel): # type: ignore
default=field_info.default,
annotation=field_info.annotation,
)
for field_name, field_info in cls.__fields__.items()
for field_name, field_info in cls.model_fields.items()
]
sig = sig.replace(parameters=parameters)
@ -251,20 +252,18 @@ class BaseSlideConfig(BaseModel): # type: ignore
return _wrapper_
@model_validator(mode="after")
@classmethod
def apply_dedent_notes(
cls, base_slide_config: "BaseSlideConfig"
self,
) -> "BaseSlideConfig":
"""
Remove indentation from notes, if specified.
:param base_slide_config: The current config.
:return: The config, optionally modified.
"""
if base_slide_config.dedent_notes:
base_slide_config.notes = dedent(base_slide_config.notes)
if self.dedent_notes:
self.notes = dedent(self.notes)
return base_slide_config
return self
class PreSlideConfig(BaseSlideConfig):
@ -292,7 +291,7 @@ class PreSlideConfig(BaseSlideConfig):
return cls(
start_animation=start_animation,
end_animation=end_animation,
**base_slide_config.dict(),
**base_slide_config.model_dump(),
)
@field_validator("start_animation", "end_animation")
@ -309,31 +308,37 @@ class PreSlideConfig(BaseSlideConfig):
return v
@model_validator(mode="after")
@classmethod
def start_animation_is_before_end(
cls, pre_slide_config: "PreSlideConfig"
self,
) -> "PreSlideConfig":
"""
Validate that start and end animation indices satisfy `start < end`.
Validate that start and end animation indices satisfy 'start < end'.
:param pre_slide_config: The current config.
:return: The config, if indices are valid.
"""
if pre_slide_config.start_animation >= pre_slide_config.end_animation:
if pre_slide_config.start_animation == pre_slide_config.end_animation == 0:
raise ValueError(
"You have to play at least one animation (e.g., `self.wait()`) "
"before pausing. If you want to start paused, use the appropriate "
"command-line option when presenting. "
"IMPORTANT: when using ManimGL, `self.wait()` is not considered "
"to be an animation, so prefer to directly use `self.play(...)`."
)
raise ValueError(
"Start animation index must be strictly lower than end animation index"
)
return self
return pre_slide_config
@model_validator(mode="after")
def has_src_or_more_than_zero_animations(
self,
) -> "PreSlideConfig":
if self.src is not None and self.start_animation != self.end_animation:
raise ValueError(
"A slide cannot have 'src=...' and more than zero animations at the same time."
)
elif self.src is None and self.start_animation == self.end_animation:
raise ValueError(
"You have to play at least one animation (e.g., 'self.wait()') "
"before pausing. If you want to start paused, use the appropriate "
"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(...)'."
)
return self
@property
def slides_slice(self) -> slice:
@ -352,7 +357,7 @@ class SlideConfig(BaseSlideConfig):
def from_pre_slide_config_and_files(
cls, pre_slide_config: PreSlideConfig, file: Path, rev_file: Path
) -> "SlideConfig":
return cls(file=file, rev_file=rev_file, **pre_slide_config.dict())
return cls(file=file, rev_file=rev_file, **pre_slide_config.model_dump())
class PresentationConfig(BaseModel): # type: ignore[misc]
@ -402,16 +407,15 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
use_cached: bool = True,
include_reversed: bool = True,
prefix: str = "",
) -> "PresentationConfig":
) -> None:
"""
Copy the files to a given directory and return the corresponding configuration.
:param folder: The folder that will contain the animation files.
:param use_cached: Whether caching should be used to avoid copies when possible.
:param include_reversed: Whether to also copy reversed animation to the folder.
:param prefix: Optional prefix added to each file name.
"""
slides = []
for slide_config in self.slides:
file = slide_config.file
rev_file = slide_config.rev_file
@ -419,8 +423,6 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
dest = folder / f"{prefix}{file.name}"
rev_dest = folder / f"{prefix}{rev_file.name}"
slides.append(slide_config.model_copy(file=dest, rev_file=rev_dest))
if not use_cached or not dest.exists():
shutil.copy(file, dest)
@ -428,33 +430,3 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
# TODO: if include_reversed is False, then rev_dev will likely not exist
# and this will cause an issue when decoding.
shutil.copy(rev_file, rev_dest)
return self.model_copy(slides=slides)
def list_presentation_configs(folder: Path) -> list[Path]:
"""
List all presentation configs in a given folder.
:param folder: The folder to search the presentation configs.
:return: The list of paths that map to valid presentation configs.
"""
paths = []
for filepath in folder.glob("*.json"):
try:
_ = PresentationConfig.from_file(filepath)
paths.append(filepath)
except (
ValidationError,
json.JSONDecodeError,
) 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(paths)} valid presentation configuration files in `{folder}`."
)
return paths

View File

@ -1,16 +1,23 @@
import hashlib
import os
import shutil
import tempfile
from collections.abc import Iterator
from multiprocessing import Pool
from pathlib import Path
from typing import Any, Optional
import av
from tqdm import tqdm
from .logger import logger
def concatenate_video_files(files: list[Path], dest: Path) -> None:
"""Concatenate multiple video files into one."""
if len(files) == 1:
shutil.copy(files[0], dest)
return
def _filter(files: list[Path]) -> Iterator[Path]:
"""Patch possibly empty video files."""
@ -19,7 +26,7 @@ def concatenate_video_files(files: list[Path], dest: Path) -> None:
if len(container.streams.video) > 0:
yield file
else:
logger.warn(
logger.warning(
f"Skipping video file {file} because it does "
"not contain any video stream. "
"This is probably caused by Manim, see: "
@ -89,8 +96,9 @@ def link_nodes(*nodes: av.filter.context.FilterContext) -> None:
c.link_to(n)
def reverse_video_file(src: Path, dest: Path) -> None:
def reverse_video_file_in_one_chunk(src_and_dest: tuple[Path, Path]) -> None:
"""Reverses a video file, writing the result to `dest`."""
src, dest = src_and_dest
with (
av.open(str(src)) as input_container,
av.open(str(dest), mode="w") as output_container,
@ -120,8 +128,70 @@ def reverse_video_file(src: Path, dest: Path) -> None:
for _ in range(frames_count):
frame = graph.pull()
frame.pict_type = 5 # Otherwise we get a warning saying it is changed
frame.pict_type = "NONE" # Otherwise we get a warning saying it is changed
output_container.mux(output_stream.encode(frame))
for packet in output_stream.encode():
output_container.mux(packet)
def reverse_video_file(
src: Path,
dest: Path,
max_segment_duration: Optional[float] = 4.0,
num_processes: Optional[int] = None,
**tqdm_kwargs: Any,
) -> None:
"""Reverses a video file, writing the result to `dest`."""
with av.open(str(src)) as input_container: # Fast path if file is short enough
input_stream = input_container.streams.video[0]
if max_segment_duration is None:
return reverse_video_file_in_one_chunk((src, dest))
elif input_stream.duration:
if (
float(input_stream.duration * input_stream.time_base)
<= max_segment_duration
):
return reverse_video_file_in_one_chunk((src, dest))
else: # pragma: no cover
logger.debug(
f"Could not determine duration of {src}, falling back to segmentation."
)
with tempfile.TemporaryDirectory() as tmpdirname:
tmpdir = Path(tmpdirname)
with av.open(
str(tmpdir / f"%04d.{src.suffix}"),
"w",
format="segment",
options={"segment_time": str(max_segment_duration)},
) as output_container:
output_stream = output_container.add_stream(
template=input_stream,
)
for packet in input_container.demux(input_stream):
if packet.dts is None:
continue
packet.stream = output_stream
output_container.mux(packet)
src_files = list(tmpdir.iterdir())
rev_files = [
src_file.with_stem("rev_" + src_file.stem) for src_file in src_files
]
with Pool(num_processes, maxtasksperchild=1) as pool:
for _ in tqdm(
pool.imap_unordered(
reverse_video_file_in_one_chunk, zip(src_files, rev_files)
),
desc="Reversing large file by cutting it in segments",
total=len(src_files),
unit=" files",
**tqdm_kwargs,
):
pass # We just consume the iterator
concatenate_video_files(rev_files[::-1], dest)

View File

@ -283,8 +283,7 @@ class ManimSlidesDirective(Directive):
# 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
self.state.document.settings.env.app.builder.tags.has("skip-manim-slides")
or self.state.document.settings.env.app.builder.name == "gettext"
or "SKIP_MANIM_SLIDES" in os.environ
)
@ -340,7 +339,7 @@ class ManimSlidesDirective(Directive):
ref_block = ""
if "quality" in self.options:
quality = f'{self.options["quality"]}_quality'
quality = f"{self.options['quality']}_quality"
else:
quality = "example_quality"
frame_rate = QUALITIES[quality]["frame_rate"]
@ -484,7 +483,7 @@ def _log_rendering_times(*args):
)
for row in group:
print( # noqa: T201
f"{' '*(max_file_length)} {row[2].rjust(7)}s {row[1]}"
f"{' ' * (max_file_length)} {row[2].rjust(7)}s {row[1]}"
)
print("") # noqa: T201

View File

@ -125,7 +125,7 @@ class ManimSlidesMagic(Magics): # type: ignore
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
%%manim_slides -v WARNING --progress_bar None MySlide --manim-slides controls=true one_file=true
class MySlide(Slide):
def construct(self):
@ -222,17 +222,29 @@ class ManimSlidesMagic(Magics): # type: ignore
kwargs = dict(arg.split("=", 1) for arg in manim_slides_args)
if embed: # Embedding implies data-uri
kwargs["data_uri"] = "true"
# If data_uri is set, raise a warning
if "data_uri" in kwargs:
logger.warning(
"'data_uri' configuration option is deprecated and will be removed in a future release. "
"Please use 'one_file' instead."
)
kwargs["one_file"] = (
kwargs["one_file"]
if "one_file" in kwargs
else kwargs.pop("data_uri")
)
if embed: # Embedding implies one_file
kwargs["one_file"] = "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, "
# Seems like files are blocked so one_file is the only working option...
if kwargs.get("one_file", "false").lower().strip() == "false":
logger.warning(
"one_file option is currently automatically enabled, "
"because using local video files does not seem to work properly."
)
kwargs["data_uri"] = "true"
kwargs["one_file"] = "true"
presentation_configs = get_scenes_presentation_config(
[clsname], Path("./slides")

View File

@ -1,9 +1,9 @@
"""Slides module with logic to either import ManimCE or ManimGL."""
__all__ = (
"API_NAME",
"MANIM",
"MANIMGL",
"API_NAME",
"Slide",
"ThreeDSlide",
)
@ -40,7 +40,7 @@ API: str = os.environ.get(MANIM_API, "manim").lower()
if API not in API_NAMES:
raise ImportError(
f"Specified MANIM_API={API!r} is not in valid options: " f"{API_NAMES}",
f"Specified MANIM_API={API!r} is not in valid options: {API_NAMES}",
)
API_NAME = API_NAMES[API]

View File

@ -5,6 +5,7 @@ from __future__ import annotations
__all__ = ("BaseSlide",)
import platform
import shutil
from abc import abstractmethod
from collections.abc import MutableMapping, Sequence, ValuesView
from pathlib import Path
@ -39,6 +40,12 @@ LEFT: np.ndarray = np.array([-1.0, 0.0, 0.0])
class BaseSlide:
disable_caching: bool = False
flush_cache: bool = False
skip_reversing: bool = False
max_duration_before_split_reverse: float | None = 4.0
num_processes: int | None = None
def __init__(
self, *args: Any, output_folder: Path = FOLDER_PATH, **kwargs: Any
) -> None:
@ -51,6 +58,7 @@ class BaseSlide:
self._start_animation = 0
self._canvas: MutableMapping[str, Mobject] = {}
self._wait_time_between_slides = 0.0
self._skip_animations = False
@property
@abstractmethod
@ -177,11 +185,23 @@ class BaseSlide:
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.
.. seealso::
:attr:`canvas` for usage examples.
"""
self._canvas.update(objects)
def remove_from_canvas(self, *names: str) -> None:
"""Remove objects from the canvas."""
"""
Remove objects from the canvas.
:param names: The names of objects to remove.
.. seealso::
:attr:`canvas` for usage examples.
"""
for name in names:
self._canvas.pop(name)
@ -193,8 +213,12 @@ class BaseSlide:
@property
def mobjects_without_canvas(self) -> Sequence[Mobject]:
"""
Return the list of objects contained in the scene, minus those present in
Return the list of Mobjects contained in the scene, minus those present in
the canvas.
.. seealso::
:attr:`canvas` for usage examples.
"""
return [
mobject
@ -263,7 +287,7 @@ class BaseSlide:
self._wait_time_between_slides = max(wait_time, 0.0)
def play(self, *args: Any, **kwargs: Any) -> None:
"""Overload `self.play` and increment animation count."""
"""Overload 'self.play' and increment animation count."""
super().play(*args, **kwargs) # type: ignore[misc]
self._current_animation += 1
@ -282,36 +306,62 @@ class BaseSlide:
next slide is played. By default, this is the right arrow key.
:param args:
Positional arguments to be passed to
Positional arguments passed to
:meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`,
or ignored if `manimlib` API is used.
:param skip_animations:
Exclude the next slide from the output.
If `manim` is used, this is also passed to :meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`,
which will avoid rendering the corresponding animations.
.. seealso::
:meth:`start_skip_animations`
:meth:`stop_skip_animations`
:param loop:
If set, next slide will be looping.
:param auto_next:
If set, next slide will play immediately play the next slide
upon terminating.
Note that this is only supported by ``manim-slides present``
and ``manim-slides convert --to=html``.
.. warning::
Only supported by ``manim-slides present``
and ``manim-slides convert --to=html``.
:param playback_rate:
Playback rate at which the video is played.
Note that this is only supported by ``manim-slides present``.
.. warning::
Only supported by ``manim-slides present``.
:param reversed_playback_rate:
Playback rate at which the reversed video is played.
Note that this is only supported by ``manim-slides present``.
.. warning::
Only supported by ``manim-slides present``.
:param notes:
Presenter notes, in Markdown format.
Note that PowerPoint does not support Markdown.
.. note::
PowerPoint does not support Markdown formatting,
so the text will be displayed as is.
Note that this is only supported by ``manim-slides present``
and ``manim-slides convert --to=html/pptx``.
.. warning::
Only supported by ``manim-slides present``,
``manim-slides convert --to=html`` and
``manim-slides convert --to=pptx``.
:param dedent_notes:
If set, apply :func:`textwrap.dedent` to notes.
:param pathlib.Path src:
An optional path to a video file to include as next slide.
The video will be copied into the output folder, but no rescaling
is applied.
:param kwargs:
Keyword arguments to be passed to
Keyword arguments passed to
:meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`,
or ignored if `manimlib` API is used.
@ -433,6 +483,21 @@ class BaseSlide:
self._current_slide += 1
if base_slide_config.src is not None:
self._slides.append(
PreSlideConfig.from_base_slide_config_and_animation_indices(
base_slide_config,
self._current_animation,
self._current_animation,
)
)
base_slide_config = BaseSlideConfig() # default
self._current_slide += 1
if self._skip_animations:
base_slide_config.skip_animations = True
self._base_slide_config = base_slide_config
self._start_animation = self._current_animation
@ -452,11 +517,17 @@ class BaseSlide:
)
)
def _save_slides(self, use_cache: bool = True) -> None:
def _save_slides( # noqa: C901
self,
use_cache: bool = True,
flush_cache: bool = False,
skip_reversing: bool = False,
) -> None:
"""
Save slides, optionally using cached files.
Note that cached files only work with Manim.
.. warning:
Caching files only work with Manim.
"""
self._add_last_slide()
@ -465,6 +536,9 @@ class BaseSlide:
scene_name = str(self)
scene_files_folder = files_folder / scene_name
if flush_cache and scene_files_folder.exists():
shutil.rmtree(scene_files_folder)
scene_files_folder.mkdir(parents=True, exist_ok=True)
files: list[Path] = self._partial_movie_files
@ -482,14 +556,25 @@ class BaseSlide:
for pre_slide_config in tqdm(
self._slides,
desc=f"Concatenating animation files to '{scene_files_folder}' and generating reversed animations",
desc=f"Concatenating animations 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,
unit=" slides",
):
slide_files = files[pre_slide_config.slides_slice]
if pre_slide_config.skip_animations:
continue
if pre_slide_config.src:
slide_files = [pre_slide_config.src]
else:
slide_files = files[pre_slide_config.slides_slice]
file = merge_basenames(slide_files)
try:
file = merge_basenames(slide_files)
except ValueError as e:
raise ValueError(
f"Failed to merge basenames of files for slide: {pre_slide_config!r}"
) from e
dst_file = scene_files_folder / file.name
rev_file = scene_files_folder / f"{file.stem}_reversed{file.suffix}"
@ -499,7 +584,18 @@ class BaseSlide:
# 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 skip_reversing:
rev_file = dst_file
else:
reverse_video_file(
dst_file,
rev_file,
max_segment_duration=self.max_duration_before_split_reverse,
num_processes=self.num_processes,
leave=self._leave_progress_bar,
ascii=True if platform.system() == "Windows" else None,
disable=not self._show_progress_bar,
)
slides.append(
SlideConfig.from_pre_slide_config_and_files(
@ -523,6 +619,22 @@ class BaseSlide:
f"Slide '{scene_name}' configuration written in '{slide_path.absolute()}'"
)
def start_skip_animations(self) -> None:
"""
Start skipping animations.
This automatically applies ``skip_animations=True``
to all subsequent calls to :meth:`next_slide`.
This is useful when you want to skip animations from multiple slides in a row,
without having to manually set ``skip_animations=True``.
"""
self._skip_animations = True
def stop_skip_animations(self) -> None:
"""Stop skipping animations."""
self._skip_animations = False
def wipe(
self,
*args: Any,

View File

@ -13,10 +13,43 @@ from .base import BaseSlide
class Slide(BaseSlide, Scene): # type: ignore[misc]
"""
Inherits from :class:`Scene<manim.scene.scene.Scene>` and provide necessary tools
Inherits from :class:`Scene<manim.scene.scene.Scene>` and provides necessary tools
for slides rendering.
:param args: Positional arguments passed to scene object.
:param pathlib.Path output_folder: Where the slide animation files should be written.
:param kwargs: Keyword arguments passed to scene object.
:cvar bool disable_caching: :data:`False`: Whether to disable the use of
cached animation files.
:cvar bool flush_cache: :data:`False`: Whether to flush the cache.
Unlike with Manim, flushing is performed before rendering.
:cvar bool skip_reversing: :data:`False`: Whether to generate reversed animations.
If set to :data:`False`, and no cached reversed animation
exists (or caching is disabled) for a given slide,
then the reversed animation will be simply the same
as the original one, i.e., ``rev_file = file``,
for the current slide config.
:cvar typing.Optional[float] max_duration_before_split_reverse: :data:`4.0`: Maximum duration
before of a video animation before it is reversed by splitting the file into smaller chunks.
Generating reversed animations can require an important amount of
memory (because the whole video needs to be kept in memory),
and splitting the video into multiple chunks usually speeds
up the process (because it can be done in parallel) while taking
less memory.
Set this to :data:`None` to disable splitting the file into chunks.
:cvar typing.Optional[int] num_processes: :data:`None`: Number of processes
to use for parallelizable operations.
If :data:`None`, defaults to :func:`os.process_cpu_count`.
This is currently used when generating reversed animations, and can
increase memory consumption.
"""
def __init__(self, *args: Any, **kwargs: Any) -> None:
# OpenGL renderer disables 'write_to_movie' by default
# which is required for saving the animations
config["write_to_movie"] = True
super().__init__(*args, **kwargs)
@property
def _frame_shape(self) -> tuple[float, float]:
if isinstance(self.renderer, OpenGLRenderer):
@ -75,6 +108,15 @@ class Slide(BaseSlide, Scene): # type: ignore[misc]
def _start_at_animation_number(self) -> Optional[int]:
return config["from_animation_number"] # type: ignore
def play(self, *args: Any, **kwargs: Any) -> None:
"""Overload 'self.play' and increment animation count."""
super().play(*args, **kwargs)
if self._base_slide_config.skip_animations:
# Manim will not render the animations, so we reset the animation
# counter to the previous value
self._current_animation -= 1
def next_section(self, *args: Any, **kwargs: Any) -> None:
"""
Alias to :meth:`next_slide`.
@ -97,23 +139,41 @@ class Slide(BaseSlide, Scene): # type: ignore[misc]
base_slide_config: BaseSlideConfig,
**kwargs: Any,
) -> None:
Scene.next_section(self, *args, **kwargs)
Scene.next_section(
self,
*args,
skip_animations=base_slide_config.skip_animations | self._skip_animations,
**kwargs,
)
BaseSlide.next_slide.__wrapped__(
self,
base_slide_config=base_slide_config,
)
def render(self, *args: Any, **kwargs: Any) -> None:
"""MANIM render."""
"""MANIM renderer."""
# We need to disable the caching limit since we rely on intermediate files
max_files_cached = config["max_files_cached"]
config["max_files_cached"] = float("inf")
flush_manim_cache = config["flush_cache"]
if flush_manim_cache:
# We need to postpone flushing *after* we saved slides
config["flush_cache"] = False
super().render(*args, **kwargs)
config["max_files_cached"] = max_files_cached
self._save_slides()
self._save_slides(
use_cache=not (config["disable_caching"] or self.disable_caching),
flush_cache=(config["flush_cache"] or self.flush_cache),
skip_reversing=self.skip_reversing,
)
if flush_manim_cache:
self.renderer.file_writer.flush_cache_directory()
class ThreeDSlide(Slide, ThreeDScene): # type: ignore[misc]

View File

@ -4,7 +4,6 @@ from pathlib import Path
from typing import Any, ClassVar, Optional
from manimlib import Scene, ThreeDCamera
from manimlib.utils.file_ops import get_sorted_integer_files
from .base import BaseSlide
@ -12,42 +11,40 @@ from .base import BaseSlide
class Slide(BaseSlide, Scene): # type: ignore[misc]
def __init__(self, *args: Any, **kwargs: Any) -> None:
kwargs.setdefault("file_writer_config", {}).update(
skip_animations=True,
break_into_partial_movies=True,
write_to_movie=True,
subdivide_output=True,
)
kwargs["preview"] = False # Avoid opening a preview window
super().__init__(*args, **kwargs)
@property
def _frame_height(self) -> float:
return self.camera.frame.get_height() # type: ignore
return float(self.camera.get_frame_height())
@property
def _frame_width(self) -> float:
return self.camera.frame.get_width() # type: ignore
return float(self.camera.get_frame_width())
@property
def _background_color(self) -> str:
return self.camera_config["background_color"].hex # type: ignore
rgba = self.camera.background_rgba
r = int(255 * rgba[0])
g = int(255 * rgba[1])
b = int(255 * rgba[2])
if rgba[3] == 1.0:
return f"#{r:02x}{g:02x}{b:02x}"
a = int(255 * rgba[3])
return f"#{r:02x}{g:02x}{b:02x}{a:02x}"
@property
def _resolution(self) -> tuple[int, int]:
return self.camera_config["pixel_width"], self.camera_config["pixel_height"]
return self.camera.get_pixel_width(), self.camera.get_pixel_height()
@property
def _partial_movie_files(self) -> list[Path]:
kwargs = {
"remove_non_integer_files": True,
"extension": self.file_writer.movie_file_extension,
}
return [
Path(file)
for file in get_sorted_integer_files(
self.file_writer.partial_movie_directory, **kwargs
)
]
partial_movie_directory = self.file_writer.partial_movie_directory
extension = self.file_writer.movie_file_extension
return sorted(partial_movie_directory.glob(f"*{extension}"))
@property
def _show_progress_bar(self) -> bool:
@ -64,7 +61,11 @@ class Slide(BaseSlide, Scene): # type: ignore[misc]
def run(self, *args: Any, **kwargs: Any) -> None:
"""MANIMGL renderer."""
super().run(*args, **kwargs)
self._save_slides(use_cache=False)
self._save_slides(
use_cache=False,
flush_cache=self.flush_cache,
skip_reversing=self.skip_reversing,
)
class ThreeDSlide(Slide):

View File

@ -17,7 +17,8 @@ classifiers = [
"Topic :: Scientific/Engineering",
]
dependencies = [
"av>=9.0.0",
"av>=9.0.0,<14",
"beautifulsoup4>=4.12.3",
"click>=8.1.3",
"click-default-group>=1.2.2",
"jinja2>=3.1.2",
@ -30,8 +31,7 @@ dependencies = [
"qtpy>=2.4.1",
"requests>=2.28.1",
"rich>=13.3.2",
"rtoml==0.9.0;sys_platform=='win32' and python_version<'3.13'",
"rtoml>=0.9.0;sys_platform!='win32' or python_version>='3.13'",
"rtoml>=0.11.0",
"tqdm>=4.64.1",
]
description = "Tool for live presentations using manim"
@ -49,7 +49,10 @@ docs = [
"myst-parser>=2.0.0",
"nbsphinx>=0.9.2",
"pandoc>=2.3",
"pygments<2.19", # See: https://github.com/ManimCommunity/manim/issues/4104
"sphinx>=7.0.1",
"sphinxcontrib-programoutput>=0.18",
"sphinx-design>=0.6.1",
"sphinx-click>=4.4.0",
"sphinx-copybutton>=0.5.1",
"sphinxext-opengraph>=0.7.5",
@ -58,17 +61,20 @@ full = [
"manim-slides[magic,manim,sphinx-directive]",
]
magic = ["manim-slides[manim]", "ipython>=8.12.2"]
manim = ["manim>=0.18.0"]
manimgl = ["manimgl>=1.6.1;python_version<'3.12'"]
pyqt6 = ["pyqt6>=6.6.1"]
manim = ["manim>=0.19"]
manimgl = ["manimgl>=1.7.2"]
pyqt6 = ["pyqt6>=6.7.0"]
pyqt6-full = ["manim-slides[full,pyqt6]"]
pyside6 = ["pyside6>=6.5.1,<6.5.3;python_version<'3.12'", "pyside6>=6.6.1;python_version>='3.12'"]
pyside6 = ["pyside6>=6.6.1,!=6.8.1.1"]
pyside6-full = ["manim-slides[full,pyside6]"]
sphinx-directive = ["docutils>=0.20.1", "manim-slides[manim]"]
tests = [
"importlib-metadata>=8.6.1;python_version<'3.10'",
"manim-slides[full,manimgl,pyqt6,pyside6,sphinx-directive]",
"pytest>=7.4.0",
"pytest-cov>=4.1.0",
"pytest-env>=0.8.2",
"pytest-missing-modules>=0.1.0",
"pytest-qt>=4.2.0",
]
@ -86,7 +92,7 @@ Repository = "https://github.com/jeertmans/manim-slides"
allow_dirty = false
commit = true
commit_args = ""
current_version = "5.1.7"
current_version = "5.4.2"
ignore_missing_version = false
message = "chore(deps): bump version from {current_version} to {new_version}"
parse = '(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(-rc(?P<release>\d+))?'
@ -136,7 +142,7 @@ search = "<!-- start changelog -->"
builtin = "clear,rare,informal,usage,names,en-GB_to_en-US"
check-hidden = true
ignore-words-list = "master"
skip = "requirements.lock,requirements-dev.lock"
skip = "uv.lock"
[tool.coverage.report]
exclude_lines = [
@ -184,6 +190,13 @@ env = [
"QT_API=pyside6",
"QT_QPA_PLATFORM=offscreen",
]
filterwarnings = [
'''ignore:'audioop' is deprecated:DeprecationWarning''',
'ignore:pkg_resources is deprecated as an API:DeprecationWarning',
'ignore::DeprecationWarning:pkg_resources.*:',
'ignore:invalid escape sequence.*:DeprecationWarning',
'ignore:invalid escape sequence.*:SyntaxWarning',
]
[tool.ruff]
extend-exclude = ["manim_slides/cli/resources.py"]
@ -212,17 +225,9 @@ isort = {known-first-party = ["manim_slides", "tests"]}
"docs/source/reference/magic_example.ipynb" = ["F403", "F405"]
"tests/test_slide.py" = ["N801"]
[tool.rye]
[tool.uv]
dev-dependencies = [
"bump-my-version>=0.20.3",
"pre-commit>=3.5.0",
]
managed = true
[tool.uv]
override-dependencies = [
# Bypass constraints from ManimGL
"manimpango>=0.5.0,<1.0.0",
"numpy<=1.24;python_version < '3.12'",
"numpy>=1.26;python_version >= '3.12'",
"setuptools>=73.0.1",
]

View File

@ -1,444 +0,0 @@
# generated by rye
# use `rye lock` or `rye sync` to update this lockfile
#
# last locked with the following flags:
# pre: false
# features: []
# all-features: true
# with-sources: false
# generate-hashes: false
-e file:.
alabaster==0.7.16
# via sphinx
annotated-types==0.6.0
# via pydantic
asttokens==2.4.1
# via stack-data
attrs==23.2.0
# via jsonschema
# via referencing
av==12.0.0
# via manim-slides
babel==2.14.0
# via sphinx
beautifulsoup4==4.12.3
# via furo
# via nbconvert
bleach==6.1.0
# via nbconvert
bracex==2.4
# via wcmatch
bump-my-version==0.21.0
certifi==2024.2.2
# via requests
cfgv==3.4.0
# via pre-commit
charset-normalizer==3.3.2
# via requests
click==8.1.7
# via bump-my-version
# via click-default-group
# via cloup
# via manim
# via manim-slides
# via rich-click
# via sphinx-click
click-default-group==1.2.4
# via manim-slides
cloup==3.0.5
# via manim
colour==0.1.5
# via manimgl
comm==0.2.2
# via ipykernel
contourpy==1.2.1
# via matplotlib
coverage==7.4.4
# via pytest-cov
cycler==0.12.1
# via matplotlib
debugpy==1.8.1
# via ipykernel
decorator==5.1.1
# via ipython
# via manim
defusedxml==0.7.1
# via nbconvert
distlib==0.3.8
# via virtualenv
docutils==0.20.1
# via manim-slides
# via myst-parser
# via nbsphinx
# via sphinx
# via sphinx-click
executing==2.0.1
# via stack-data
fastjsonschema==2.19.1
# via nbformat
filelock==3.13.4
# via virtualenv
fonttools==4.53.0
# via matplotlib
furo==2024.1.29
# via manim-slides
glcontext==2.5.0
# via moderngl
identify==2.5.35
# via pre-commit
idna==3.7
# via requests
imagesize==1.4.1
# via sphinx
iniconfig==2.0.0
# via pytest
ipykernel==6.29.4
# via manim-slides
ipython==8.18.1
# via ipykernel
# via manim-slides
# via manimgl
isosurfaces==0.1.0
# via manim
# via manimgl
jedi==0.19.1
# via ipython
jinja2==3.1.3
# via manim-slides
# via myst-parser
# via nbconvert
# via nbsphinx
# via sphinx
jsonschema==4.21.1
# via nbformat
jsonschema-specifications==2023.12.1
# via jsonschema
jupyter-client==8.6.1
# via ipykernel
# via nbclient
jupyter-core==5.7.2
# via ipykernel
# via jupyter-client
# via nbclient
# via nbconvert
# via nbformat
jupyterlab-pygments==0.3.0
# via nbconvert
kiwisolver==1.4.5
# via matplotlib
lxml==5.2.1
# via manim-slides
# via python-pptx
manim==0.18.1
# via manim-slides
manimgl==1.6.1
# via manim-slides
manimpango==0.5.0
# via --override (workspace)
# via manim
# via manimgl
mapbox-earcut==1.0.1
# via manim
# via manimgl
markdown-it-py==3.0.0
# via mdit-py-plugins
# via myst-parser
# via rich
markupsafe==2.1.5
# via jinja2
# via nbconvert
matplotlib==3.9.0
# via manimgl
matplotlib-inline==0.1.7
# via ipykernel
# via ipython
mdit-py-plugins==0.4.0
# via myst-parser
mdurl==0.1.2
# via markdown-it-py
mistune==3.0.2
# via nbconvert
moderngl==5.10.0
# via manim
# via manimgl
# via moderngl-window
moderngl-window==2.4.6
# via manim
# via manimgl
mpmath==1.3.0
# via sympy
multipledispatch==1.0.0
# via pyrr
myst-parser==2.0.0
# via manim-slides
nbclient==0.10.0
# via nbconvert
nbconvert==7.16.3
# via nbsphinx
nbformat==5.10.4
# via nbclient
# via nbconvert
# via nbsphinx
nbsphinx==0.9.3
# via manim-slides
nest-asyncio==1.6.0
# via ipykernel
networkx==2.8.8
# via manim
nodeenv==1.8.0
# via pre-commit
numpy==1.24.0
# via --override (workspace)
# via contourpy
# via ipython
# via isosurfaces
# via manim
# via manim-slides
# via manimgl
# via mapbox-earcut
# via matplotlib
# via moderngl-window
# via networkx
# via pyrr
# via scipy
packaging==24.0
# via ipykernel
# via matplotlib
# via nbconvert
# via pytest
# via qtpy
# via sphinx
pandoc==2.3
# via manim-slides
pandocfilters==1.5.1
# via nbconvert
parso==0.8.4
# via jedi
pexpect==4.9.0
# via ipython
pillow==10.3.0
# via manim
# via manim-slides
# via manimgl
# via matplotlib
# via moderngl-window
# via python-pptx
platformdirs==4.2.0
# via jupyter-core
# via virtualenv
pluggy==1.4.0
# via pytest
# via pytest-qt
plumbum==1.8.2
# via pandoc
ply==3.11
# via pandoc
pre-commit==3.7.0
prompt-toolkit==3.0.43
# via ipython
# via questionary
psutil==5.9.8
# via ipykernel
ptyprocess==0.7.0
# via pexpect
pure-eval==0.2.2
# via stack-data
pycairo==1.26.0
# via manim
pydantic==2.7.0
# via bump-my-version
# via manim-slides
# via pydantic-extra-types
# via pydantic-settings
pydantic-core==2.18.1
# via pydantic
pydantic-extra-types==2.6.0
# via manim-slides
pydantic-settings==2.2.1
# via bump-my-version
pydub==0.25.1
# via manim
# via manimgl
pyglet==2.0.15
# via moderngl-window
pygments==2.17.2
# via furo
# via ipython
# via manim
# via manimgl
# via nbconvert
# via rich
# via sphinx
pyopengl==3.1.7
# via manimgl
pyparsing==3.1.2
# via matplotlib
pyqt6==6.6.1
# via manim-slides
pyqt6-qt6==6.6.3
# via pyqt6
pyqt6-sip==13.6.0
# via pyqt6
pyrr==0.10.3
# via moderngl-window
pyside6==6.5.2
# via manim-slides
pyside6-addons==6.5.2
# via pyside6
pyside6-essentials==6.5.2
# via pyside6
# via pyside6-addons
pytest==8.1.1
# via manim-slides
# via pytest-cov
# via pytest-env
# via pytest-qt
pytest-cov==5.0.0
# via manim-slides
pytest-env==1.1.3
# via manim-slides
pytest-qt==4.4.0
# via manim-slides
python-dateutil==2.9.0.post0
# via jupyter-client
# via matplotlib
python-dotenv==1.0.1
# via pydantic-settings
python-pptx==0.6.23
# via manim-slides
pyyaml==6.0.1
# via manimgl
# via myst-parser
# via pre-commit
pyzmq==26.0.0
# via ipykernel
# via jupyter-client
qtpy==2.4.1
# via manim-slides
questionary==1.10.0
# via bump-my-version
referencing==0.34.0
# via jsonschema
# via jsonschema-specifications
requests==2.31.0
# via manim-slides
# via sphinx
rich==13.7.1
# via bump-my-version
# via manim
# via manim-slides
# via manimgl
# via rich-click
rich-click==1.8.0
# via bump-my-version
rpds-py==0.18.0
# via jsonschema
# via referencing
rtoml==0.10.0
# via manim-slides
scipy==1.13.0
# via manim
# via manimgl
screeninfo==0.8.1
# via manim
# via manimgl
setuptools==69.5.1
# via nodeenv
shiboken6==6.5.2
# via pyside6
# via pyside6-addons
# via pyside6-essentials
six==1.16.0
# via asttokens
# via bleach
# via python-dateutil
skia-pathops==0.8.0.post1
# via manim
# via manimgl
snowballstemmer==2.2.0
# via sphinx
soupsieve==2.5
# via beautifulsoup4
sphinx==7.3.6
# via furo
# via manim-slides
# via myst-parser
# via nbsphinx
# via sphinx-basic-ng
# via sphinx-click
# via sphinx-copybutton
# via sphinxext-opengraph
sphinx-basic-ng==1.0.0b2
# via furo
sphinx-click==5.1.0
# via manim-slides
sphinx-copybutton==0.5.2
# via manim-slides
sphinxcontrib-applehelp==1.0.8
# via sphinx
sphinxcontrib-devhelp==1.0.6
# via sphinx
sphinxcontrib-htmlhelp==2.0.5
# via sphinx
sphinxcontrib-jsmath==1.0.1
# via sphinx
sphinxcontrib-qthelp==1.0.7
# via sphinx
sphinxcontrib-serializinghtml==1.1.10
# via sphinx
sphinxext-opengraph==0.9.1
# via manim-slides
srt==3.5.3
# via manim
stack-data==0.6.3
# via ipython
svgelements==1.9.6
# via manim
# via manimgl
sympy==1.12.1
# via manimgl
tinycss2==1.2.1
# via nbconvert
tomlkit==0.12.4
# via bump-my-version
tornado==6.4
# via ipykernel
# via jupyter-client
tqdm==4.66.2
# via manim
# via manim-slides
# via manimgl
traitlets==5.14.2
# via comm
# via ipykernel
# via ipython
# via jupyter-client
# via jupyter-core
# via matplotlib-inline
# via nbclient
# via nbconvert
# via nbformat
# via nbsphinx
typing-extensions==4.11.0
# via manim
# via pydantic
# via pydantic-core
# via rich-click
urllib3==2.2.1
# via requests
validators==0.28.3
# via manimgl
virtualenv==20.25.3
# via pre-commit
watchdog==2.3.1
# via manim
wcmatch==8.5.1
# via bump-my-version
wcwidth==0.2.13
# via prompt-toolkit
webencodings==0.5.1
# via bleach
# via tinycss2
xlsxwriter==3.2.0
# via python-pptx

View File

@ -1,404 +0,0 @@
# generated by rye
# use `rye lock` or `rye sync` to update this lockfile
#
# last locked with the following flags:
# pre: false
# features: []
# all-features: true
# with-sources: false
# generate-hashes: false
-e file:.
alabaster==0.7.16
# via sphinx
annotated-types==0.6.0
# via pydantic
asttokens==2.4.1
# via stack-data
attrs==23.2.0
# via jsonschema
# via referencing
av==12.0.0
# via manim-slides
babel==2.15.0
# via sphinx
beautifulsoup4==4.12.3
# via furo
# via nbconvert
bleach==6.1.0
# via nbconvert
certifi==2024.2.2
# via requests
charset-normalizer==3.3.2
# via requests
click==8.1.7
# via click-default-group
# via cloup
# via manim
# via manim-slides
# via sphinx-click
click-default-group==1.2.4
# via manim-slides
cloup==3.0.5
# via manim
colour==0.1.5
# via manimgl
comm==0.2.2
# via ipykernel
contourpy==1.2.1
# via matplotlib
coverage==7.5.4
# via pytest-cov
cycler==0.12.1
# via matplotlib
debugpy==1.8.2
# via ipykernel
decorator==5.1.1
# via ipython
# via manim
defusedxml==0.7.1
# via nbconvert
docutils==0.20.1
# via manim-slides
# via myst-parser
# via nbsphinx
# via sphinx
# via sphinx-click
executing==2.0.1
# via stack-data
fastjsonschema==2.20.0
# via nbformat
fonttools==4.53.0
# via matplotlib
furo==2024.5.6
# via manim-slides
glcontext==2.5.0
# via moderngl
idna==3.7
# via requests
imagesize==1.4.1
# via sphinx
iniconfig==2.0.0
# via pytest
ipykernel==6.29.4
# via manim-slides
ipython==8.18.1
# via ipykernel
# via manim-slides
# via manimgl
isosurfaces==0.1.0
# via manim
# via manimgl
jedi==0.19.1
# via ipython
jinja2==3.1.3
# via manim-slides
# via myst-parser
# via nbconvert
# via nbsphinx
# via sphinx
jsonschema==4.22.0
# via nbformat
jsonschema-specifications==2023.12.1
# via jsonschema
jupyter-client==8.6.2
# via ipykernel
# via nbclient
jupyter-core==5.7.2
# via ipykernel
# via jupyter-client
# via nbclient
# via nbconvert
# via nbformat
jupyterlab-pygments==0.3.0
# via nbconvert
kiwisolver==1.4.5
# via matplotlib
lxml==5.2.1
# via manim-slides
# via python-pptx
manim==0.18.1
# via manim-slides
manimgl==1.6.1
# via manim-slides
manimpango==0.5.0
# via --override (workspace)
# via manim
# via manimgl
mapbox-earcut==1.0.1
# via manim
# via manimgl
markdown-it-py==3.0.0
# via mdit-py-plugins
# via myst-parser
# via rich
markupsafe==2.1.5
# via jinja2
# via nbconvert
matplotlib==3.9.0
# via manimgl
matplotlib-inline==0.1.7
# via ipykernel
# via ipython
mdit-py-plugins==0.4.1
# via myst-parser
mdurl==0.1.2
# via markdown-it-py
mistune==3.0.2
# via nbconvert
moderngl==5.10.0
# via manim
# via manimgl
# via moderngl-window
moderngl-window==2.4.6
# via manim
# via manimgl
mpmath==1.3.0
# via sympy
multipledispatch==1.0.0
# via pyrr
myst-parser==3.0.1
# via manim-slides
nbclient==0.10.0
# via nbconvert
nbconvert==7.16.4
# via nbsphinx
nbformat==5.10.4
# via nbclient
# via nbconvert
# via nbsphinx
nbsphinx==0.9.4
# via manim-slides
nest-asyncio==1.6.0
# via ipykernel
networkx==2.8.8
# via manim
numpy==1.24.0
# via --override (workspace)
# via contourpy
# via ipython
# via isosurfaces
# via manim
# via manim-slides
# via manimgl
# via mapbox-earcut
# via matplotlib
# via moderngl-window
# via networkx
# via pyrr
# via scipy
packaging==24.0
# via ipykernel
# via matplotlib
# via nbconvert
# via pytest
# via qtpy
# via sphinx
pandoc==2.3
# via manim-slides
pandocfilters==1.5.1
# via nbconvert
parso==0.8.4
# via jedi
pexpect==4.9.0
# via ipython
pillow==10.3.0
# via manim
# via manim-slides
# via manimgl
# via matplotlib
# via moderngl-window
# via python-pptx
platformdirs==4.2.2
# via jupyter-core
pluggy==1.5.0
# via pytest
# via pytest-qt
plumbum==1.8.3
# via pandoc
ply==3.11
# via pandoc
prompt-toolkit==3.0.43
# via ipython
psutil==6.0.0
# via ipykernel
ptyprocess==0.7.0
# via pexpect
pure-eval==0.2.2
# via stack-data
pycairo==1.26.0
# via manim
pydantic==2.7.0
# via manim-slides
# via pydantic-extra-types
pydantic-core==2.18.1
# via pydantic
pydantic-extra-types==2.6.0
# via manim-slides
pydub==0.25.1
# via manim
# via manimgl
pyglet==2.0.15
# via moderngl-window
pygments==2.17.2
# via furo
# via ipython
# via manim
# via manimgl
# via nbconvert
# via rich
# via sphinx
pyopengl==3.1.7
# via manimgl
pyparsing==3.1.2
# via matplotlib
pyqt6==6.6.1
# via manim-slides
pyqt6-qt6==6.6.3
# via pyqt6
pyqt6-sip==13.6.0
# via pyqt6
pyrr==0.10.3
# via moderngl-window
pyside6==6.5.2
# via manim-slides
pyside6-addons==6.5.2
# via pyside6
pyside6-essentials==6.5.2
# via pyside6
# via pyside6-addons
pytest==8.2.2
# via manim-slides
# via pytest-cov
# via pytest-env
# via pytest-qt
pytest-cov==5.0.0
# via manim-slides
pytest-env==1.1.3
# via manim-slides
pytest-qt==4.4.0
# via manim-slides
python-dateutil==2.9.0.post0
# via jupyter-client
# via matplotlib
python-pptx==0.6.23
# via manim-slides
pyyaml==6.0.1
# via manimgl
# via myst-parser
pyzmq==26.0.3
# via ipykernel
# via jupyter-client
qtpy==2.4.1
# via manim-slides
referencing==0.35.1
# via jsonschema
# via jsonschema-specifications
requests==2.31.0
# via manim-slides
# via sphinx
rich==13.7.1
# via manim
# via manim-slides
# via manimgl
rpds-py==0.18.1
# via jsonschema
# via referencing
rtoml==0.10.0
# via manim-slides
scipy==1.13.0
# via manim
# via manimgl
screeninfo==0.8.1
# via manim
# via manimgl
shiboken6==6.5.2
# via pyside6
# via pyside6-addons
# via pyside6-essentials
six==1.16.0
# via asttokens
# via bleach
# via python-dateutil
skia-pathops==0.8.0.post1
# via manim
# via manimgl
snowballstemmer==2.2.0
# via sphinx
soupsieve==2.5
# via beautifulsoup4
sphinx==7.3.7
# via furo
# via manim-slides
# via myst-parser
# via nbsphinx
# via sphinx-basic-ng
# via sphinx-click
# via sphinx-copybutton
# via sphinxext-opengraph
sphinx-basic-ng==1.0.0b2
# via furo
sphinx-click==6.0.0
# via manim-slides
sphinx-copybutton==0.5.2
# via manim-slides
sphinxcontrib-applehelp==1.0.8
# via sphinx
sphinxcontrib-devhelp==1.0.6
# via sphinx
sphinxcontrib-htmlhelp==2.0.5
# via sphinx
sphinxcontrib-jsmath==1.0.1
# via sphinx
sphinxcontrib-qthelp==1.0.7
# via sphinx
sphinxcontrib-serializinghtml==1.1.10
# via sphinx
sphinxext-opengraph==0.9.1
# via manim-slides
srt==3.5.3
# via manim
stack-data==0.6.3
# via ipython
svgelements==1.9.6
# via manim
# via manimgl
sympy==1.12.1
# via manimgl
tinycss2==1.3.0
# via nbconvert
tornado==6.4.1
# via ipykernel
# via jupyter-client
tqdm==4.66.2
# via manim
# via manim-slides
# via manimgl
traitlets==5.14.2
# via comm
# via ipykernel
# via ipython
# via jupyter-client
# via jupyter-core
# via matplotlib-inline
# via nbclient
# via nbconvert
# via nbformat
# via nbsphinx
typing-extensions==4.11.0
# via manim
# via pydantic
# via pydantic-core
urllib3==2.2.1
# via requests
validators==0.28.3
# via manimgl
watchdog==2.3.1
# via manim
wcwidth==0.2.13
# via prompt-toolkit
webencodings==0.5.1
# via bleach
# via tinycss2
xlsxwriter==3.2.0
# via python-pptx

View File

@ -1,5 +1,5 @@
#! /bin/bash
rye run manim-slides render -t -qk -s --format png --resolution 64,64 static/logo.py ManimSlidesFavicon && mv media/images/logo/*.png static/favicon.png
uv run manim-slides render -t -qk -s --format png --resolution 64,64 static/logo.py ManimSlidesFavicon && mv media/images/logo/*.png static/favicon.png
ln -f -r -s static/favicon.png docs/source/_static/favicon.png

View File

@ -1,21 +1,21 @@
#! /bin/bash
MANIM_SLIDES_THEME=light rye run manim-slides render -qk -s --format png --resolution 2560,1280 static/logo.py ManimSlidesLogo && mv media/images/logo/*.png static/logo.png
MANIM_SLIDES_THEME=light uv run manim-slides render -qk -s --format png --resolution 2560,1280 static/logo.py ManimSlidesLogo && mv media/images/logo/*.png static/logo.png
ln -f -r -s static/logo.png docs/source/_static/logo.png
MANIM_SLIDES_THEME=dark_docs rye run manim-slides render -qk -s --format png --resolution 2560,1280 static/logo.py ManimSlidesLogo && mv media/images/logo/*.png static/logo_dark_docs.png
MANIM_SLIDES_THEME=dark_docs uv run manim-slides render -qk -s --format png --resolution 2560,1280 static/logo.py ManimSlidesLogo && mv media/images/logo/*.png static/logo_dark_docs.png
ln -f -r -s static/logo_dark_docs.png docs/source/_static/logo_dark_docs.png
MANIM_SLIDES_THEME=dark_github rye run manim-slides render -qk -s --format png --resolution 2560,1280 static/logo.py ManimSlidesLogo && mv media/images/logo/*.png static/logo_dark_github.png
MANIM_SLIDES_THEME=dark_github uv run manim-slides render -qk -s --format png --resolution 2560,1280 static/logo.py ManimSlidesLogo && mv media/images/logo/*.png static/logo_dark_github.png
ln -f -r -s static/logo_dark_github.png docs/source/_static/logo_dark_github.png
MANIM_SLIDES_THEME=light rye run manim-slides render -t -qk -s --format png --resolution 2560,1280 static/logo.py ManimSlidesLogo && mv media/images/logo/*.png static/logo_light_transparent.png
MANIM_SLIDES_THEME=light uv run manim-slides render -t -qk -s --format png --resolution 2560,1280 static/logo.py ManimSlidesLogo && mv media/images/logo/*.png static/logo_light_transparent.png
ln -f -r -s static/logo_light_transparent.png docs/source/_static/logo_light_transparent.png
MANIM_SLIDES_THEME=dark_docs rye run manim-slides render -t -qk -s --format png --resolution 2560,1280 static/logo.py ManimSlidesLogo && mv media/images/logo/*.png static/logo_dark_transparent.png
MANIM_SLIDES_THEME=dark_docs uv run manim-slides render -t -qk -s --format png --resolution 2560,1280 static/logo.py ManimSlidesLogo && mv media/images/logo/*.png static/logo_dark_transparent.png
ln -f -r -s static/logo_dark_transparent.png docs/source/_static/logo_dark_transparent.png

View File

@ -71,8 +71,20 @@ def paths() -> Generator[list[Path], None, None]:
yield [random_path() for _ in range(20)]
@pytest.fixture(scope="session")
@pytest.fixture
def presentation_config(
slides_folder: Path,
) -> Generator[PresentationConfig, None, None]:
yield PresentationConfig.from_file(slides_folder / "BasicSlide.json")
def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
"""Make sure missing modules run at the very end."""
def uses_missing_modules_fixtures(item: pytest.Item) -> int:
if "missing_modules" in getattr(item, "fixturenames", []):
return 1
return 0
items.sort(key=uses_missing_modules_fixtures)

View File

@ -38,3 +38,7 @@ class BasicSlide(Slide):
self.next_slide()
self.zoom(other_text, [])
class BasicSlideSkipReversing(BasicSlide):
skip_reversing = True

View File

@ -86,6 +86,15 @@ class TestBaseSlide:
assert base_slide.wait_time_between_slides == 0.0
def test_skip_animations(self, base_slide: BaseSlide) -> None:
assert not base_slide._skip_animations
def test_start_and_stop_skip_animations(self, base_slide: BaseSlide) -> None:
base_slide.start_skip_animations()
assert base_slide._skip_animations
base_slide.stop_skip_animations()
assert not base_slide._skip_animations
def test_play(self) -> None:
pass # This method should be tested in test_slide.py

73
tests/test_checkhealth.py Normal file
View File

@ -0,0 +1,73 @@
import importlib.util
import sys
from itertools import chain, combinations
import pytest
from click.testing import CliRunner
from pytest_missing_modules.plugin import MissingModulesContextGenerator
from manim_slides.__version__ import __version__
from manim_slides.checkhealth import checkhealth
MANIM_NOT_INSTALLED = importlib.util.find_spec("manim") is None
MANIMGL_NOT_INSTALLED = importlib.util.find_spec("manimlib") is None
PYQT6_NOT_INSTALLED = importlib.util.find_spec("PyQt6") is None
PYSIDE6_NOT_INSTALLED = importlib.util.find_spec("PySide6") is None
@pytest.mark.filterwarnings("ignore:Selected binding 'pyqt6' could not be found")
@pytest.mark.parametrize(
"names",
list(
chain.from_iterable(
combinations(("manim", "manimlib", "PyQt6", "PySide6"), r=r)
for r in range(0, 5)
)
),
)
def test_checkhealth(
names: tuple[str, ...], missing_modules: MissingModulesContextGenerator
) -> None:
runner = CliRunner()
manim_missing = "manim" in names or MANIM_NOT_INSTALLED
manimlib_missing = "manimlib" in names or MANIMGL_NOT_INSTALLED
pyqt6_missing = "PyQt6" in names or PYQT6_NOT_INSTALLED
pyside6_missing = "PySide6" in names or PYSIDE6_NOT_INSTALLED
if "qtpy" in sys.modules:
del sys.modules["qtpy"] # Avoid using cached module
with missing_modules(*names):
if (
not manimlib_missing
and not MANIMGL_NOT_INSTALLED
and sys.version_info < (3, 10)
):
pytest.skip("See https://github.com/3b1b/manim/issues/2263")
result = runner.invoke(
checkhealth,
env={"QT_API": "pyqt6", "FORCE_QT_API": "1"},
)
assert result.exit_code == 0
assert f"Manim Slides version: {__version__}" in result.output
assert sys.executable in result.output
if manim_missing:
assert "manim not found" in result.output
else:
assert "manim (version:" in result.output
if manimlib_missing:
assert "manimgl not found" in result.output
else:
assert "manimgl (version:" in result.output
if pyqt6_missing and pyside6_missing:
assert "No Qt API found" in result.output
elif pyqt6_missing:
assert "Qt API: pyside6 (version:" in result.output
else:
assert "Qt API: pyqt6 (version:" in result.output

View File

@ -1,8 +1,10 @@
import shutil
from enum import EnumMeta
from pathlib import Path
import pytest
from pydantic import ValidationError
import requests
from bs4 import BeautifulSoup
from manim_slides.config import PresentationConfig
from manim_slides.convert import (
@ -17,6 +19,7 @@ from manim_slides.convert import (
ControlsLayout,
Converter,
Display,
HtmlZip,
JsBool,
JsFalse,
JsNull,
@ -138,7 +141,8 @@ def test_unquoted_enum(enum_type: EnumMeta) -> None:
class TestConverter:
@pytest.mark.parametrize(
("name", "converter"), [("html", RevealJS), ("pdf", PDF), ("pptx", PowerPoint)]
("name", "converter"),
[("html", RevealJS), ("pdf", PDF), ("pptx", PowerPoint), ("zip", HtmlZip)],
)
def test_from_string(self, name: str, converter: type) -> None:
assert Converter.from_string(name) == converter
@ -150,9 +154,142 @@ class TestConverter:
RevealJS(presentation_configs=[presentation_config]).convert_to(out_file)
assert out_file.exists()
assert Path(tmp_path / "slides_assets").is_dir()
file_contents = Path(out_file).read_text()
file_contents = out_file.read_text()
assert "manim" in file_contents.casefold()
def test_revealjs_offline_converter(
self, tmp_path: Path, presentation_config: PresentationConfig
) -> None:
out_file = tmp_path / "slides.html"
RevealJS(presentation_configs=[presentation_config], offline="true").convert_to(
out_file
)
assert out_file.exists()
assets_dir = Path(tmp_path / "slides_assets")
assert assets_dir.is_dir()
for file in [
"black.min.css",
"reveal.min.css",
"reveal.min.js",
"zenburn.min.css",
]:
assert (assets_dir / file).exists()
def test_revealjs_data_encode(
self,
tmp_path: Path,
presentation_config: PresentationConfig,
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Mock requests.Session.get to return a fake response (should not be called)
class MockResponse:
def __init__(self, content: bytes, text: str, status_code: int) -> None:
self.content = content
self.text = text
self.status_code = status_code
# Apply the monkeypatch
monkeypatch.setattr(
requests.Session,
"get",
lambda self, url: MockResponse(
b"body { background-color: #9a3241; }",
"body { background-color: #9a3241; }",
200,
),
)
out_file = tmp_path / "slides.html"
RevealJS(
presentation_configs=[presentation_config], offline="false", one_file="true"
).convert_to(out_file)
assert out_file.exists()
# Check that assets are not stored
assert not (tmp_path / "slides_assets").exists()
with open(out_file, encoding="utf-8") as file:
content = file.read()
soup = BeautifulSoup(content, "html.parser")
# Check if video is encoded in base64
videos = soup.find_all("section")
assert all(
"data:video/mp4;base64," in video["data-background-video"]
for video in videos
)
# Check if CSS is not inlined
styles = soup.find_all("style")
assert not any("background-color: #9a3241;" in style.string for style in styles)
# Check if JS is not inlined
scripts = soup.find_all("script")
assert not any(
"background-color: #9a3241;" in (script.string or "") for script in scripts
)
def test_revealjs_offline_inlining(
self,
tmp_path: Path,
presentation_config: PresentationConfig,
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Mock requests.Session.get to return a fake response
class MockResponse:
def __init__(self, content: bytes, text: str, status_code: int) -> None:
self.content = content
self.text = text
self.status_code = status_code
# Apply the monkeypatch
monkeypatch.setattr(
requests.Session,
"get",
lambda self, url: MockResponse(
b"body { background-color: #9a3241; }",
"body { background-color: #9a3241; }",
200,
),
)
out_file = tmp_path / "slides.html"
RevealJS(
presentation_configs=[presentation_config], offline="true", one_file="true"
).convert_to(out_file)
assert out_file.exists()
with open(out_file, encoding="utf-8") as file:
content = file.read()
soup = BeautifulSoup(content, "html.parser")
# Check if CSS is inlined
styles = soup.find_all("style")
assert any("background-color: #9a3241;" in style.string for style in styles)
# Check if JS is inlined
scripts = soup.find_all("script")
assert any("background-color: #9a3241;" in script.string for script in scripts)
def test_htmlzip_converter(
self, tmp_path: Path, presentation_config: PresentationConfig
) -> None:
archive = tmp_path / "got.zip"
expected = tmp_path / "expected.html"
got = tmp_path / "got.html"
HtmlZip(presentation_configs=[presentation_config]).convert_to(archive)
RevealJS(presentation_configs=[presentation_config]).convert_to(expected)
shutil.unpack_archive(str(archive), extract_dir=tmp_path)
assert archive.exists()
assert got.exists()
assert expected.exists()
assert got.read_text() == expected.read_text().replace(
"expected_assets", "got_assets"
)
@pytest.mark.parametrize("num_presentation_configs", (1, 2))
def test_revealjs_multiple_scenes_converter(
self,
@ -185,10 +322,6 @@ class TestConverter:
).convert_to(out_file)
assert out_file.exists()
def test_converter_no_presentation_config(self) -> None:
with pytest.raises(ValidationError):
Converter(presentation_configs=[])
def test_pptx_converter(
self, tmp_path: Path, presentation_config: PresentationConfig
) -> None:

View File

@ -1,3 +1,4 @@
import warnings
from pathlib import Path
import pytest
@ -64,6 +65,34 @@ def test_convert(slides_folder: Path, extension: str) -> None:
assert results.exit_code == 0
@pytest.mark.parametrize(("extension",), [("html",)])
def test_convert_data_uri_deprecated(slides_folder: Path, extension: str) -> None:
runner = CliRunner(mix_stderr=False)
with runner.isolated_filesystem():
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
results = runner.invoke(
cli,
[
"convert",
"BasicSlide",
f"basic_example.{extension}",
"--folder",
str(slides_folder),
"--to",
extension,
"-cdata_uri=true",
],
)
assert any(
"'data_uri' configuration option is deprecated" in str(item.message)
and item.category is DeprecationWarning
for item in w
)
assert results.exit_code == 0
@pytest.mark.parametrize(
("extension", "expected_log"),
[("html", ""), ("pdf", ""), ("pptx", ""), ("ppt", "WARNING")],

View File

@ -6,6 +6,11 @@ import pytest
import manim_slides.slide as slide
skip_if_py39 = pytest.mark.skipif(
sys.version_info < (3, 10),
reason="See https://github.com/3b1b/manim/issues/2263",
)
def assert_import(
*,
@ -20,6 +25,7 @@ def assert_import(
assert slide.MANIMGL == manimgl
@skip_if_py39
def test_force_api() -> None:
pytest.importorskip("manimlib")
import manim # noqa: F401
@ -53,6 +59,7 @@ def test_invalid_api() -> None:
del os.environ[slide.MANIM_API]
@skip_if_py39
@pytest.mark.filterwarnings("ignore:assert_import")
def test_manim_and_manimgl_imported() -> None:
pytest.importorskip("manimlib")
@ -79,6 +86,7 @@ def test_manim_imported() -> None:
)
@skip_if_py39
def test_manimgl_imported() -> None:
pytest.importorskip("manimlib")
import manimlib # noqa: F401

View File

@ -1,6 +1,10 @@
import contextlib
import os
import random
import shutil
import sys
import tempfile
from collections.abc import Iterator
from pathlib import Path
from typing import Any, Union
@ -17,6 +21,7 @@ from manim import (
Dot,
FadeIn,
GrowFromCenter,
Square,
Text,
)
from manim.renderer.opengl_renderer import OpenGLRenderer
@ -26,23 +31,32 @@ from manim_slides.defaults import FOLDER_PATH
from manim_slides.render import render
from manim_slides.slide.manim import Slide as CESlide
if sys.version_info < (3, 10):
class _GLSlide:
def construct(self) -> None:
pass
def render(self) -> None:
pass
GLSlide = pytest.param(
_GLSlide,
marks=pytest.mark.skip(reason="See https://github.com/3b1b/manim/issues/2263"),
)
else:
from manim_slides.slide.manimlib import Slide as GLSlide
_GLSlide = GLSlide
class CEGLSlide(CESlide):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, renderer=OpenGLRenderer(), **kwargs)
if sys.version_info >= (3, 12):
class _GLSlide:
pass
GLSlide = pytest.param(_GLSlide, marks=pytest.mark.skip())
else:
from manim_slides.slide.manimlib import Slide as GLSlide
SlideType = Union[type[CESlide], type[GLSlide], type[CEGLSlide]]
Slide = Union[CESlide, GLSlide, CEGLSlide]
SlideType = Union[type[CESlide], type[_GLSlide], type[CEGLSlide]]
Slide = Union[CESlide, _GLSlide, CEGLSlide]
@pytest.mark.parametrize(
@ -52,11 +66,13 @@ Slide = Union[CESlide, GLSlide, CEGLSlide]
pytest.param(
"--GL",
marks=pytest.mark.skipif(
sys.version_info >= (3, 12),
reason="ManimGL requires numpy<1.25, which is outdated and Python < 3.12",
sys.version_info < (3, 10),
reason="See https://github.com/3b1b/manim/issues/2263.",
),
),
"--CE --renderer=opengl",
],
ids=("CE", "GL", "CE(GL)"),
)
def test_render_basic_slide(
renderer: str,
@ -69,7 +85,7 @@ def test_render_basic_slide(
with runner.isolated_filesystem() as tmp_dir:
shutil.copy(manimgl_config, tmp_dir)
results = runner.invoke(
render, [renderer, str(slides_file), "BasicSlide", "-ql"]
render, [*renderer.split(" "), str(slides_file), "BasicSlide", "-ql"]
)
assert results.exit_code == 0, results
@ -97,17 +113,116 @@ def test_render_basic_slide(
assert local_presentation_config.resolution == presentation_config.resolution
def test_clear_cache(
slides_file: Path,
) -> None:
runner = CliRunner()
with runner.isolated_filesystem() as tmp_dir:
local_media_folder = (
Path(tmp_dir)
/ "media"
/ "videos"
/ slides_file.stem
/ "480p15"
/ "partial_movie_files"
/ "BasicSlide"
)
local_slides_folder = Path(tmp_dir) / "slides"
assert not local_media_folder.exists()
assert not local_slides_folder.exists()
results = runner.invoke(render, [str(slides_file), "BasicSlide", "-ql"])
assert results.exit_code == 0, results
assert local_media_folder.is_dir() and list(local_media_folder.iterdir())
assert local_slides_folder.exists()
results = runner.invoke(
render, [str(slides_file), "BasicSlide", "-ql", "--flush_cache"]
)
assert results.exit_code == 0, results
assert local_media_folder.is_dir() and not list(local_media_folder.iterdir())
assert local_slides_folder.exists()
results = runner.invoke(
render, [str(slides_file), "BasicSlide", "-ql", "--disable_caching"]
)
assert results.exit_code == 0, results
assert local_media_folder.is_dir() and list(local_media_folder.iterdir())
assert local_slides_folder.exists()
results = runner.invoke(
render,
[
str(slides_file),
"BasicSlide",
"-ql",
"--disable_caching",
"--flush_cache",
],
)
assert results.exit_code == 0, results
assert local_media_folder.is_dir() and not list(local_media_folder.iterdir())
assert local_slides_folder.exists()
@pytest.mark.parametrize(
"renderer",
[
"--CE",
pytest.param(
"--GL",
marks=pytest.mark.skipif(
sys.version_info < (3, 10),
reason="See https://github.com/3b1b/manim/issues/2263.",
),
),
],
)
@pytest.mark.parametrize(
("klass", "skip_reversing"),
[("BasicSlide", False), ("BasicSlideSkipReversing", True)],
)
def test_skip_reversing(
renderer: str,
slides_file: Path,
manimgl_config: Path,
klass: str,
skip_reversing: bool,
) -> None:
runner = CliRunner()
with runner.isolated_filesystem() as tmp_dir:
shutil.copy(manimgl_config, tmp_dir)
results = runner.invoke(render, [renderer, str(slides_file), klass, "-ql"])
assert results.exit_code == 0, results
local_slides_folder = (Path(tmp_dir) / "slides").resolve(strict=True)
local_config_file = (local_slides_folder / f"{klass}.json").resolve(strict=True)
local_presentation_config = PresentationConfig.from_file(local_config_file)
for slide in local_presentation_config.slides:
if skip_reversing:
assert slide.file == slide.rev_file
else:
assert slide.file != slide.rev_file
def init_slide(cls: SlideType) -> Slide:
if issubclass(cls, CESlide):
return cls()
elif issubclass(cls, GLSlide):
from manimlib.config import get_configuration, parse_cli
from manimlib.extract_scene import get_scene_config
from manimlib.config import parse_cli
args = parse_cli()
config = get_configuration(args)
scene_config = get_scene_config(config)
return cls(**scene_config)
_args = parse_cli()
return cls()
raise ValueError(f"Unsupported class {cls}")
@ -121,8 +236,22 @@ def assert_constructs(cls: SlideType) -> None:
init_slide(cls).construct()
@contextlib.contextmanager
def tmp_cwd() -> Iterator[str]:
cwd = os.getcwd()
tmp_dir = tempfile.mkdtemp()
os.chdir(tmp_dir)
try:
yield tmp_dir
finally:
os.chdir(cwd)
def assert_renders(cls: SlideType) -> None:
init_slide(cls).render()
with tmp_cwd():
init_slide(cls).render()
class TestSlide:
@ -185,6 +314,26 @@ class TestSlide:
self.play(dot.animate.move_to(LEFT))
self.play(dot.animate.move_to(DOWN))
def test_split_reverse(self) -> None:
@assert_renders
class _(CESlide):
max_duration_before_split_reverse = 3.0
def construct(self) -> None:
self.wait(2.0)
for _ in range(3):
self.next_slide()
self.wait(10.0)
@assert_renders
class __(CESlide):
max_duration_before_split_reverse = None
def construct(self) -> None:
self.wait(5.0)
self.next_slide()
self.wait(5.0)
def test_file_too_long(self) -> None:
@assert_renders
class _(CESlide):
@ -371,6 +520,120 @@ class TestSlide:
self.next_slide()
assert self._current_slide == 2
def test_next_slide_skip_animations(self) -> None:
class Foo(CESlide):
def construct(self) -> None:
circle = Circle(color=BLUE)
self.play(GrowFromCenter(circle))
assert not self._base_slide_config.skip_animations
self.next_slide(skip_animations=True)
square = Square(color=BLUE)
self.play(GrowFromCenter(square))
assert self._base_slide_config.skip_animations
self.next_slide()
assert not self._base_slide_config.skip_animations
self.play(GrowFromCenter(square))
class Bar(CESlide):
def construct(self) -> None:
circle = Circle(color=BLUE)
self.play(GrowFromCenter(circle))
assert not self._base_slide_config.skip_animations
self.next_slide(skip_animations=False)
square = Square(color=BLUE)
self.play(GrowFromCenter(square))
assert not self._base_slide_config.skip_animations
self.next_slide()
assert not self._base_slide_config.skip_animations
self.play(GrowFromCenter(square))
class Baz(CESlide):
def construct(self) -> None:
circle = Circle(color=BLUE)
self.play(GrowFromCenter(circle))
assert not self._base_slide_config.skip_animations
self.start_skip_animations()
self.next_slide()
square = Square(color=BLUE)
self.play(GrowFromCenter(square))
assert self._base_slide_config.skip_animations
self.next_slide()
assert self._base_slide_config.skip_animations
self.play(GrowFromCenter(square))
self.stop_skip_animations()
with tmp_cwd() as tmp_dir:
init_slide(Foo).render()
init_slide(Bar).render()
init_slide(Baz).render()
slides_folder = Path(tmp_dir) / "slides"
assert slides_folder.exists()
slide_file = slides_folder / "Foo.json"
config = PresentationConfig.from_file(slide_file)
assert len(config.slides) == 2
slide_file = slides_folder / "Bar.json"
config = PresentationConfig.from_file(slide_file)
assert len(config.slides) == 3
slide_file = slides_folder / "Baz.json"
config = PresentationConfig.from_file(slide_file)
assert len(config.slides) == 1
def test_next_slide_include_video(self) -> None:
class Foo(CESlide):
def construct(self) -> None:
circle = Circle(color=BLUE)
self.play(GrowFromCenter(circle))
self.next_slide()
square = Square(color=BLUE)
self.play(GrowFromCenter(square))
self.next_slide()
self.wait(2)
with tmp_cwd() as tmp_dir:
init_slide(Foo).render()
slides_folder = Path(tmp_dir) / "slides"
assert slides_folder.exists()
slide_file = slides_folder / "Foo.json"
config = PresentationConfig.from_file(slide_file)
assert len(config.slides) == 3
class Bar(CESlide):
def construct(self) -> None:
self.next_slide(src=config.slides[0].file)
self.wait(2)
self.next_slide()
self.wait(2)
self.next_slide() # Dummy
self.next_slide(src=config.slides[1].file, loop=True)
self.next_slide() # Dummy
self.wait(2)
self.next_slide(src=config.slides[2].file)
init_slide(Bar).render()
slide_file = slides_folder / "Bar.json"
config = PresentationConfig.from_file(slide_file)
assert len(config.slides) == 6
assert config.slides[-3].loop
def test_canvas(self) -> None:
@assert_constructs
class _(CESlide):

View File

@ -69,7 +69,7 @@ class TestWizard:
monkeypatch.setattr(QMessageBox, "exec", exec_patched)
for i, (key, _) in enumerate(wizard.config.keys.dict().items()):
for i, (key, _) in enumerate(wizard.config.keys.model_dump().items()):
open_dialog(i, getattr(wizard.config.keys, key))
wizard.button_box.accepted.emit()
@ -89,7 +89,7 @@ def test_init() -> None:
assert results.exit_code == 0
assert CONFIG_PATH.exists()
assert Config().dict() == Config.from_file(CONFIG_PATH).dict()
assert Config().model_dump() == Config.from_file(CONFIG_PATH).model_dump()
def test_init_custom_path() -> None:
@ -106,7 +106,7 @@ def test_init_custom_path() -> None:
assert results.exit_code == 0
assert not CONFIG_PATH.exists()
assert custom_path.exists()
assert Config().dict() == Config.from_file(custom_path).dict()
assert Config().model_dump() == Config.from_file(custom_path).model_dump()
def test_init_path_exists() -> None:
@ -120,7 +120,7 @@ def test_init_path_exists() -> None:
assert results.exit_code == 0
assert CONFIG_PATH.exists()
assert Config().dict() == Config.from_file(CONFIG_PATH).dict()
assert Config().model_dump() == Config.from_file(CONFIG_PATH).model_dump()
results = runner.invoke(init, input="o")
@ -156,7 +156,7 @@ def test_wizard(monkeypatch: MonkeyPatch) -> None:
assert results.exit_code == 0
assert CONFIG_PATH.exists()
assert Config().dict() == Config.from_file(CONFIG_PATH).dict()
assert Config().model_dump() == Config.from_file(CONFIG_PATH).model_dump()
def test_wizard_closed_without_saving(monkeypatch: MonkeyPatch) -> None:

4293
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff