Compare commits

..

150 Commits

Author SHA1 Message Date
880cba3064 chore(lib): moved Sphinx directive from docs to sphinxext 2025-04-18 18:55:47 +02:00
fbf5f42f31 Merge branch 'main' into reorganize 2025-04-18 14:17:07 +02:00
0c6cd67038 chore(dev): move dev-dependencies inside dependency-groups (#542)
* chore(dev): move dev-dependencies inside dependency-groups

* fix(ci): ci was not broken
2025-04-18 14:15:30 +02:00
a5412a8df2 chore(deps): pre-commit autoupdate (#541)
* chore(deps): pre-commit autoupdate

updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.2 → v0.11.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.2...v0.11.5)

* chore(fmt): auto fixes from pre-commit.com hooks

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-04-15 12:48:36 +02:00
e9480c9bc7 chore(docs): update features table 2025-04-03 11:19:06 +02:00
3e0268a431 chore(deps): bump version from 5.5.0 to 5.5.1 2025-03-28 15:26:55 +01:00
6e14dc9051 feat(html): pause HTML slides with SPACE key (#539)
* feat(html): pause HTML slides with `SPACE` key

Pressing <kbd>SPACE</kbd> key now pauses the slides, instead of skipping it.

* chore(fmt): auto fixes from pre-commit.com hooks

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-03-28 15:25:53 +01:00
4a400398b8 chore(deps): pre-commit autoupdate (#537)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.0 → v0.11.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.0...v0.11.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-03-28 15:24:58 +01:00
d641d2d82c feat(html): always include RevealJS' notes plugin (#538)
This allows to open the speaker view no matter if we had notes included.
2025-03-28 15:23:53 +01:00
d3396d3a01 chore(deps): bump version from 5.4.2 to 5.5.0 2025-03-24 15:05:53 +01:00
7a922db6f1 chore(dev): lock 2025-03-24 15:05:46 +01:00
9b9593985d chore(docs): add missing changelog entry from #536 2025-03-24 15:04:12 +01:00
c915af19e8 chore(html): bump RevealJS version to 5.2 (#536)
Bump version as it includes a nice fix for speaker view: background videos are now played.

See: https://github.com/hakimel/reveal.js/pull/1037#issuecomment-1825352783.
2025-03-24 15:01:27 +01:00
a8897552d8 chore(docs): remove link to survey 2025-03-24 15:00:34 +01:00
b02dbbcc8c chore(deps): pre-commit autoupdate (#532)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.9.6 → v0.11.0](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.6...v0.11.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-03-24 14:28:32 +01:00
c6ba210797 chore(docs): specify canonical URL 2025-02-25 11:42:47 +01:00
b1212a49d3 chore(docs): all-versions Zenodo DOI 2025-02-24 14:05:29 +01:00
f7ce5fc115 chore(deps): pre-commit autoupdate (#530)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.9.4 → v0.9.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.4...v0.9.6)
- [github.com/pre-commit/mirrors-mypy: v1.14.1 → v1.15.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.14.1...v1.15.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-02-10 23:11:37 +01:00
5f9bbf2a79 chore(deps): pre-commit autoupdate (#528)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.9.3 → v0.9.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.3...v0.9.4)
- [github.com/codespell-project/codespell: v2.4.0 → v2.4.1](https://github.com/codespell-project/codespell/compare/v2.4.0...v2.4.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-02-04 15:27:42 +01:00
3c8384a908 chore(fmt): auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2025-01-29 18:29:09 +00:00
4bf4d38fd8 Merge branch 'main' into reorganize 2025-01-29 19:28:57 +01:00
ccbe9d558c feat(lib): allow to insert external videos as slides (#526)
* feat(lib): allow to insert external videos as slides

See https://github.com/jeertmans/manim-slides/discussions/520

* chore(lib): lint and changelog entry

* chore: fix PR #

fix

* fix: docs
2025-01-29 19:17:45 +01:00
a2bd1ffb67 chore(convert): improve HTML Jinja2 template (#443)
* chore(convert): improve HTML Jinja2 template

As suggested by @yunusey in #442

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

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

* chore(deps): fix importlib

* fix: remove redundant if

* fix(docs): rename PeculiarProgrammer to taibeled

* chore: update template and doc. example

* chore(docs): add changelog entry

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-01-29 11:01:37 +01:00
e911ec3096 feat(lib): smarter files reversing (#439)
* feat(lib): smarter files reversing

Implement a smarter generation of reversed files by splitting the video into smaller segments.

Closes #434

* chore(lib): change default length

* chore: use suffix

* chore(docs): update

* chore(docs): fix docs and add changelog entry

* chore(tests): coverage

* chore(docs): typo
2025-01-28 23:05:59 +00:00
daf547414c chore(deps): pre-commit autoupdate (#525)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.9.2 → v0.9.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.2...v0.9.3)
- [github.com/codespell-project/codespell: v2.3.0 → v2.4.0](https://github.com/codespell-project/codespell/compare/v2.3.0...v2.4.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-01-28 16:46:13 +01:00
528952dbc3 chore(deps): bump version from 5.4.1 to 5.4.2 2025-01-22 21:43:00 +01:00
dbced6e62e fix(lib): pass skip argument to ManimCE (#524)
* fix(lib): pass skip argument to ManimCE

* fix(lint): make ruff happy
2025-01-22 21:41:22 +01:00
941b895083 chore(deps): bump version from 5.4.0 to 5.4.1 2025-01-22 21:12:20 +01:00
289b7c1683 fix(ci): lockfile 2025-01-22 21:12:14 +01:00
b07a83898b feat(lib): add start_skip_animations and stop_skip_animations meth. (#523) 2025-01-22 21:10:37 +01:00
074a029759 chore(deps): bump version from 5.3.1 to 5.4.0 2025-01-21 13:41:51 +01:00
b4af76050e fix(ci): lock 2025-01-21 13:41:43 +01:00
adce58e1b7 fix(lib): bump Manim and fix OpenGL renderer (#522)
* fix(lib): bump Manim and fix OpenGL renderer

* fix(tests): correctly change cwd
2025-01-21 13:38:41 +01:00
32ab690932 feat(lib): add skip_animations compatibility (#516)
* feat: Add skip_animations compatibility

* Add tests, config and changelog

* chore(fmt): auto fixes from pre-commit.com hooks

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

* Update manim_slides/slide/base.py

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

* chore(fmt): auto fixes from pre-commit.com hooks

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

* chore(tests): implement tests

---------

Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-01-21 11:27:21 +01:00
df31345f83 chore(deps): pre-commit autoupdate (#521)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.9.1 → v0.9.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.1...v0.9.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-01-21 10:11:13 +01:00
ef282300f1 chore(deps): bump version from 5.3.0 to 5.3.1 2025-01-15 14:57:28 +01:00
a9ba1b4fad fix(docker): try to fix Docker image 2025-01-15 14:56:39 +01:00
4cc6c2865d chore(deps): pre-commit autoupdate (#509)
* chore(deps): pre-commit autoupdate

updates:
- [github.com/astral-sh/ruff-pre-commit: v0.8.4 → v0.9.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.4...v0.9.1)
- [github.com/pre-commit/mirrors-mypy: v1.14.0 → v1.14.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.14.0...v1.14.1)

* chore(fmt): auto fixes from pre-commit.com hooks

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
2025-01-15 14:38:37 +01:00
0483e2f861 chore(ci): fix RTD build (#518)
See https://github.com/ManimCommunity/manim/issues/4104
2025-01-15 14:30:31 +01:00
a58ff6c388 fix(convert): fix missing slides in HTML presentation after #508 (#515)
* fix(convert): fix missing slides after #508

* Update template.html

* Update CHANGELOG.md

* Update CHANGELOG.md

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

---------

Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
2025-01-15 11:25:18 +01:00
bf512f2f73 fix(ci): typo in bug.yml issue template
See https://github.com/jeertmans/manim-slides/issues/513#issuecomment-2586771845
2025-01-15 10:17:56 +01:00
541b175360 chore(ci): fix bug issue template 2025-01-13 11:29:26 +01:00
57ab592d36 chore(deps): bump version from 5.2.0 to 5.3.0 2025-01-10 11:57:16 +01:00
840d1d80d9 fix(convert): avoid micro-stuttering with --one-file in HTML presentation (#508)
* fix: Fix micro-stuttering with --one-file in HTML presentation

* Update template.html

* Update CHANGELOG.md

* Update CHANGELOG.md

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

* Update CHANGELOG.md

---------

Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
2025-01-09 14:13:57 +01:00
c15cd95565 chore(docs): Manim Slides survey 2025-01-08 11:34:37 +01:00
478552c528 fix(docs): dead links (#507) 2025-01-03 14:20:05 +01:00
1189f37cf3 feat(convert/html): inline CSS and JS with convert --one_file --offline (#505)
* feat: Inline CSS and JS by default with --offline

* chore(fmt): auto fixes from pre-commit.com hooks

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

* chore: Add test

* Add one_file parameter

* chore(fmt): auto fixes from pre-commit.com hooks

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

* Fix lint

* Fix typo

* Fix typo

* Fix IPython magic doc

* Update manim_slides/convert.py

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

* Add test for one_file=true

* chore(fmt): auto fixes from pre-commit.com hooks

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

* Update manim_slides/convert.py

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

* Update manim_slides/convert.py

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

* Update docs/source/reference/sharing.md

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

* Update manim_slides/convert.py

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

* chore(fmt): auto fixes from pre-commit.com hooks

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

* Add changelog and tests

* Fix IPython magic

* Update docs/source/faq.md

* Update CHANGELOG.md

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
2025-01-03 13:49:05 +01:00
e50271b0b2 feat(cli): document config options (#485)
* feat(cli): document config options

* chpre(docs): add changelog entry

* fixes

* chore(deps): remove restriction

* fix(deps): PySide6 issue

See https://github.com/astral-sh/uv/issues/10056

* chore(docs): document config options

* chore(fmt): auto fixes from pre-commit.com hooks

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

* fox(docs): deps

* chore(tests): remove old test

* chore(fmt): auto fixes from pre-commit.com hooks

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-01-03 09:19:41 +01:00
98955bee5c fix(deps): bump ManimGL>=1.7.2 (#506)
* fix(deps): bump ManimGL>=1.7.2

This bumps and fixes issues related to latest ManimGL version, as it includes breaking changes.

* fix(docs): PR number

* fix(deps): PySide6 issue

See https://github.com/astral-sh/uv/issues/10056

* fix(ci): headers
2025-01-02 17:39:27 +01:00
2169938df7 chore(deps): bump version from 5.1.10 to 5.2.0 2024-12-24 11:12:02 +01:00
a207248deb chore(deps): update lock 2024-12-24 11:10:58 +01:00
1c859991b8 chore(deps): pre-commit autoupdate (#504)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.8.3 → v0.8.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.3...v0.8.4)
- [github.com/pre-commit/mirrors-mypy: v1.13.0 → v1.14.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.13.0...v1.14.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-12-24 11:10:04 +01:00
a24915665d chore(deps): pre-commit autoupdate (#501)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.8.2 → v0.8.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.2...v0.8.3)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-12-18 10:44:08 +01:00
5cce117050 chore(deps): bump astral-sh/setup-uv from 3 to 4 (#492)
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 3 to 4.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/v3...v4)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
2024-12-11 16:10:01 +01:00
bd76fbdfd9 chore(deps): pre-commit autoupdate (#486)
* chore(deps): pre-commit autoupdate

updates:
- [github.com/astral-sh/ruff-pre-commit: v0.7.1 → v0.8.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.7.1...v0.8.2)

* chore(fmt): auto fixes from pre-commit.com hooks

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-12-11 15:55:29 +01:00
5b026919ea chore(deps): bump ManimGL to 1.7.1 (#499)
* chore(deps): bump ManimGL to 1.7.1

Bump ManimGL's minimal version, so relax constraints on other deps and remove compatibility issues with Manim

* fix(docs): correct PR number

* fix(lib): update ManimGL's init

See https://github.com/3b1b/manim/issues/2261

* fix(lib): force float

* chore(tests): correctly ignore warning

* fix(tests)

* fix(tests): add skips

* chore(fmt): auto fixes from pre-commit.com hooks

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

* fix(tests)

* oops

* fix on 3.12

* fix(lib): correctly patch ManimGL

* fix(deps): pyrr issue

* fix: version

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-12-11 15:54:59 +01:00
05c1a16ca3 feat(cli): smarter info window hiding logic (#482)
* Adds features from #327

This changes the ``--hide-info-window-`` command to accept three options: auto, always, and never. Auto will hide the info window if there is only one screen. This also fixes the --full-screen option by moving the info window to monitor 1 and the presentation to monitor 0 by default.

* chore(fmt): auto fixes from pre-commit.com hooks

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

* Let Qt decide the screens

* Fix full screen bug

* chore(fmt): auto fixes from pre-commit.com hooks

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

* fix(cli): improve logic

* fix

* Revert fixes and clean code

* Update changelog

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
2024-12-11 09:16:50 +01:00
3bd8c386b1 chore(deps): bump codecov/codecov-action from 4 to 5 (#491)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4 to 5.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
2024-12-06 18:05:18 +01:00
628c8da832 fix(convert): blank web page when converting multiple slides into HTML (#497)
* fix(convert): Blank web page when converting multiple slides into HTML

* chore(docs): add changelog

* Fix typo
2024-12-04 12:25:34 +01:00
3b62e6b788 chore(deps): bump version from 5.1.9 to 5.1.10 2024-12-03 12:57:36 +01:00
ff9aac49d7 chore(deps): pin av<14 to avoid breaking changes (#495)
* chore(deps): Pin `av<14` to avoid breaking changes

Closes #494

* chore(docs): syntax
2024-12-03 12:56:16 +01:00
16d9d32d33 fix(ci): actually run tests on multiple Python versions (#493) 2024-12-03 12:17:32 +01:00
988011ff7d feat(cli): allow multiple reverses (#488)
* Permit multiple reverses

* chore(docs): add changelog entry

---------

Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
2024-11-12 12:09:35 +01:00
3dbe12b480 fix(ci): typo 2024-11-06 10:25:18 +01:00
6ba657c0d5 fix(ci): feature_request.yml 2024-11-06 10:24:50 +01:00
6c8ab61f9d chore(deps): pre-commit autoupdate (#480)
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.6.0 → v5.0.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.6.0...v5.0.0)
- [github.com/astral-sh/ruff-pre-commit: v0.6.8 → v0.7.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.8...v0.7.1)
- [github.com/pre-commit/mirrors-mypy: v1.11.2 → v1.13.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.11.2...v1.13.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
2024-11-01 10:01:53 +01:00
91e6e139e3 fix(docs): some typos (#484) 2024-11-01 10:01:28 +01:00
d8acbae165 feat(convert): allow fully offline HTML presentation (#440)
* feat(cli): allow offline HTML presentations

* feat(convert): allow fully offline HTML presentation

TODO: check if this is really the case, especially for nested dependencies?

Closes #438

* fix(cli): typo

* chore(fmt): auto fixes from pre-commit.com hooks

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

* chore(tests): add tests

* fix(cli): use full path

* fix(tests): typo

* chore(ci): avoid specific kernel name

* fix ?

* chore(lib): simplify logic

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-11-01 10:00:48 +01:00
75af26e601 chore(docker): drop legacy syntax for env variables 2024-10-15 17:58:20 +02:00
a8903b809d chore(deps): bump version from 5.1.8 to 5.1.9 2024-10-15 17:24:48 +02:00
d813aaf313 chore(docs): add changelog entry 2024-10-15 17:24:37 +02:00
d5679924b9 fix(ci): docker image (#481)
* fix(ci): docker image

* fix(ci): test build docker

* fix(ci): typo :-(

* fix(ci): don't use `--system`
2024-10-15 17:22:03 +02:00
fb562d88ac chore(deps): bump version from 5.1.7 to 5.1.8 2024-10-02 19:56:23 +02:00
8a1fb4c259 chore(deps): pre-commit autoupdate (#479)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.6.7 → v0.6.8](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.7...v0.6.8)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-09-30 23:03:39 +02:00
9db2358793 chore(deps): bump astral-sh/setup-uv from 2 to 3 (#477)
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 2 to 3.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/v2...v3)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-25 10:08:09 +02:00
de91ac7b7c chore(ci): from Rye to uv (#476)
* chore(ci): from Rye to uv

uv is just better for what I need to do, and Rye will eventually be replaced by uv anyway

* chore(ci): add tests' extra

* chore(ci): oops

* fix(ci): some tests

* chore(ci): remove -W error

Because it breaks on the CI, but not locally..
2024-09-24 17:15:18 +02:00
6ad89ecdf6 chore(docs): document new potential fix for PPTX (#475)
* chore(docs): document new potential fix for PPTX

Ref: https://github.com/jeertmans/manim-slides/issues/392#issuecomment-2368198106.

* chore(fmt): auto fixes from pre-commit.com hooks

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-09-24 16:14:34 +02:00
c98501b71c chore(deps): pre-commit autoupdate (#474)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.6.4 → v0.6.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.4...v0.6.7)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-09-24 15:22:32 +02:00
2d7d097276 fix(ci): wrong link 2024-09-23 10:22:24 +02:00
eeac42c638 chore(deps): pre-commit autoupdate (#471)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.6.3 → v0.6.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.3...v0.6.4)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
2024-09-15 11:41:11 +02:00
ae42403962 chore(cli): switch option order 2024-09-11 15:57:21 +02:00
1539b2f7e4 feat(cli/convert): add HTML zip output (#472)
Closes #437
2024-09-11 15:30:38 +02:00
a39a9c2bb1 feat(lib): use and add more config option (#452)
* feat(lib): use and add more config option

Closes #441

* feat(lib): add support for more options and test them

* chore(docs): add attributes to documentation

* chore(docs): sort members by member type

* chore(docs): small improvements

* chore(docs): cleanup
2024-09-05 14:08:10 +02:00
87867762ea chore(deps): pre-commit autoupdate (#468)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.6.2 → v0.6.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.2...v0.6.3)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-09-02 22:39:57 +02:00
2f4fe9bd06 fix(lib): deprecation warnings (#467)
* fix(lib): deprecation warnings

* fix(tests): collect tests in the correct order

* fix(tests): ignore pydub warning

* fix(tests): correctly ignore warnings

* fix(ci): do we need faulthandler?
2024-08-27 14:57:23 +02:00
924d8210d9 fix(cli): remove echo 2024-08-27 13:50:44 +02:00
59236b84db chore(deps): pre-commit autoupdate (#466)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.6.1 → v0.6.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.1...v0.6.2)
- [github.com/pre-commit/mirrors-mypy: v1.11.1 → v1.11.2](https://github.com/pre-commit/mirrors-mypy/compare/v1.11.1...v1.11.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
2024-08-27 12:55:59 +02:00
c047da67b1 feat(cli): manim-slides checkhealth (#458)
* feat(cli): `manim-slides checkhealth`

Closes #457

* chore(fmt): auto fixes from pre-commit.com hooks

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

* chore(tests): implement some basic tests

* chore(docs): document changes

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-08-27 12:23:24 +02:00
5b6f5eb1e4 fix(present): black video flashes and other bugs linked to Qt (#465)
* Relies on Pixel Format instead of Size

* chore(lib): remove deprecated warning

* chore(deps): update lockfiles

* chore(lib): cleanup code

* chore(ci): run fmt

* chore: update changelog

* chore: typo

* chore(docs): remove ref. to black screen

* fix(deps): issue on Windows

---------

Co-authored-by: PeculiarProgrammer <179261820+PeculiarProgrammer@users.noreply.github.com>
2024-08-26 17:29:39 +02:00
f02ef630cf chore(lib): pyproject.toml config 2024-08-26 09:42:22 +02:00
3b23c7739d chore(fmt): auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2024-08-23 21:17:15 +00:00
517fbc6240 Fix for #293 and similar errors
This commit fixes the screen going black after the video ends in versions of PySide6 v6.5.3 and newer. This allows for other errors (like #315) that require a newer version of PySide6 to also be fixed.
2024-08-23 17:05:27 -04:00
ae8d5b6aab chore(lib): re-organizing the module
This PR refactors a lot of the code in order to have a better organized codebase. If will that I needed to create a second level of submodules, to better distinguish the different parts of this project.
2024-08-20 11:31:58 +02:00
c3e1aa0276 chore(deps): pre-commit autoupdate (#463)
* chore(deps): pre-commit autoupdate

updates:
- [github.com/astral-sh/ruff-pre-commit: v0.5.6 → v0.6.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.6...v0.6.1)

* fix(ci): ignore F403 and F405 in notebooks

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
2024-08-20 11:31:13 +02:00
3e7174c331 chore(deps): pre-commit autoupdate (#459)
updates:
- [github.com/macisamuele/language-formatters-pre-commit-hooks: v2.13.0 → v2.14.0](https://github.com/macisamuele/language-formatters-pre-commit-hooks/compare/v2.13.0...v2.14.0)
- [github.com/astral-sh/ruff-pre-commit: v0.4.10 → v0.5.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.10...v0.5.6)
- [github.com/pre-commit/mirrors-mypy: v1.10.0 → v1.11.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.10.0...v1.11.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-08-09 13:20:42 +02:00
6572dc3e21 chore(lib): enhance error message for --folder (#462)
Closes #461.
2024-08-07 12:05:53 +02:00
f707b6cd0c fix(docs): broken link 2024-07-23 14:38:54 +02:00
98f6dbd864 chore(docs): add citing 2024-07-23 14:35:23 +02:00
8c0dd6a605 chore(ci): update pre-commit ci config 2024-07-19 15:54:30 +02:00
ba3756b314 fix(ci): typo 2024-07-19 15:47:45 +02:00
1413a5eb9e fix(ci): move placeholder to correct input 2024-07-19 15:46:57 +02:00
467f744450 fix(ci): label name 2024-07-19 15:43:50 +02:00
4fe347e66a fix(ci): removing template and changing input types 2024-07-19 15:42:35 +02:00
9b62b62386 chore(ci): rework issue templates (#456)
* chore(ci): rework issue templates

Closes #406

* chore(ci): small fixes

* up

* add changelog entry
2024-07-19 15:32:26 +02:00
e2d8c5667f fix(lib): Manim fixes, bump to >= 0.18, and tests (#447)
* fix(lib): Manim fixes, bump to >= 0.18, and tests

* chore(ci): tests and happy mypy

* chore(deps): fix override

* fix(tests): correct skipping

* fix(ci): coverage

* fix(docs): dead links

* fix(tests): deps fixes

* fix(deps): add missing override

* fix(tests): correctly ignore

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

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

* chore(tests): no filterwarning

* chore(tests): add a check to see if we can install package

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

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

* chore(ci): typo

* fix(ci): typo

* oops

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

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

* fix(ci): double quote instead of single

* chore(ci): add OSes requirements

* chore(tests): removed `full-gl` extra

* chore(ci): automatically cancel jobs

* fix(docs): typo

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-07-01 18:19:24 +02:00
e80d1d08eb fix(ci): add missing 'v' in tag 2024-06-27 09:57:17 +02:00
5924c329a8 [pre-commit.ci] pre-commit autoupdate (#450)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.4.8 → v0.4.10](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.8...v0.4.10)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-06-26 10:57:46 +02:00
20f91a4590 chore(deps): bump docker/build-push-action from 5 to 6 (#451)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 6.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-18 08:13:40 +02:00
964de66563 chore(deps): pin rtoml==0.9.0 on Windows (#432)
* chore(deps): pin `rtoml==0.9.0` on Windows

See #398 and https://github.com/samuelcolvin/rtoml/issues/74.

* chore(ci): trying to fix on Python 3.12
2024-06-12 10:32:50 +02:00
89377e3a55 [pre-commit.ci] pre-commit autoupdate (#444)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.4.4 → v0.4.8](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.4...v0.4.8)
- [github.com/codespell-project/codespell: v2.2.6 → v2.3.0](https://github.com/codespell-project/codespell/compare/v2.2.6...v2.3.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-06-12 10:32:23 +02:00
0aa624c8ad chore(lib): Remove old validation check preventing loop=True with auto_next=True (#445)
* remove old validation check

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

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

* Update CHANGELOG.md

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-06-02 13:47:49 +02:00
9460f6b135 fix(convert): Whitespace issue in default RevealJS template (#442)
* Fix whitespace issue in RevealJS template

* Edit changelog
2024-05-27 20:49:14 +02:00
c0a240d758 fix(ci): update rye url
See https://github.com/astral-sh/rye/issues/1111
2024-05-24 21:45:45 +02:00
b08073983b fix(lib): prevent filename collision (#429)
* fix(lib): prevent filename collision

Apparently, ManimCE can produce two different animations with the same name (i.e., the same hash). As documented, ManimGL would any produce files with the same name so this fix was needed.

Closes #428

* chore(lib): update comment

chore(lib): update comment

* chore(tests): add test

* chore(tests): remove redundant underscore

* chore(docs): add changelog entry
2024-05-18 10:17:25 +02:00
993acf0e3f [pre-commit.ci] pre-commit autoupdate (#427)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.4.2 → v0.4.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.2...v0.4.4)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-05-18 10:09:41 +02:00
962ff2bffe chore(deps): bump version from 5.1.6 to 5.1.7 2024-05-03 13:48:01 +02:00
9f2e4757ec chore(ci): fancy README and better bump config (#425)
* chore(ci): fancy README and better bump config

* fix(ci): CITATION.cff

* chore(docs): add changelog entry
2024-05-03 13:47:44 +02:00
6d85222fdc chore(ci): update changelog 2024-04-30 10:28:34 +02:00
19f60bf880 chore(version): bump 5.1.5 to 5.1.6 2024-04-30 10:26:26 +02:00
554f076a25 [pre-commit.ci] pre-commit autoupdate (#424)
* [pre-commit.ci] pre-commit autoupdate

updates:
- [github.com/astral-sh/ruff-pre-commit: v0.4.1 → v0.4.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.1...v0.4.2)
- [github.com/pre-commit/mirrors-mypy: v1.9.0 → v1.10.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.9.0...v1.10.0)

* chore(lint): apply suggestions

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
2024-04-30 10:22:49 +02:00
676acc6e3b fix(docs): typos in example 2024-04-30 10:13:17 +02:00
26c3fdca75 fix(ci): remove unused imports 2024-04-27 10:03:05 +02:00
f17e855323 feat(lib): add options for skipping Sphinx directive (#423) 2024-04-27 09:54:59 +02:00
73490298b3 chore(docs): add an Examples Gallery (#422)
* chore(docs): add an Examples Gallery

* chore(docs): add changelog entry
2024-04-27 09:51:55 +02:00
c9a06e45aa fix(docs): Update CHANGELOG.md 2024-04-23 08:42:04 +02:00
45269b9d1c [pre-commit.ci] pre-commit autoupdate (#421)
* [pre-commit.ci] pre-commit autoupdate

updates:
- [github.com/astral-sh/ruff-pre-commit: v0.3.7 → v0.4.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.3.7...v0.4.1)

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-04-23 08:40:39 +02:00
14df6eb55a chore(docs): update changelog 2024-04-18 22:14:35 +02:00
ef014ebac6 chore(version): bump 5.1.4 to 5.1.5 2024-04-18 22:13:11 +02:00
d5d1513d94 chore(dev): move to Rye instead of PDM (#420)
* test: re-add opencv-python

* chore(dev): move to Rye instead of PDM

* try fix

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

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

* fix: build backend

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

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

* fix: string quotes?

* small fixes

* upgrade typing

* fix(ci): rye install on Windows

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

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

* fix(ci): typos

* fix

* fix(ci): actually use right python version

* fix(deps): manimgl

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

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

* fix docs

* another fix

* cleanup

* make sure to use trusted publisher

* chore(docs): remove PDM

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-04-18 22:12:45 +02:00
bd04dae2bf fix(cli): broken --show-config command (#419)
Fix and improve the command.
2024-04-17 20:33:44 +02:00
d5c20d1791 chore(docs): update faq page 2024-04-17 20:07:15 +02:00
527ce2767e chore(docs): add faq page (#418)
* chore(docs): add faq page

Closes #405

* chore(docs): add faq page

Closes #405

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

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

* update changelog

* fix

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-04-17 18:10:46 +02:00
04bca458f1 chore(ci): checking links and spell checking (#417)
* chore(ci): checking links and spell checking

* chore(ci): move markdown-link-check to GitHub ci

Because pre-commit.ci does not have access to the internet...

* fix(lib): revert `reverse-...` utils because of warnings

* chore(ci): checking links and spell checking

* chore(ci): move markdown-link-check to GitHub ci

Because pre-commit.ci does not have access to the internet...

* fix(docs): myst-parser xref cannot end with .html

* fix(docs): oops
2024-04-17 17:59:40 +02:00
4e7abe8706 fix(lib): revert reverse-... utils because of warnings 2024-04-17 17:02:43 +02:00
49e2c31d9a chore(version): bump 5.1.3 to 5.1.4 2024-04-16 17:35:42 +02:00
5920a843f5 Merge remote-tracking branch 'origin/main' into main 2024-04-16 17:35:26 +02:00
59dd365291 chore(deps): remove OpenCV python (#415)
Closes #413
2024-04-16 17:35:23 +02:00
3e2e64b09f chore(docs): update changelog 2024-04-16 17:25:03 +02:00
8a3bf87db8 chore(lib): filter out video files without video stream (#416)
* chore(lib): filter out video files without video stream

This is a ( hopefully temporary) fix to #390.

Closes #390

* fix: Windows issue
2024-04-16 17:17:42 +02:00
498e9af2bf fix(lib): correctly retrieve background color (#414)
Closes #409
2024-04-16 14:33:27 +02:00
24ee23af11 chore(deps): update RevealJS version to 5.1.0 (#412)
Closes #384
2024-04-16 13:53:08 +02:00
a775c4989b [pre-commit.ci] pre-commit autoupdate (#411)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.3.5 → v0.3.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.3.5...v0.3.7)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-04-16 13:23:08 +02:00
04f6ee7f9b [pre-commit.ci] pre-commit autoupdate (#403)
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.5.0 → v4.6.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.5.0...v4.6.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-04-11 09:52:12 +02:00
bbc539b461 chore(docs): add nixpkgs installation documentation (#404) 2024-04-11 09:30:40 +02:00
2d9d263c9c chore(docs): add favicon (#399)
* chore(docs): add favicon

* fix(docs): typo
2024-04-04 14:40:36 +02:00
cfa9c082ab [pre-commit.ci] pre-commit autoupdate (#374)
* [pre-commit.ci] pre-commit autoupdate

updates:
- [github.com/macisamuele/language-formatters-pre-commit-hooks: v2.12.0 → v2.13.0](https://github.com/macisamuele/language-formatters-pre-commit-hooks/compare/v2.12.0...v2.13.0)
- [github.com/astral-sh/ruff-pre-commit: v0.2.1 → v0.3.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.2.1...v0.3.5)
- [github.com/pre-commit/mirrors-mypy: v1.8.0 → v1.9.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.8.0...v1.9.0)

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-04-04 11:14:40 +02:00
67533c460e feat(cli): added separate option for info window screen (#389)
Closes #388.
2024-04-04 11:14:25 +02:00
a85f1c4036 fix(docs): README url 2024-03-08 11:59:08 +01:00
b3fe6f17b9 chore(cite): update citation file 2024-03-07 10:26:04 +01:00
e7182a445d feat(present): add audio output (#382)
* feat(present): add audio output

This PR adds the necessary audio output to the presenter mode, so it can play audio contained in video files, see #375.

However, this does not fix the issue that slides do not contain audio by default, but should be solved by Manim, see #375 for updates on that topic.

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

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

* fix: PR number

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-03-05 11:08:30 +01:00
95 changed files with 8843 additions and 5432 deletions

View File

@ -1,16 +0,0 @@
[bumpversion]
current_version = 5.1.3
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(-rc(?P<release>\d+))?
serialize =
{major}.{minor}.{patch}-rc{release}
{major}.{minor}.{patch}
commit = True
message = chore(version): bump {current_version} to {new_version}
[bumpversion:file:manim_slides/__version__.py]
search = __version__ = "{current_version}"
replace = __version__ = "{new_version}"
[bumpversion:file:CITATION.cff]
search = version: v{current_version}
replace = version: v{new_version}

View File

@ -1,40 +1,177 @@
name: Bug
description: Report an issue to help improve the project.
labels: bug
title: '[BUG] <description>'
title: '[BUG] <short-description-here>'
labels: [bug]
body:
- type: markdown
id: preamble
attributes:
value: |
**Thank you for reporting a problem about Manim Slides!**
If you know how to solve your problem, feel free to submit a PR too!
> [!WARNING]
> Before reporting your bug, please make sure to:
>
> 1. create and activate virtual environment (venv);
> 2. install `manim-slides` and the necessary dependencies;
> 3. and reduce your Python to a minimal working example (MWE).
>
> You can skip the last step if your issue occurs during installation.
- type: checkboxes
id: terms
attributes:
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%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
- label: Created a virtual environment in which I can reproduce my bug;
- type: textarea
id: description
attributes:
label: Description
description: A brief description of the question or issue, also include what you tried and what didn't work
label: Describe the issue
description: A description of the issue, also include what you tried and what didn't work.
validations:
required: true
- type: textarea
id: version
- type: input
id: command
attributes:
label: Version
description: Which version of Manim Slides are you using? You can use `manim-slides --version` to get that information.
label: Command
description: |
Enter the command that failed.
This will be automatically formatted into code, so no need for backticks.
placeholder: manim-slides render mwe.py MWE
validations:
required: true
- type: dropdown
id: issue-type
attributes:
label: Issue Type
description: >
Please select the option in the drop-down.
options:
- Installation issue
- Visual bug when presenting (`manim-slides present`)
- Bug when presenting with HTML/PowerPoint/... format (`manim-slides convert`)
- Other
validations:
required: true
- type: input
id: py-version
attributes:
label: Python version
description: |
Please copy and paste the output of `python --version`.
Make sure to activate your virtual environment first (if any).
placeholder: Python 3.11.8
validations:
required: true
- type: textarea
id: venv
attributes:
label: Python environment
description: |
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: true
- type: dropdown
id: platform
attributes:
label: Platform
description: What is your platform. Linux, macOS, or Windows?
label: What is your platform?
multiple: true
options:
- Linux
- macOS
- Windows
- Other (please precise below)
validations:
required: true
- type: input
id: platform-other
attributes:
label: Other platform
description: Please answer if you have replied *Other* above.
validations:
required: false
- type: textarea
id: code
attributes:
label: Manim Slides Python code
description: |
Please copy and paste a minimal working example (MWE) of your Python code that can reproduce your bug.
This will be automatically formatted into code, so no need for backticks.
placeholder: |
from manim import *
from manim_slides import Slide
class MWE(Slide):
def construct(self):
circle = Circle(radius=2, color=RED)
dot = Dot()
self.play(GrowFromCenter(circle))
self.next_slide(loop=True)
self.play(MoveAlongPath(dot, circle), run_time=0.5)
self.next_slide()
self.play(dot.animate.move_to(ORIGIN))
render: python
validations:
required: false
- type: textarea
id: logs
attributes:
label: Relevant log output
description: |
Please copy and paste any relevant log output.
This will be automatically formatted into code, so no need for backticks.
render: shell
validations:
required: false
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: Please add screenshots if applicable
description: Please add screenshots if applicable.
validations:
required: false
- type: textarea
id: extrainfo
id: extra-info
attributes:
label: Additional information
description: Is there anything else we should know about this bug?
validations:
required: false
- type: textarea
id: suggested-fix
attributes:
label: Recommended fix or suggestions
description: A clear and concise description of how you want to update it.
validations:
required: false

View File

@ -1,57 +1,61 @@
name: Documentation
description: Ask / Report an issue related to the documentation.
title: 'DOC: <description>'
labels: [bug, docs]
title: '[DOC] <short-description-here>'
labels: [documentation]
body:
- type: markdown
id: preamble
attributes:
value: >
**Thank you for wanting to report a problem with manim-slides docs!**
value: |
**Thank you for reporting a problem about Manim Slides' documentation!**
If you know how to solve your problem, feel free to submit a PR too!
If the problem seems straightforward, feel free to submit a PR instead!
Verify first that your issue is not already reported on GitHub [Issues].
[Issues]:
https://github.com/jeertmans/manim-slides/issues
- type: checkboxes
id: terms
attributes:
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;
required: true
- type: textarea
id: description
attributes:
label: Describe the Issue
label: Describe the issue
description: A clear and concise description of the issue you encountered.
validations:
required: true
- type: input
- type: textarea
id: pages
attributes:
label: Affected Page
description: Add a link to page with the problem.
label: Affected page(s)
description: Link to page(s) with the problem.
placeholder: |
+ https://manim-slides.eertmans.be/latest/installation.html
+ https://manim-slides.eertmans.be/latest/features_table.html
validations:
required: true
- type: dropdown
id: issue-type
attributes:
label: Issue Type
label: Issue type
description: >
Please select the option in the drop-down.
<details>
<summary>
<em>Issue?</em>
</summary>
</details>
options:
- Documentation Enhancement
- Documentation Report
- Typo, spelling mistake, broken link, etc.
- Something is missing
- Documentation enhancement
- Other
validations:
required: true
- type: textarea
id: suggested-fix
attributes:
label: Recommended fix or suggestions
description: A clear and concise description of how you want to update it.

View File

@ -1,8 +1,26 @@
name: Feature Request
name: Feature request
description: Have a new idea/feature? Please suggest!
labels: enhancement
title: '[FEATURE] <description>'
title: '[FEATURE] <short-description-here>'
labels: [enhancement]
body:
- type: markdown
id: preamble
attributes:
value: |
**Thank you for suggesting a new feature!**
If you know how to implement it, feel free to submit a PR too!
- type: checkboxes
id: terms
attributes:
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%3Aenhancement+) and [discussions](https://github.com/jeertmans/manim-slides/discussions) to see if my issue had not already been reported;
required: true
- type: textarea
id: description
attributes:
@ -10,6 +28,7 @@ body:
description: A brief description of the enhancement you propose, also include what you tried and what worked.
validations:
required: true
- type: textarea
id: screenshots
attributes:
@ -17,6 +36,7 @@ body:
description: Please add screenshots if applicable
validations:
required: false
- type: textarea
id: extrainfo
attributes:

View File

@ -1,14 +0,0 @@
name: Question/Help/Support
description: Ask us about Manim Slides
title: 'Support: Ask us anything'
labels: [help, question]
body:
- type: textarea
attributes:
label: "Please explain the issue you're experiencing (with as much detail as possible):"
description: >
Please make sure to leave a reference to the document/code you're
referring to.
validations:
required: true

View File

@ -10,11 +10,13 @@
<!-- Describe all the proposed changes in your PR -->
## Check List (Check all the applicable boxes)
## Check List
- [ ] I understand that my contributions needs to pass the checks.
- [ ] If I created new functions / methods, I documented them and add type hints.
- [ ] If I modified already existing code, I updated the documentation accordingly.
Check all the applicable boxes:
- [ ] I understand that my contributions needs to pass the checks;
- [ ] If I created new functions / methods, I documented them and add type hints;
- [ ] If I modified already existing code, I updated the documentation accordingly;
- [ ] The title of my pull request is a short description of the requested changes.
## Screenshots

27
.github/scripts/check_github_issues.py vendored Normal file
View File

@ -0,0 +1,27 @@
"""Check that GitHub issues (and PR) links match the number in Markdown link."""
import glob
import re
import sys
if __name__ == "__main__":
p = re.compile(
r"\[#(?P<number1>[0-9]+)\]"
r"\(https://github\.com/"
r"(?:[a-zA-Z0-9_-]+)/(?:[a-zA-Z0-9_-]+)/"
r"(?:(?:issues)|(?:pull))/(?P<number2>[0-9]+)\)"
)
ret_code = 0
for glob_pattern in sys.argv[1:]:
for file in glob.glob(glob_pattern, recursive=True):
with open(file) as f:
for i, line in enumerate(f):
for m in p.finditer(line):
if m.group("number1") != m.group("number2"):
start, end = m.span()
print(f"{file}:{i}: ", line[start:end], file=sys.stderr) # noqa: T201
ret_code = 1
sys.exit(ret_code)

View File

@ -18,15 +18,17 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install PDM
uses: pdm-project/setup-pdm@v4
- name: Setup uv
uses: astral-sh/setup-uv@v4
with:
python-version: '3.10'
cache: true
enable-cache: true
- name: Build package
run: uv build
- name: Publish to PyPI
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
run: pdm publish
uses: pypa/gh-action-pypi-publish@release/v1
publish-docker:
name: Publish Docker image
@ -34,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
@ -61,11 +62,11 @@ jobs:
print(f"tag_name={ref_tag}", file=f)
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
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

@ -7,7 +7,54 @@ on:
name: Tests
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
pip-install:
strategy:
fail-fast: false
matrix:
os: [macos-13, ubuntu-latest, windows-latest]
pyversion: ['3.9', '3.10', '3.11', '3.12']
extras: [pyside6-full, manimgl]
exclude:
- pyversion: '3.12'
extras: manimgl
runs-on: ${{ matrix.os }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.pyversion }}
cache: pip
- name: Install manim dependencies on MacOS
if: matrix.os == 'macos-latest'
run: brew install ffmpeg py3cairo pango pkg-config scipy
- name: Install manim dependencies on Ubuntu
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
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
if: matrix.os == 'windows-latest'
run: choco install ffmpeg
- name: Install package
shell: bash
env:
extras: ${{ matrix.extras }}
run: pip install ".[$extras]"
pytest:
strategy:
fail-fast: false
@ -18,45 +65,26 @@ 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: Install PDM
uses: pdm-project/setup-pdm@v4
- name: Setup uv
uses: astral-sh/setup-uv@v4
with:
python-version: ${{ matrix.pyversion }}
cache: true
enable-cache: true
# Path related stuff
- name: Append to Path on MacOS
if: matrix.os == 'macos-latest'
run: |
echo "${HOME}/.local/bin" >> $GITHUB_PATH
echo "/Users/runner/Library/Python/${{ matrix.pyversion }}/bin" >> $GITHUB_PATH
- name: Append to Path on Ubuntu
if: matrix.os == 'ubuntu-latest'
run: echo "${HOME}/.local/bin" >> $GITHUB_PATH
- name: Append to Path on Windows
if: matrix.os == 'windows-latest'
run: echo "${HOME}/.local/bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
# OS depedencies
- name: Install manim dependencies on MacOS
if: matrix.os == 'macos-latest'
run: brew install ffmpeg py3cairo
run: brew install ffmpeg py3cairo pango pkg-config scipy
- name: Install manim dependencies on Ubuntu
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev
sudo apt-get install xvfb
sudo apt-get install build-essential python3-dev libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev xvfb
nohup Xvfb $DISPLAY &
- name: Install Windows dependencies
@ -67,22 +95,25 @@ jobs:
if: matrix.os == 'windows-latest'
uses: ssciwr/setup-mesa-dist-win@v2
- name: Install Manim Slides
run: |
pdm sync -Ggithub-action -Gtest
- name: Run pytest
if: matrix.os != 'ubuntu-latest' || matrix.pyversion != '3.11'
run: pdm run pytest
- name: Run pytest and coverage
if: matrix.os == 'ubuntu-latest' && matrix.pyversion == '3.11'
run: pdm run pytest --cov-report xml --cov=manim_slides tests/
run: uv run --python ${{ matrix.pyversion }} --frozen --group tests --no-dev pytest
- name: Upload to codecov.io
if: matrix.os == 'ubuntu-latest' && matrix.pyversion == '3.11'
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
fail_ci_if_error: true
markdown-link-check:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Check links
uses: gaurav-nelson/github-action-markdown-link-check@v1
with:
use-quiet-mode: yes
use-verbose-mode: yes
config-file: .mdlc.json

2
.gitignore vendored
View File

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

8
.mdlc.json Normal file
View File

@ -0,0 +1,8 @@
{
"replacementPatterns": [
{
"pattern": "^/(?<path>.*)$",
"replacement": "https://eertmans.be/manim-slides/latest/$<path>.html"
}
]
}

View File

@ -1,31 +1,47 @@
ci:
autofix_commit_msg: |
chore(fmt): auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
autoupdate_commit_msg: 'chore(deps): pre-commit autoupdate'
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
rev: v5.0.0
hooks:
- id: check-yaml
- id: check-toml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
rev: v2.12.0
rev: v2.14.0
hooks:
- id: pretty-format-yaml
args: [--autofix]
- 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.2.1
rev: v0.11.5
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.8.0
rev: v1.15.0
hooks:
- id: mypy
additional_dependencies: [types-requests, types-setuptools]
- repo: https://github.com/codespell-project/codespell
rev: v2.4.1
hooks:
- id: codespell
additional_dependencies:
- tomli
- repo: local
hooks:
- id: github-issues
name: GitHub issues link check
description: Check issues (and PR) links are matching number.
entry: python .github/scripts/check_github_issues.py
language: system
types: [markdown]

1
.python-version Normal file
View File

@ -0,0 +1 @@
3.11

View File

@ -2,22 +2,17 @@ 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
post_create_environment:
- asdf plugin add uv
- asdf install uv latest
- asdf global uv latest
- UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv sync --group docs --no-dev --no-cache
sphinx:
builder: html
configuration: docs/source/conf.py
fail_on_warning: true
python:
install:
- method: pip
path: .
extra_requirements:
- docs
- magic
- sphinx-directive

View File

@ -8,7 +8,357 @@ 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.0...HEAD)
## [Unreleased](https://github.com/jeertmans/manim-slides/compare/v5.5.1...HEAD)
(unreleased-chore)=
### Chore
- Moved `docs` and `tests` extras, as well as `dev-dependencies`,
inside groups in `dependency-groups`. This could break existing code
when using one of those extras, but as they were not part of the public API,
we do not consider this to be a **breaking change**.
[#542](https://github.com/jeertmans/manim-slides/pull/542)
- Moved `manim_slides.docs.manim_slides_directive` to `manim_slides.sphinxext.manim_slides_directive`.
This is a **breaking change** because documentation configs have
to be updated.
[#242](https://github.com/jeertmans/manim-slides/pull/242)
(v5.5.1)=
## [v5.5.1](https://github.com/jeertmans/manim-slides/compare/v5.5.0...v5.5.1)
(v5.5.1-changed)=
### Changed
- HTML template now always includes the *notes* plugin so that the speaker
view is always available. Previously, it was only included if the slides
had notes.
[#538](https://github.com/jeertmans/manim-slides/pull/538)
- Pressing <kbd>SPACE</kbd> key now pauses the slides, instead of skipping it.
Previously, it was not possible to pause HTML slides, which can be very annoying
when trying to explain something.
[#539](https://github.com/jeertmans/manim-slides/pull/539)
(v5.5.0)=
## [v5.5.0](https://github.com/jeertmans/manim-slides/compare/v5.4.2...v5.5.0)
(v5.5.0-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)
(v5.5.0-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)
(v5.5.0-chore)=
### Chore
- Trimmed whitespaces in HTML template.
[#443](https://github.com/jeertmans/manim-slides/pull/443)
- Bumped RevealJS' version to 5.2 to allow video playing in speaker view.
[#536](https://github.com/jeertmans/manim-slides/pull/536)
(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)
is solved.
[#432](https://github.com/jeertmans/manim-slides/pull/432)
- Removed an old validation check that prevented setting `loop=True` with
`auto_next=True` on `next_slide()`
[#445](https://github.com/jeertmans/manim-slides/pull/445)
- Improved (and fixed) tests for Manim(GL), bumped minimal ManimCE version,
improved coverage, and override dependency conflicts.
[#447](https://github.com/jeertmans/manim-slides/pull/447)
- Improved issue templates.
[#456](https://github.com/jeertmans/manim-slides/pull/456)
- 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)
(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)
(v5.1.8-removed)=
### Removed
- Removed `full-gl` extra, because it does not make sense to ship both
`manimgl` and `manim` together.
[#447](https://github.com/jeertmans/manim-slides/pull/447)
(v5.1.7)=
## [v5.1.7](https://github.com/jeertmans/manim-slides/compare/v5.1.6...v5.1.7)
(v5.1.7-chore)=
### Chore
- Improved the CI for bumping the version and README rendering on PyPI.
[#425](https://github.com/jeertmans/manim-slides/pull/425)
(v5.1.6)=
## [v5.1.6](https://github.com/jeertmans/manim-slides/compare/v5.1.5...v5.1.6)
(v5.1.6-added)=
### Added
- Added options to skip the Manim Slides Sphinx directive.
[#423](https://github.com/jeertmans/manim-slides/pull/423)
(v5.1.6-chore)=
### Chore
- Added an examples gallery.
[#422](https://github.com/jeertmans/manim-slides/pull/422)
(v5.1.5)=
## [v5.1.5](https://github.com/jeertmans/manim-slides/compare/v5.1.4...v5.1.5)
(v5.1.5-chore)=
### Chore
- Added CI for broken HTML links and fixed, plus spell checking.
[#417](https://github.com/jeertmans/manim-slides/pull/417)
- Create FAQ page and clear FAQ from README.md.
[#418](https://github.com/jeertmans/manim-slides/pull/418)
- Used Rye instead of PDM for faster development.
[#420](https://github.com/jeertmans/manim-slides/pull/420)
(v5.1.5-fixed)=
### Fixed
- Fixed broken `--show-config` command.
[#419](https://github.com/jeertmans/manim-slides/pull/419)
(v5.1.4)=
## [v5.1.4](https://github.com/jeertmans/manim-slides/compare/v5.1.3...v5.1.4)
(v5.1.4-added)=
### Added
- Added audio output to `manim-slides present`.
[#382](https://github.com/jeertmans/manim-slides/pull/382)
(v5.1.4-changed)=
### Changed
- Added `--info-window-screen` option and change `--screen-number`
to not move the info window.
[#389](https://github.com/jeertmans/manim-slides/pull/389)
(v5.1.4-chore)=
### Chore
- Created a favicon for the website/documentation.
[#399](https://github.com/jeertmans/manim-slides/pull/399)
- Documented the Nixpkg installation.
[#404](https://github.com/jeertmans/manim-slides/pull/404 )
- Updated the default RevealJS version to 5.1.0.
[#412](https://github.com/jeertmans/manim-slides/pull/412)
- Removed the `opencv-python` dependency.
[#415](https://github.com/jeertmans/manim-slides/pull/415)
(v5.1.4-fixed)=
### Fixed
- Fixed the retrieval of `background_color` with ManimCE.
[#414](https://github.com/jeertmans/manim-slides/pull/414)
- Fixed #390 issue caused by empty media created by ManimCE.
[#416](https://github.com/jeertmans/manim-slides/pull/416)
(v5.1.3)=
## [v5.1.3](https://github.com/jeertmans/manim-slides/compare/v5.1.2...v5.1.3)
(v5.1.3-chore)=
### Chore
- Fix link in documentation.
[#368](https://github.com/jeertmans/manim-slides/pull/368)
- Warn users if not using recommended Qt bindings.
[#373](https://github.com/jeertmans/manim-slides/pull/373)
(v5.1.2)=
## [v5.1.2](https://github.com/jeertmans/manim-slides/compare/v5.1.1...v5.1.2)
(v5.1.2-chore)=
### Chore
- Fix ReadTheDocs version flyout in iframes.
[#367](https://github.com/jeertmans/manim-slides/pull/367)
(v5.1.1)=
## [v5.1.1](https://github.com/jeertmans/manim-slides/compare/v5.1.0...v5.1.1)
@ -59,7 +409,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(v5.1-chore)=
### Chore
- Removed subrocess calls to FFMPEG with direct `libav` bindings using
- Removed subrocess calls to FFmpeg with direct `libav` bindings using
the `av` Python module. This should enhance rendering speed and security.
[#335](https://github.com/jeertmans/manim-slides/pull/335)
- Changed build backend to PDM and reflected on docs.
@ -133,7 +483,7 @@ In an effort to better document changes, this CHANGELOG document is now created.
but the new player should be much easier to maintain and more performant,
than its predecessor.
[#243](https://github.com/jeertmans/manim-slides/pull/243)
- Changed the slide config format to exclude unecessary information.
- Changed the slide config format to exclude unnecessary information.
`StypeType` is removed in favor to one boolean `loop` field. This is
a **breaking change** and one should re-render the slides to apply changes.
[#243](https://github.com/jeertmans/manim-slides/pull/243)

View File

@ -3,25 +3,23 @@
cff-version: 1.2.0
title: Manim Slides
message: A Python package for presenting Manim content anywhere
message: >-
If you use this software, please cite it using our article
in the Journal of Open Source Education.
type: software
authors:
- name: Jérome Eertmans
orcid: 'https://orcid.org/0000-0002-5579-5360'
website: 'https://eertmans.be'
identifiers:
- type: doi
value: 10.21105/jose.00206
description: The paper presenting the software.
doi: 10.5281/zenodo.7971360
repository-code: 'https://github.com/jeertmans/manim-slides'
url: 'https://eertmans.be/manim-slides'
abstract: >-
Manim Slides is a Python package that makes presenting
Manim animations straightforward. With minimal changes
required to pre-existing code, one can slide through
animations in a PowerPoint-like manner, or share its
slides online using ReavealJS power.
slides online using ReavealJS' power.
keywords:
- Education
- Math Animations
@ -29,4 +27,19 @@ keywords:
- PowerPoint
- Python
license: MIT
version: v5.1.3
version: v5.5.1
preferred-citation:
publisher:
name: The Open Journal
type: article
authors:
- name: Jérome Eertmans
orcid: 'https://orcid.org/0000-0002-5579-5360'
doi: 10.21105/jose.00206
journal: Journal of Open Source Education
month: 8
year: 2023
title: 'Manim Slides: A Python package for presenting Manim content anywhere'
volume: 6
number: 66
pages: 206

View File

@ -4,6 +4,8 @@
<img alt="Manim Slides Logo" src="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/logo.png">
</picture>
<!-- start pypi -->
[![Latest Release][pypi-version-badge]][pypi-version-url]
[![Python version][pypi-python-version-badge]][pypi-version-url]
[![PyPI - Downloads][pypi-download-badge]][pypi-version-url]
@ -28,8 +30,8 @@ Manim Slides will *automatically* detect the one you are using!
- [Installation](#installation)
- [Usage](#usage)
- [Comparison with Similar Tools](#comparison-with-similar-tools)
- [F.A.Q](#faq)
* [How to increase quality on Windows](#how-to-increase-quality-on-windows)
- [F.A.Q](https://eertmans.be/manim-slides/latest/faq.html)
- [Citing](#citing)
- [Contributing](#contributing)
* [Reporting an Issue](#reporting-an-issue)
* [Seeking for Help](#seeking-for-help)
@ -49,7 +51,7 @@ for detailed install instructions.
Using Manim Slides is a two-step process:
1. Render animations using `Slide` (resp. `ThreeDSlide`) as a base class instead
of `Scene` (resp. `ThreeDScene`), and add calls to `self.next_slide()`
everytime you want to create a new slide.
every time you want to create a new slide.
2. Run `manim-slides` on rendered animations and display them like a
*PowerPoint* presentation.
@ -57,7 +59,7 @@ The documentation is available [online](https://eertmans.be/manim-slides/).
### Basic Example
Call `self.next_slide()` everytime you want to create a pause between
Call `self.next_slide()` every time you want to create a pause between
animations, and `self.next_slide(loop=True)` if you want the next slide to loop
over animations until the user presses continue:
@ -118,7 +120,7 @@ manim-slides BasicExample
</p>
For detailed usage documentation, run `manim-slides --help`, or go to the
[documentation](https://eertmans.be/manim-slides/reference/cli.html).
[documentation](https://eertmans.be/manim-slides/latest/reference/cli.html).
## Interactive Tutorial
@ -150,27 +152,28 @@ Below is a comparison of the most used ones with Manim Slides:
| Web Browser presentations | Yes | No | Yes | No |
| Offline presentations | Yes, with Qt | Yes, with OpenCV | No | No
## F.A.Q
## Citing
### How to increase quality on Windows
If you use this project, please cite it using the following reference:
On Windows platform, one may encounter a lower image resolution than expected.
Usually, this is observed because Windows rescales every application to
fit the screen.
As found by [@arashash](https://github.com/arashash),
in [#20](https://github.com/jeertmans/manim-slides/issues/20),
the problem can be addressed by changing the scaling factor to 100%:
```bibtex
@article{Jerome_Eertmans_Manim_Slides_A_2023,
title = {{Manim Slides: A Python package for presenting Manim content anywhere}},
author = {{Jérome Eertmans}},
year = 2023,
month = aug,
journal = {Journal of Open Source Education},
volume = 6,
doi = {10.21105/jose.00206}
}
```
<p align="center">
<img alt="Windows Fix Scaling" src="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/windows_quality_fix.png">
</p>
in *Settings*->*Display*.
or by linking this GitHub repository at the end of the presentation.
## Contributing
Contributions are more than welcome! Please read through
[our contributing section](https://eertmans.be/manim-slides/contributing/index.html).
[our contributing section](https://eertmans.be/manim-slides/latest/contributing/index.html).
### Reporting an Issue
@ -202,12 +205,10 @@ be able to help you!
Sometimes, you may have a question about Manim Slides,
not necessarily an issue.
There are two ways you can reach us for questions:
- via the `Question/Help/Support` topic when
[choosing an issue template](https://github.com/jeertmans/manim-slides/issues/new/choose);
- or via
[GitHub discussions](https://github.com/jeertmans/manim-slides/discussions).
First, make sure to read the
[F.A.Q](https://eertmans.be/manim-slides/latest/faq.html) to see if
your question has already been answered. If not, please follow the
recommendation (from that page) to reach us for questions.
<!-- end seeking-for-help -->
@ -226,9 +227,9 @@ you can do so at: [jeertmans@icloud.com](mailto:jeertmans@icloud.com).
[pypi-python-version-badge]: https://img.shields.io/pypi/pyversions/manim-slides
[pypi-download-badge]: https://img.shields.io/pypi/dm/manim-slides
[documentation-badge]: https://readthedocs.org/projects/manim-slides/badge/?version=latest
[documentation-url]: https://manim-slides.readthedocs.io/en/latest/?badge=latest
[doi-badge]: https://zenodo.org/badge/DOI/10.5281/zenodo.8215167.svg
[doi-url]: https://doi.org/10.5281/zenodo.8215167
[documentation-url]: https://manim-slides.readthedocs.io/
[doi-badge]: https://zenodo.org/badge/DOI/10.5281/zenodo.7971360.svg
[doi-url]: https://doi.org/10.5281/zenodo.7971360
[jose-badge]: https://jose.theoj.org/papers/10.21105/jose.00206/status.svg
[jose-url]: https://doi.org/10.21105/jose.00206
[codecov-badge]: https://codecov.io/gh/jeertmans/manim-slides/branch/main/graph/badge.svg?token=8P4DY9JCE4

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

@ -0,0 +1 @@
../../../static/favicon.png

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 customise each link
// 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

@ -4,6 +4,7 @@
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
import os
import sys
from datetime import date
@ -30,11 +31,13 @@ extensions = [
# Additional
"nbsphinx",
"myst_parser",
"sphinxcontrib.programoutput",
"sphinxext.opengraph",
"sphinx_click",
"sphinx_copybutton",
"sphinx_design",
# Custom
"manim_slides.docs.manim_slides_directive",
"manim_slides.sphinxext.manim_slides_directive",
]
autodoc_typehints = "both"
@ -53,8 +56,10 @@ add_module_names = False
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_baseurl = os.environ.get("READTHEDOCS_CANONICAL_URL", "/")
html_theme = "furo"
html_static_path = ["_static"]
html_favicon = "_static/favicon.png"
html_theme_options = {
"light_logo": "logo_light_transparent.png",

View File

@ -18,10 +18,10 @@ workflow
internals
```
[Workflow](./workflow)
[Workflow](/contributing/workflow)
: how to work on this project. Start here if you're a new contributor.
[Internals](./internals)
[Internals](/contributing/internals)
: how Manim Slides is built and how the various parts of it work.
## Reporting an Issue

View File

@ -18,65 +18,36 @@ Useful links:
* [GitHub's Hello World](https://docs.github.com/en/get-started/quickstart/hello-world).
* [GitHub Pull Request in 100 Seconds](https://www.youtube.com/watch?v=8lGpZkjnkt4&ab_channel=Fireship).
Once you feel comfortable with git and GitHub, [fork](https://github.com/jeertmans/manim-slides/fork) the repository, and clone it locally.
Once you feel comfortable with git and GitHub,
[fork](https://github.com/jeertmans/manim-slides/fork)
the repository, and clone it locally.
As for every Python project, using virtual environment is recommended to avoid
conflicts between modules.
For this project, we use [PDM](https://pdm-project.org/) 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 PDM, installation becomes straightforward:
With uv, installation becomes straightforward:
```bash
pdm install
```
This, however, only installs the minimal set of dependencies to run the package.
If you would like to install Manim or ManimGL,
as documented in the [quickstart](../quickstart),
you can use the `-G|--group` option:
```bash
pdm install -Gmanim # For Manim
# or
pdm install -Gmanimgl # For ManimGL
```
Additionnally, Manim Slides comes with groups of dependencies for development purposes:
```bash
pdm install -Gdev # For linters and formatters
# or
pdm install -Gdocs # To build the documentation locally
# or
pdm install -Gtest # To run tests
uv sync
```
:::{note}
You can combine any number of groups or extras when installing the package locally.
You can also install everything with `pdm install -G:all`.
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 `pdm run` to any command, e.g.:
Instead, you either need to prepend `uv run` to any command, e.g.:
```bash
pdm run manim-slides wizard
```
or [enter a new shell](https://pdm-project.org/latest/usage/venv/#activate-a-virtualenv)
that uses this new Python environment:
```bash
eval $(pdm venv activate) # Click on the link above to see shell-specific command
manim-slides wizard
uv run manim-slides wizard
```
## Testing your code
@ -85,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
pdm 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
@ -95,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
pdm run pytest
uv run pytest
```
## Building the documentation
@ -107,7 +78,7 @@ To generate the documentation, run the following:
```bash
cd docs
pdm run make html
uv run make html
```
Then, the output index file is located at `docs/build/html/index.html` and

124
docs/source/faq.md Normal file
View File

@ -0,0 +1,124 @@
# Frequently Asked Questions
This page summarizes a few of the most frequently asked questions
when using Manim Slides.
They are organized by topic.
If your question is not here, please first look through the
[open **and closed** issues on GitHub](https://github.com/jeertmans/manim-slides/issues?q=is%3Aissue)
or within the [discussions](https://github.com/jeertmans/manim-slides/discussions).
If you still cannot find help after that, do not hesitate to create
your own issue or discussion on GitHub!
## Installing
Everything related to installing Manim-Slides.
Please do not forget the carefully read through
the [installation](/installation) page!
## Rendering
Questions related to `manim-slides render [SCENES]...`,
### I cannot render with ManimGL
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 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
Questions related to `manim-slides present [SCENES]...`,
or `manim-slides [SCENES]...` for short.
### Can I have interactive slides
No. Slides are pre-rendered static videos files
and cannot be modified on the fly.
If you need new to have some kind of interactive, look
at the preview feature coupled with the OpenGL renderer
with ManimCE or ManimGL.
### Slides go black when video finishes
This is an issue with Qt,
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.
Usually, this is observed because Windows rescales every application to
fit the screen.
As found by [@arashash](https://github.com/arashash),
in [#20](https://github.com/jeertmans/manim-slides/issues/20),
the problem can be addressed by changing the scaling factor to 100%:
<p align="center">
<img
alt="Windows Fix Scaling"
src="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/windows_quality_fix.png"
>
</p>
in *Settings*->*Display*.
## Converting to any format
Questions that apply to all output formats when using
`manim-slides convert [SCENES]...`.
### What are all possible configuration options
Configuration options can be specified with the syntax
`-c<option_name>=<option_value>`.
To list all accepted options, use `manim-slides convert --to=FORMAT --show-config`,
where `FORMAT` is one of the supported formats.
This will also show the default value for each option.
### How to retrieve the current template
If you want to create your own template, the best is to start from the default one.
You can either download it from the
[template folder](https://github.com/jeertmans/manim-slides/tree/main/manim_slides/cli/convert/templates)
or use the `manim-slides convert --to=FORMAT --show-template` command,
where `FORMAT` is one of the supported formats.
## Converting to HTML
Questions related to `manim-slides convert [SCENES]... output.html`.
### I moved my `.html` file and it stopped working
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,
you need to move the HTML **and its assets** altogether.
## Converting to PPTX
Questions related to `manim-slides convert [SCENES]... output.pptx`.
### My media stop playing after a few slides
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

@ -10,20 +10,22 @@ The following summarizes the different presentation features Manim Slides offers
| :--- | :---: | :---: | :---: | :---: |
| Basic navigation through slides | Yes | Yes | Yes | Yes (static image) |
| Replay slide | Yes | No | No | N/A |
| Pause animation | Yes | No | No | N/A |
| Pause animation | Yes | Yes | No | N/A |
| Play slide in reverse | Yes | No | No | N/A |
| Slide count | Yes | Yes (optional) | Yes (optional) | N/A |
| Needs Python with Manim Slides installed | Yes | No | No | No
| Requires internet access | No | Yes | No | No |
| Requires internet access | No | Depends[^1] | No | No |
| Auto. play slides | Yes | Yes | Yes | N/A |
| Loops support | Yes | Yes | Yes | N/A |
| Fully customizable | No | Yes (`--use-template` option) | No | No |
| Other dependencies | None | A modern web browser | PowerPoint or LibreOffice Impress[^1] | None |
| Works cross-platforms | Yes | Yes | Partly[^1][^2] | Yes |
| Other dependencies | None | A modern web browser | PowerPoint or LibreOffice Impress[^2] | None |
| Works cross-platforms | Yes | Yes | Partly[^2][^3] | Yes |
:::
[^1]: If you encounter a problem where slides do not automatically play or loops do not work,
[^1]: By default, HTML assets are loaded from the internet, but they can be
pre-downloaded and embedded in the HTML file at conversion time.
[^2]: If you encounter a problem where slides do not automatically play or loops do not work,
please
[file an issue on GitHub](https://github.com/jeertmans/manim-slides/issues/new/choose).
[^2]: PowerPoint online does not seem to support automatic playing of videos,
[^3]: PowerPoint online does not seem to support automatic playing of videos,
so you need LibreOffice Impress on Linux platforms.

75
docs/source/gallery.md Normal file
View File

@ -0,0 +1,75 @@
# Examples Gallery
With Manim, the only limit to what you can create is your imagination!
*This also applies to Manim Slides.*
As the field of possibilities is infinitely vast,
it's often useful to **learn** how to use Manim Slides **based on examples**.
The aim of this page is to share with you the creations of some
Manim Slides users, to hopefully inspire you!
Most of them use HTML conversion to make them accessible via a website.
If you too have created content with Manim Slides that is available online
(e.g., a YouTube video or website),
don't hesitate to contact us so that we can share your content on this page!
## Scientif Research
Below are people that dissimenate their research results
using Manim Slides presentations.
### Daniel Panizo Pérez
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
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).
<div style="position:relative;padding-bottom:56.25%;">
<iframe
loading="lazy"
style="width:100%;height:100%;position:absolute;left:0px;top:0px;"
frameborder="1"
width="100%"
height="100%"
allowfullscreen
allow="autoplay"
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/mtheoretical).
### Jérome Eertmans
Jérome, the author of Manim Slides, publishes his presentations
on the topic of *Ray Tracing applied to Radio Propagations* on his
[personal website](https://eertmans.be). He also uses Manim Slides
for presenting at conferences using the *PowerPoint* or HTML conversion.
For example, below are the slides of his
[PhD confirmation](https://eertmans.be/posts/confirmation2023-presentation/).
<div style="position:relative;padding-bottom:56.25%;">
<iframe
loading="lazy"
style="width:100%;height:100%;position:absolute;left:0px;top:0px;"
frameborder="1"
width="100%"
height="100%"
allowfullscreen
allow="autoplay"
src="https://eertmans.be/assets/slides/2023-12-07-confirmation.html">
</iframe>
</div>
## School Work
Below are people that used Manim Slides for school presentations.
*This list is currently empty. Please reach out to us if you have examples
to share!*

View File

@ -23,7 +23,7 @@ og:description: Manim Slides makes creating slides with Manim super easy!
Manim Slides makes creating slides with Manim super easy!
In a [very few steps](./quickstart),
In a [very few steps](/quickstart),
you can create slides and present them either using the GUI, or your browser.
Slide through the demo below to get a quick glimpse on what you can do with
@ -43,6 +43,8 @@ installation
reference/index
features_table
manim_or_manimgl
gallery
faq
```
```{toctree}

View File

@ -12,7 +12,7 @@ The benefit of using pipx is that it will automatically create a new virtual
environment for every package you install.
:::{note}
Everytime you read `pipx install`, you can use `pip install` instead,
Every time you read `pipx install`, you can use `pip install` instead,
if you are working in a virtual environment or else.
:::
@ -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
@ -106,17 +93,14 @@ Along with the optional dependencies for Manim and ManimGL,
Manim Slides offers additional *extras*, that can be activated
using optional dependencies:
- `full`, to include `magic`, `manim`, `manimgl`, and
- `full`, to include `magic`, `manim`, and
`sphinx-directive` extras (see below);
- `magic`, to include a Jupyter magic to render
animations inside notebooks. This automatically installs `manim`,
and does not work with ManimGL;
- `manim` and `manimgl`, for installing the corresponding
dependencies;
- `pyqt6` to include PyQt6 Qt bindings. Those bindings are available
on most platforms and Python version, but produce a weird black
screen between slide with `manim-slides present`,
see [#QTBUG-118501](https://bugreports.qt.io/browse/QTBUG-118501);
- `pyqt6` 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];
@ -134,6 +118,38 @@ pipx install -U "manim-slides[extra1,extra2]"
[^2]: Actually, PySide6 can be installed on Python 3.12, but you will then
observe the same visual bug as with PyQt6.
## Nixpkgs installation
Manim Slides is distributed under Nixpkgs >=24.05.
If you are using Nix or NixOS, you can find Manim Slides under:
- `nixpkgs.manim-slides`, which is meant to be a stand alone application and
includes PyQt6 (see above);
- `nixpkgs.python3Packages.manim-slides`, which is meant to be used as a
module (for notebook magics), and includes IPython but does not include
any Qt binding.
You can try out the Manim Slides package with:
```sh
nix-shell -p manim ffmpeg manim-slides
```
or by adding it to your
[configuration file](https://nixos.org/manual/nixos/stable/#sec-package-management).
Alternatively, you can try Manim Slides in a Python environment with:
```sh
nix-shell -p manim ffmpeg "python3.withPackages(ps: with ps; [ manim-slides, ...])"
```
or bundle this into [your Nix environment](https://wiki.nixos.org/wiki/Python).
:::{note}
Nix does not currently support `manimgl`.
:::
## When you need a Qt backend
Before `v5.1`, Manim Slides automatically included PySide6 as
@ -153,5 +169,5 @@ install those are via optional dependencies, as explained above.
An alternative way to install Manim Slides is to clone the git repository,
and build the package from source. Read the
[contributing guide](./contributing/workflow)
[contributing guide](/contributing/workflow)
to know how to process.

View File

@ -15,7 +15,6 @@ the slides.
If both modules are present in {py:data}`sys.modules`, then Manim Slides will
prefer using `manim`.
### Usage
The simplest way to use Manim Slides with the correct Manim API is to:

View File

@ -1,7 +1,7 @@
# Quickstart
If not already, install Manim Slides, along with either Manim or ManimGL,
see [installation](./installation).
see [installation](/installation).
## Creating your first slides
@ -31,4 +31,5 @@ The output slides should look this this:
:quality: high
```
For more advanced examples, see the [Examples](reference/examples) section.
For more advanced examples,
see the [Examples](/reference/examples) section.

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

@ -4,7 +4,34 @@ This page contains an exhaustive list of all the commands available with `manim-
```{eval-rst}
.. click:: manim_slides.__main__:cli
.. click:: manim_slides.cli.commands:main
: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

@ -4,7 +4,7 @@ One of the benefits of the `convert` command is the use of template files.
Currently, only the HTML export uses one. If not specified, the template
will be the one shipped with Manim Slides, see
[`manim_slides/templates/revealjs.html`](https://github.com/jeertmans/manim-slides/blob/main/manim_slides/templates/revealjs.html).
[`manim_slides/cli/convert/templates/revealjs.html`](https://github.com/jeertmans/manim-slides/blob/main/manim_slides/cli/convert/templates/revealjs.html).
Because you can actually use your own template with the `--use-template`
option, possibilities are infinite!

View File

@ -2,7 +2,8 @@
Manim Slides' graphical user interface (GUI) is the *de facto* way to present slides.
If you do not specify one of the commands listed in the [CLI reference](./cli),
If you do not specify one of the commands listed in the
[CLI reference](/reference/cli),
Manim Slides will use **present** by default, which launches a GUI window,
playing your scene(s) like so:
@ -25,7 +26,7 @@ directory, you should not worry about that :-)
## Configuration File
It is possible to configure Manim Slides via a configuration file, even though
this feature is currently limited. You may initiliaze the default configuration
this feature is currently limited. You may initialize the default configuration
file with:
```bash

View File

@ -30,11 +30,11 @@ manim-slides convert --show-config
## Using a Custom Template
The default template used for HTML conversion can be found on
[GitHub](https://github.com/jeertmans/manim-slides/blob/main/manim_slides/data/revealjs_template.html)
[GitHub](https://github.com/jeertmans/manim-slides/blob/main/manim_slides/cli/convert/templates/revealjs.html)
or printed with the `--show-template` option.
If you wish to use another template, you can do so with the
`--use-template FILE` option.
## More about HTML Slides
You can read more about HTML slides in the [sharing](./sharing) section.
You can read more about HTML slides in the [sharing](/reference/sharing) section.

View File

@ -16,23 +16,23 @@ sharing
Sphinx Extension <sphinx_extension>
```
[Application Programming Interface](./api): list of classes and methods that may
[Application Programming Interface](/reference/api): list of classes and methods that may
be useful to the end-user.
[Command Line Interface](./cli): list of all commands available using Manim
[Command Line Interface](/reference/cli): list of all commands available using Manim
Slides' executable.
[Examples](./examples): curated list of examples and their output.
[Examples](/reference/examples): curated list of examples and their output.
[Graphical User Interface](./gui): details about the main Manim Slide' feature.
[Graphical User Interface](/reference/gui): details about the main Manim Slide' feature.
[HTML Presentation](./html): an alternative way of presenting your animations.
[HTML Presentation](/reference/html): an alternative way of presenting your animations.
[IPython Magic](./ipython_magic): a magic to render and display Manim Slides inside notebooks.
[IPython Magic](/reference/ipython_magic): a magic to render and display Manim Slides inside notebooks.
+ [Example](./magic_example): example notebook using the magics.
+ [Example](/reference/magic_example): example notebook using the magics.
[Sharing](./sharing): how to share your presentation with others.
[Sharing](/reference/sharing): how to share your presentation with others.
[Sphinx Extension](./sphinx_extension): a Sphinx extension for diplaying Manim Slides animations within your documentation.
[Sphinx Extension](/reference/sphinx_extension): a Sphinx extension for displaying Manim Slides animations within your documentation.

View File

@ -18,6 +18,7 @@
"outputs": [],
"source": [
"from manim import *\n",
"\n",
"from manim_slides import *"
]
},
@ -77,9 +78,9 @@
],
"metadata": {
"kernelspec": {
"display_name": "manim-slides",
"display_name": ".venv",
"language": "python",
"name": "manim-slides"
"name": "python3"
},
"language_info": {
"codemirror_mode": {
@ -91,7 +92,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.10.6"
"version": "3.11.8"
}
},
"nbformat": 4,

View File

@ -126,15 +126,21 @@ to use an `iframe`:
</div>
```
<!-- markdown-link-check-disable -->
<!-- see why: https://github.com/tcort/markdown-link-check/discussions/189 -->
The additional code comes from
[this article](https://faq.dailymotion.com/hc/en-us/articles/360022841393-How-to-preserve-the-player-aspect-ratio-on-a-responsive-page)
and it there to preserve the original aspect ratio (16:9).
<!-- markdown-link-check-enable -->
### 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
@ -142,8 +148,8 @@ HTML conversion makes it convenient to play your presentation on a
remote server.
This is how your are able to watch all the examples on this website. If you want
to know how to share your slide with GitHub pages, see the
[workflow file](https://github.com/jeertmans/manim-slides/blob/main/.github/workflows/pages.yml).
to know how to share your slide with GitHub pages, check out the
[Manim Slides Starter GitHub repository template](https://github.com/jeertmans/manim-slides-starter).
:::{warning}
Keep in mind that playing large video files over the internet network
@ -161,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

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

View File

@ -40,7 +40,7 @@ class ConvertExample(Slide):
step_2 = Text("2. Replace Scene with Slide")
step_3 = Text("3. In construct, add pauses where you need")
step_4 = Text("4. You can also create loops")
step_5 = Text("5. Render you scene with Manim")
step_5 = Text("5. Render your scene with Manim")
step_6 = Text("6. Open your presentation with Manim Slides")
for step in [step_1, step_2, step_3, step_4, step_5, step_6]:
@ -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 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)
@ -191,7 +191,7 @@ class Example(Slide):
self.play(Transform(code, code_step_6))
self.play(code.animate.shift(UP), FadeIn(code_step_7), FadeIn(or_text))
watch_text = Text("Watch result on next slides!").shift(2 * DOWN).scale(0.5)
watch_text = Text("Watch results on next slides!").shift(2 * DOWN).scale(0.5)
self.next_slide(loop=True)
self.play(FadeIn(watch_text))

View File

@ -1,6 +1,13 @@
"""
Manim Slides module.
Submodules are lazily imported, in order to provide a faster import experience
in some cases.
"""
import sys
from types import ModuleType
from typing import Any, List
from typing import Any
from .__version__ import __version__
@ -8,9 +15,7 @@ from .__version__ import __version__
class Module(ModuleType):
def __getattr__(self, name: str) -> Any:
if name == "Slide" or name == "ThreeDSlide":
module = __import__(
"manim_slides.slide", None, None, ["Slide", "ThreeDSlide"]
)
module = __import__("manim_slides.slide", None, None, [name])
return getattr(module, name)
elif name == "ManimSlidesMagic":
module = __import__(
@ -29,7 +34,7 @@ class Module(ModuleType):
return ModuleType.__getattribute__(self, name)
def __dir__(self) -> List[str]:
def __dir__(self) -> list[str]:
result = list(new_module.__all__)
result.extend(
(

View File

@ -1,10 +1,8 @@
import json
import click
import requests
from click_default_group import DefaultGroup
"""Manim Slides' main entrypoint."""
from .__version__ import __version__
from .checkhealth import checkhealth
from .cli.commands import main
from .convert import convert
from .logger import logger
from .present import list_scenes, present
@ -63,6 +61,7 @@ def cli(notify_outdated_version: bool) -> None:
cli.add_command(convert)
cli.add_command(checkhealth)
cli.add_command(init)
cli.add_command(list_scenes)
cli.add_command(present)
@ -70,4 +69,4 @@ cli.add_command(render)
cli.add_command(wizard)
if __name__ == "__main__":
cli()
main()

View File

@ -1 +1,3 @@
__version__ = "5.1.3"
"""Manim Slides' version."""
__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

@ -0,0 +1,72 @@
"""Manim Slides' CLI."""
import json
import click
import requests
from click_default_group import DefaultGroup
from ..__version__ import __version__
from ..core.logger import logger
from .convert.commands import convert
from .present.commands import list_scenes, present
from .render.commands import render
from .wizard.commands 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 main(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}")
main.add_command(convert)
main.add_command(init)
main.add_command(list_scenes)
main.add_command(present)
main.add_command(render)
main.add_command(wizard)

156
manim_slides/cli/commons.py Normal file
View File

@ -0,0 +1,156 @@
from pathlib import Path
from typing import Any, Callable
import click
from click import Context, Parameter
from ..core.config import list_presentation_configs
from ..core.defaults import CONFIG_PATH, FOLDER_PATH
from ..core.logger import logger
F = Callable[..., Any]
Wrapper = Callable[[F], F]
def config_path_option(function: F) -> F:
"""Wrap a function to add configuration path option."""
wrapper: Wrapper = click.option(
"-c",
"--config",
"config_path",
metavar="FILE",
default=CONFIG_PATH,
type=click.Path(dir_okay=False, path_type=Path),
help="Set path to configuration file.",
show_default=True,
)
return wrapper(function)
def config_options(function: F) -> F:
"""Wrap a function to add configuration options."""
function = config_path_option(function)
function = click.option(
"-f", "--force", is_flag=True, help="Overwrite any existing configuration file."
)(function)
function = click.option(
"-m",
"--merge",
is_flag=True,
help="Merge any existing configuration file with the new configuration.",
)(function)
return function
def verbosity_option(function: F) -> F:
"""Wrap a function to add verbosity option."""
def callback(ctx: Context, param: Parameter, value: str) -> None:
if not value or ctx.resilient_parsing:
return
logger.setLevel(value)
wrapper: Wrapper = click.option(
"-v",
"--verbosity",
type=click.Choice(
["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
case_sensitive=False,
),
help="Verbosity of CLI output.",
default=None,
expose_value=False,
envvar="MANIM_SLIDES_VERBOSITY",
show_envvar=True,
callback=callback,
)
return wrapper(function)
def folder_path_option(function: F) -> F:
"""Wrap a function to add folder path option."""
def callback(ctx: Context, param: Parameter, value: Path) -> Path:
if not value.exists():
raise click.UsageError(
f"Invalid value for '--folder': Directory '{value}' does not exist. "
"Did you render the animations first?",
ctx=ctx,
)
return value
wrapper: Wrapper = click.option(
"--folder",
metavar="DIRECTORY",
default=FOLDER_PATH,
type=click.Path(file_okay=False, path_type=Path),
callback=callback,
help="Set slides folder.",
show_default=True,
is_eager=True, # Needed to expose its value to other callbacks
)
return wrapper(function)
def scenes_argument(function: F) -> F:
"""
Wrap a function to add a scenes arguments.
This function assumes that :func:`folder_path_option` is also used
on the same decorated function.
"""
def callback(ctx: Context, param: Parameter, value: tuple[str]) -> list[Path]:
folder: Path = ctx.params.get("folder")
presentation_config_paths = list_presentation_configs(folder)
scene_names = [path.stem for path in presentation_config_paths]
num_scenes = len(scene_names)
num_digits = len(str(num_scenes))
if num_scenes == 0:
raise click.UsageError(
f"Folder {folder} does not contain "
"any valid config file, did you render the animations first?"
)
paths = []
if value:
for scene_name in value:
try:
i = scene_names.index(scene_name)
paths.append(presentation_config_paths[i])
except ValueError:
raise click.UsageError(
f"Could not find scene `{scene_name}` in: "
+ ", ".join(scene_names)
+ ". Did you make a typo or forgot to render the animations first?"
) from None
else:
click.echo(
"Choose at least one or more scenes from "
"(enter the corresponding number):\n"
+ "\n".join(
f"- {i:{num_digits}d}: {name}"
for i, name in enumerate(scene_names, start=1)
)
)
continue_prompt = True
while continue_prompt:
index = click.prompt(
"Please enter a value", type=click.IntRange(1, num_scenes)
)
paths.append(presentation_config_paths[index - 1])
continue_prompt = click.confirm(
"Do you want to enter an additional scene?"
)
return paths
wrapper: Wrapper = click.argument("scenes", nargs=-1, callback=callback)
return wrapper(function)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
"""Manim Slides conversion templates."""

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>
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/plugin/notes/notes.min.js"></script>
<!-- To include plugins, see: https://revealjs.com/plugins/ -->
{% if has_notes -%}
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/plugin/markdown/markdown.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/plugin/notes/notes.min.js"></script>
{%- endif -%}
{% if has_notes %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/plugin/markdown/markdown.min.js"></script>
{% 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,171 @@
// from 1.3 -> 2.1. If "grid" is used, the same navigation takes you
// from 1.3 -> 2.3.
navigationMode: {{ navigation_mode }},
// Randomizes the order of slides each time the presentation loads
shuffle: {{ shuffle }},
// Turns fragments on and off globally
fragments: {{ fragments }},
// Flags whether to include the current fragment in the URL,
// so that reloading brings you to the same fragment position
fragmentInURL: {{ fragment_in_url }},
// Flags if the presentation is running in an embedded mode,
// i.e. contained within a limited portion of the screen
embedded: {{ embedded }},
// Flags if we should show a help overlay when the question-mark
// key is pressed
help: {{ help }},
// Flags if it should be possible to pause the presentation (blackout)
pause: {{ pause }},
// Flags if speaker notes should be visible to all viewers
showNotes: {{ show_notes }},
// Global override for autolaying embedded media (video/audio/iframe)
// - null: Media will only autoplay if data-autoplay is present
// - true: All media will autoplay, regardless of individual setting
// - false: No media will autoplay, regardless of individual setting
autoPlayMedia: {{ auto_play_media }},
// Global override for preloading lazy-loaded iframes
// - null: Iframes with data-src AND data-preload will be loaded when within
// the viewDistance, iframes with only data-src will be loaded when visible
// - true: All iframes with data-src will be loaded when within the viewDistance
// - false: All iframes with data-src will be loaded only when visible
preloadIframes: {{ preload_iframes }},
// Can be used to globally disable auto-animation
autoAnimate: {{ auto_animate }},
// Optionally provide a custom element matcher that will be
// used to dictate which elements we can animate between.
autoAnimateMatcher: {{ auto_animate_matcher }},
// Default settings for our auto-animate transitions, can be
// overridden per-slide or per-element via data arguments
autoAnimateEasing: {{ auto_animate_easing }},
autoAnimateDuration: {{ auto_animate_duration }},
autoAnimateUnmatched: {{ auto_animate_unmatched }},
// CSS properties that can be auto-animated. Position & scale
// is matched separately so there's no need to include styles
// like top/right/bottom/left, width/height or margin.
autoAnimateStyles: {{ auto_animate_styles }},
// Controls automatic progression to the next slide
// - 0: Auto-sliding only happens if the data-autoslide HTML attribute
// is present on the current slide or fragment
// - 1+: All slides will progress automatically at the given interval
// - false: No auto-sliding, even if data-autoslide is present
autoSlide: {{ auto_slide }},
// Stop auto-sliding after user input
autoSlideStoppable: {{ auto_slide_stoppable }},
// Use this method for navigation when auto-sliding (defaults to navigateNext)
autoSlideMethod: {{ auto_slide_method }},
// Specify the average time in seconds that you think you will spend
// presenting each slide. This is used to show a pacing timer in the
// speaker view
defaultTiming: {{ default_timing }},
// Enable slide navigation via mouse wheel
mouseWheel: {{ mouse_wheel }},
// Opens links in an iframe preview overlay
// Add `data-preview-link` and `data-preview-link="false"` to customise each link
// 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 }}
});
// Override SPACE to play / pause the video
Reveal.addKeyBinding(
{
keyCode: 32,
key: 'SPACE',
description: 'Play / pause video'
},
() => {
var currentVideos = Reveal.getCurrentSlide().slideBackgroundContentElement.getElementsByTagName("video");
if (currentVideos.length > 0) {
if (currentVideos[0].paused == true) currentVideos[0].play();
else currentVideos[0].pause();
} else {
Reveal.next();
}
}
);
{% 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

@ -0,0 +1 @@
"""Manim Slides' presentation commands."""

View File

@ -1,32 +1,20 @@
import signal
import sys
from pathlib import Path
from typing import List, Optional, Tuple
from typing import Literal, Optional
import click
from click import Context, Parameter
from pydantic import ValidationError
from ..commons import config_path_option, folder_path_option, verbosity_option
from ..config import Config, PresentationConfig
from ..logger import logger
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]'`."
)
from ...core.config import Config, PresentationConfig, list_presentation_configs
from ...core.logger import logger
from ..commons import (
config_path_option,
folder_path_option,
scenes_argument,
verbosity_option,
)
@click.command()
@ -35,11 +23,13 @@ def warn_if_non_desirable_pyside6_version() -> None:
@verbosity_option
def list_scenes(folder: Path) -> None:
"""List available scenes."""
for i, scene in enumerate(_list_scenes(folder), start=1):
click.secho(f"{i}: {scene}", fg="green")
scene_names = [path.stem for path in list_presentation_configs(folder)]
num_digits = len(str(len(scene_names)))
for i, scene_name in enumerate(scene_names, start=1):
click.secho(f"{i:{num_digits}d}: {scene_name}", fg="green")
def _list_scenes(folder: Path) -> List[str]:
def _list_scenes(folder: Path) -> list[str]:
"""List available scenes in given directory."""
scenes = []
@ -50,7 +40,7 @@ def _list_scenes(folder: Path) -> List[str]:
except (
Exception
) as e: # Could not parse this file as a proper presentation config
logger.warn(
logger.warning(
f"Something went wrong with parsing presentation config `{filepath}`: {e}"
)
@ -59,7 +49,7 @@ def _list_scenes(folder: Path) -> List[str]:
return scenes
def prompt_for_scenes(folder: Path) -> List[str]:
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))
@ -71,7 +61,7 @@ def prompt_for_scenes(folder: Path) -> List[str]:
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]:
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):
@ -93,8 +83,8 @@ def prompt_for_scenes(folder: Path) -> List[str]:
def get_scenes_presentation_config(
scenes: List[str], folder: Path
) -> List[PresentationConfig]:
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)
@ -116,7 +106,7 @@ def get_scenes_presentation_config(
def start_at_callback(
ctx: Context, param: Parameter, values: str
) -> Tuple[Optional[int], ...]:
) -> tuple[Optional[int], ...]:
if values == "(None, None)":
return (None, None)
@ -147,7 +137,7 @@ def start_at_callback(
@click.command()
@click.argument("scenes", nargs=-1)
@scenes_argument
@config_path_option
@folder_path_option
@click.option("--start-paused", is_flag=True, help="Start paused.")
@ -239,13 +229,29 @@ 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",
"info_window_screen_number",
metavar="NUMBER",
type=int,
default=None,
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[str],
def present( # noqa: C901
scenes: list[str],
config_path: Path,
folder: Path,
start_paused: bool,
@ -254,13 +260,14 @@ def present(
exit_after_last_slide: bool,
hide_mouse: bool,
aspect_ratio: str,
start_at: Tuple[Optional[int], Optional[int], Optional[int]],
start_at: tuple[Optional[int], Optional[int], Optional[int]],
start_at_scene_number: int,
start_at_slide_number: int,
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:
"""
Present SCENE(s), one at a time, in order.
@ -276,7 +283,7 @@ def present(
if skip_all:
exit_after_last_slide = True
presentation_configs = get_scenes_presentation_config(scenes, folder)
presentation_configs = [PresentationConfig.from_file(path) for path in scenes]
if config_path.exists():
try:
@ -293,34 +300,54 @@ 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
from ..qt_utils import qapp
from .player import Player
app = qapp()
app.setApplicationName("Manim Slides")
if screen_number is not None:
screens = app.screens()
def get_screen(number: int) -> Optional[QScreen]:
try:
screen = app.screens()[screen_number]
return screens[number]
except IndexError:
logger.error(
f"Invalid screen number {screen_number}, "
f"allowed values are from 0 to {len(app.screens())-1} (incl.)"
f"Invalid screen number {number}, "
f"allowed values are from 0 to {len(screens) - 1} (incl.)"
)
screen = None
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
from qtpy.QtCore import Qt
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
aspect_ratio_modes = {
"keep": Qt.KeepAspectRatio,
"ignore": Qt.IgnoreAspectRatio,
}
from .player import Player
player = Player(
config,
presentation_configs,
@ -335,10 +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

@ -1,10 +1,10 @@
from datetime import datetime
from pathlib import Path
from typing import List, Optional
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 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
@ -56,7 +52,7 @@ class Info(QWidget): # type: ignore[misc]
self.video_sink = main_video_widget.videoSink()
left_layout.addWidget(main_video_widget)
# Current slide informations
# Current slide information
self.scene_label = QLabel()
self.slide_label = QLabel()
@ -169,7 +165,7 @@ class Player(QMainWindow): # type: ignore[misc]
def __init__(
self,
config: Config,
presentation_configs: List[PresentationConfig],
presentation_configs: list[PresentationConfig],
*,
start_paused: bool = False,
full_screen: bool = False,
@ -183,6 +179,7 @@ class Player(QMainWindow): # type: ignore[misc]
playback_rate: float = 1.0,
next_terminates_loop: bool = False,
hide_info_window: bool = False,
info_window_screen: Optional[QScreen] = None,
):
super().__init__()
@ -225,12 +222,16 @@ 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()
self.video_widget.setAspectRatioMode(aspect_ratio_mode)
self.setCentralWidget(self.video_widget)
self.media_player = QMediaPlayer(self)
self.media_player.setAudioOutput(self.audio_output)
self.media_player.setVideoOutput(self.video_widget)
self.playback_rate = playback_rate
@ -238,13 +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=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
@ -314,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()
@ -338,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()
@ -463,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()
@ -479,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:
@ -510,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()
@ -530,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:
@ -540,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

@ -1,18 +1,7 @@
"""
Alias command to either
``manim render [OPTIONS] [ARGS]...`` or
``manimgl [OPTIONS] [ARGS]...``.
This is especially useful for two reasons:
1. You can are sure to execute the rendering command with the same Python environment
as for ``manim-slides``.
2. You can pass options to the config.
"""
"""Manim Slides' rendering commands."""
import subprocess
import sys
from typing import Tuple
import click
@ -39,16 +28,28 @@ import click
help="If set, use ManimGL renderer.",
)
@click.argument("args", metavar="[RENDERER_ARGS]...", nargs=-1, type=click.UNPROCESSED)
def render(ce: bool, gl: bool, args: Tuple[str, ...]) -> None:
def render(ce: bool, gl: bool, args: tuple[str, ...]) -> None:
"""
Render SCENE(s) from the input FILE, using the specified renderer.
Use ``manim-slides render --help`` to see help information for
a specific renderer.
Alias command to either
``manim render [OPTIONS] [ARGS]...`` or
``manimgl [OPTIONS] [ARGS]...``.
This is especially useful for two reasons:
1. You can are sure to execute the rendering command with the same Python environment
as for ``manim-slides``.
2. You can pass options to the config.
"""
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:
subprocess.run([sys.executable, "-m", "manim", "render", *args])
from manim.cli.render.commands import render as render_ce
render_ce(args, standalone_mode=False)

View File

@ -0,0 +1 @@
"""Manim Slides' wizard."""

View File

@ -3,10 +3,9 @@ from pathlib import Path
import click
from ...core.config import Config
from ...core.logger import logger
from ..commons import config_options, verbosity_option
from ..config import Config
from ..defaults import CONFIG_PATH
from ..logger import logger
@click.command()
@ -37,7 +36,7 @@ def _init(
mode.
"""
if config_path.exists():
click.secho(f"The `{CONFIG_PATH}` configuration file exists")
logger.debug(f"The `{config_path}` configuration file exists")
if not force and not merge:
choice = click.prompt(
@ -57,7 +56,7 @@ def _init(
if force:
logger.debug(f"Overwriting `{config_path}` if exists")
elif merge:
logger.debug("Merging new config into `{config_path}`")
logger.debug(f"Merging new config into `{config_path}`")
if not skip_interactive:
if config_path.exists():
@ -82,4 +81,4 @@ def _init(
config.to_file(config_path)
click.secho(f"Configuration file successfully saved to `{config_path}`")
logger.debug(f"Configuration file successfully saved to `{config_path}`")

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

@ -1,82 +0,0 @@
from pathlib import Path
from typing import Any, Callable
import click
from click import Context, Parameter
from .defaults import CONFIG_PATH, FOLDER_PATH
from .logger import logger
F = Callable[..., Any]
Wrapper = Callable[[F], F]
def config_path_option(function: F) -> F:
"""Wrap a function to add configuration path option."""
wrapper: Wrapper = click.option(
"-c",
"--config",
"config_path",
metavar="FILE",
default=CONFIG_PATH,
type=click.Path(dir_okay=False, path_type=Path),
help="Set path to configuration file.",
show_default=True,
)
return wrapper(function)
def config_options(function: F) -> F:
"""Wrap a function to add configuration options."""
function = config_path_option(function)
function = click.option(
"-f", "--force", is_flag=True, help="Overwrite any existing configuration file."
)(function)
function = click.option(
"-m",
"--merge",
is_flag=True,
help="Merge any existing configuration file with the new configuration.",
)(function)
return function
def verbosity_option(function: F) -> F:
"""Wrap a function to add verbosity option."""
def callback(ctx: Context, param: Parameter, value: str) -> None:
if not value or ctx.resilient_parsing:
return
logger.setLevel(value)
wrapper: Wrapper = click.option(
"-v",
"--verbosity",
type=click.Choice(
["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
case_sensitive=False,
),
help="Verbosity of CLI output.",
default=None,
expose_value=False,
envvar="MANIM_SLIDES_VERBOSITY",
show_envvar=True,
callback=callback,
)
return wrapper(function)
def folder_path_option(function: F) -> F:
"""Wrap a function to add folder path option."""
wrapper: Wrapper = click.option(
"--folder",
metavar="DIRECTORY",
default=FOLDER_PATH,
type=click.Path(exists=True, file_okay=False, path_type=Path),
help="Set slides folder.",
show_default=True,
)
return wrapper(function)

View File

@ -1,715 +0,0 @@
import mimetypes
import os
import platform
import subprocess
import sys
import tempfile
import webbrowser
from base64 import b64encode
from enum import Enum
from importlib import resources
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Type, Union
import click
import cv2
import pptx
from click import Context, Parameter
from jinja2 import Template
from lxml import etree
from PIL import Image
from pydantic import (
BaseModel,
ConfigDict,
Field,
FilePath,
GetCoreSchemaHandler,
PositiveFloat,
PositiveInt,
ValidationError,
conlist,
)
from pydantic_core import CoreSchema, core_schema
from pydantic_extra_types.color import Color
from tqdm import tqdm
from . import templates
from .commons import folder_path_option, verbosity_option
from .config import PresentationConfig
from .logger import logger
from .present import get_scenes_presentation_config
def open_with_default(file: Path) -> None:
system = platform.system()
if system == "Darwin":
subprocess.call(("open", str(file)))
elif system == "Windows":
os.startfile(str(file)) # type: ignore[attr-defined]
else:
subprocess.call(("xdg-open", str(file)))
def validate_config_option(
ctx: Context, param: Parameter, value: Any
) -> Dict[str, str]:
config = {}
for c_option in value:
try:
key, value = c_option.split("=")
config[key] = value
except ValueError:
raise click.BadParameter(
f"Configuration options `{c_option}` could not be parsed into "
"a proper (key, value) pair. "
"Please use an `=` sign to separate key from value."
) from None
return config
def file_to_data_uri(file: Path) -> str:
"""Read a video and return the corresponding data-uri."""
b64 = b64encode(file.read_bytes()).decode("ascii")
mime_type = mimetypes.guess_type(file)[0] or "video/mp4"
return f"data:{mime_type};base64,{b64}"
def get_duration_ms(file: Path) -> float:
"""Read a video and return its duration in milliseconds."""
cap = cv2.VideoCapture(str(file))
fps: int = cap.get(cv2.CAP_PROP_FPS)
frame_count: int = cap.get(cv2.CAP_PROP_FRAME_COUNT)
return 1000 * frame_count / fps
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
def convert_to(self, dest: Path) -> None:
"""Convert self, i.e., a list of presentations, into a given format."""
raise NotImplementedError
def load_template(self) -> str:
"""
Return the template as a string.
An empty string is returned if no template is used.
"""
return ""
def open(self, file: Path) -> Any:
"""Open a file, generated with converter, using appropriate application."""
raise NotImplementedError
@classmethod
def from_string(cls, s: str) -> Type["Converter"]:
"""Return the appropriate converter from a string name."""
return {
"html": RevealJS,
"pdf": PDF,
"pptx": PowerPoint,
}[s]
class Str(str):
"""A simple string, but quoted when needed."""
# This fixes pickling issue on Python 3.8
__reduce_ex__ = str.__reduce_ex__
@classmethod
def __get_pydantic_core_schema__(
cls, source_type: Any, handler: GetCoreSchemaHandler
) -> CoreSchema:
return core_schema.str_schema()
def __str__(self) -> str:
"""Ensure that the string is correctly quoted."""
if self in ["true", "false", "null"]:
return self
else:
return f"'{super().__str__()}'"
class StrEnum(Enum):
def __str__(self) -> str:
return str(self.value)
Function = str # Basically, anything
class JsTrue(str, StrEnum):
true = "true"
class JsFalse(str, StrEnum):
false = "false"
class JsBool(Str, StrEnum): # type: ignore
true = "true"
false = "false"
class JsNull(Str, StrEnum): # type: ignore
null = "null"
class ControlsLayout(Str, StrEnum): # type: ignore
edges = "edges"
bottom_right = "bottom-right"
class ControlsBackArrows(Str, StrEnum): # type: ignore
faded = "faded"
hidden = "hidden"
visibly = "visibly"
class SlideNumber(Str, StrEnum): # type: ignore
true = "true"
false = "false"
hdotv = "h.v"
handv = "h/v"
c = "c"
candt = "c/t"
class ShowSlideNumber(Str, StrEnum): # type: ignore
all = "all"
print = "print"
speaker = "speaker"
class KeyboardCondition(Str, StrEnum): # type: ignore
null = "null"
focused = "focused"
class NavigationMode(Str, StrEnum): # type: ignore
default = "default"
linear = "linear"
grid = "grid"
class AutoPlayMedia(Str, StrEnum): # type: ignore
null = "null"
true = "true"
false = "false"
PreloadIframes = AutoPlayMedia
class AutoAnimateMatcher(Str, StrEnum): # type: ignore
null = "null"
class AutoAnimateEasing(Str, StrEnum): # type: ignore
ease = "ease"
AutoSlide = Union[PositiveInt, JsFalse]
class AutoSlideMethod(Str, StrEnum): # type: ignore
null = "null"
MouseWheel = Union[JsNull, float]
class Transition(Str, StrEnum): # type: ignore
none = "none"
fade = "fade"
slide = "slide"
convex = "convex"
concave = "concave"
zoom = "zoom"
class TransitionSpeed(Str, StrEnum): # type: ignore
default = "default"
fast = "fast"
slow = "slow"
class BackgroundSize(Str, StrEnum): # type: ignore
# From: https://developer.mozilla.org/en-US/docs/Web/CSS/background-size
# TODO: support more background size
contain = "contain"
cover = "cover"
BackgroundTransition = Transition
class Display(Str, StrEnum): # type: ignore
block = "block"
class RevealTheme(str, StrEnum):
black = "black"
white = "white"
league = "league"
beige = "beige"
sky = "sky"
night = "night"
serif = "serif"
simple = "simple"
soralized = "solarized"
blood = "blood"
moon = "moon"
black_contrast = "black-contrast"
white_contrast = "white-contrast"
dracula = "dracula"
class RevealJS(Converter):
# Export option: use data-uri
data_uri: bool = False
# Presentation size options from RevealJS
width: Union[Str, int] = Str("100%")
height: Union[Str, int] = Str("100%")
margin: float = 0.04
min_scale: float = 0.2
max_scale: float = 2.0
# Configuration options from RevealJS
controls: JsBool = JsBool.false
controls_tutorial: JsBool = JsBool.true
controls_layout: ControlsLayout = ControlsLayout.bottom_right
controls_back_arrows: ControlsBackArrows = ControlsBackArrows.faded
progress: JsBool = JsBool.false
slide_number: SlideNumber = SlideNumber.false
show_slide_number: Union[ShowSlideNumber, Function] = ShowSlideNumber.all
hash_one_based_index: JsBool = JsBool.false
hash: JsBool = JsBool.false
respond_to_hash_changes: JsBool = JsBool.false
history: JsBool = JsBool.false
keyboard: JsBool = JsBool.true
keyboard_condition: Union[KeyboardCondition, Function] = KeyboardCondition.null
disable_layout: JsBool = JsBool.false
overview: JsBool = JsBool.true
center: JsBool = JsBool.true
touch: JsBool = JsBool.true
loop: JsBool = JsBool.false
rtl: JsBool = JsBool.false
navigation_mode: NavigationMode = NavigationMode.default
shuffle: JsBool = JsBool.false
fragments: JsBool = JsBool.true
fragment_in_url: JsBool = JsBool.true
embedded: JsBool = JsBool.false
help: JsBool = JsBool.true
pause: JsBool = JsBool.true
show_notes: JsBool = JsBool.false
auto_play_media: AutoPlayMedia = AutoPlayMedia.null
preload_iframes: PreloadIframes = PreloadIframes.null
auto_animate: JsBool = JsBool.true
auto_animate_matcher: Union[AutoAnimateMatcher, Function] = AutoAnimateMatcher.null
auto_animate_easing: AutoAnimateEasing = AutoAnimateEasing.ease
auto_animate_duration: float = 1.0
auto_animate_unmatched: JsBool = JsBool.true
auto_animate_styles: List[str] = Field(
default_factory=lambda: [
"opacity",
"color",
"background-color",
"padding",
"font-size",
"line-height",
"letter-spacing",
"border-width",
"border-color",
"border-radius",
"outline",
"outline-offset",
]
)
auto_slide: AutoSlide = 0
auto_slide_stoppable: JsBool = JsBool.true
auto_slide_method: Union[AutoSlideMethod, Function] = AutoSlideMethod.null
default_timing: Union[JsNull, int] = JsNull.null
mouse_wheel: JsBool = JsBool.false
preview_links: JsBool = JsBool.false
post_message: JsBool = JsBool.true
post_message_events: JsBool = JsBool.false
focus_body_on_page_visibility_change: JsBool = JsBool.true
transition: Transition = Transition.none
transition_speed: TransitionSpeed = TransitionSpeed.default
background_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 = "4.6.1"
reveal_theme: RevealTheme = RevealTheme.black
title: str = "Manim Slides"
# Pydantic options
model_config = ConfigDict(use_enum_values=True, extra="forbid")
def load_template(self) -> str:
"""Return the RevealJS HTML template as a string."""
if isinstance(self.template, Path):
return self.template.read_text()
if sys.version_info < (3, 9):
return resources.read_text(templates, "revealjs.html")
return resources.files(templates).joinpath("revealjs.html").read_text()
def open(self, file: Path) -> bool:
return webbrowser.open(file.absolute().as_uri())
def convert_to(self, dest: Path) -> None:
"""
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
assets_dir = Path(
self.assets_dir.format(dirname=dirname, basename=basename, ext=ext)
)
full_assets_dir = dirname / assets_dir
logger.debug(f"Assets will be saved to: {full_assets_dir}")
full_assets_dir.mkdir(parents=True, exist_ok=True)
for presentation_config in self.presentation_configs:
presentation_config.copy_to(full_assets_dir, include_reversed=False)
dest.parent.mkdir(parents=True, exist_ok=True)
with open(dest, "w") as f:
revealjs_template = Template(self.load_template())
options = self.dict()
options["assets_dir"] = assets_dir
has_notes = any(
slide_config.notes != ""
for presentation_config in self.presentation_configs
for slide_config in presentation_config.slides
)
content = revealjs_template.render(
file_to_data_uri=file_to_data_uri,
get_duration_ms=get_duration_ms,
has_notes=has_notes,
env=os.environ,
**options,
)
f.write(content)
class FrameIndex(str, Enum):
first = "first"
last = "last"
class PDF(Converter):
frame_index: FrameIndex = FrameIndex.last
resolution: PositiveFloat = 100.0
model_config = ConfigDict(use_enum_values=True, extra="forbid")
def open(self, file: Path) -> None:
return open_with_default(file)
def convert_to(self, dest: Path) -> None:
"""Convert this configuration into a PDF presentation, saved to DEST."""
def read_image_from_video_file(file: Path, frame_index: FrameIndex) -> Image:
cap = cv2.VideoCapture(str(file))
if frame_index == FrameIndex.last:
index = cap.get(cv2.CAP_PROP_FRAME_COUNT)
cap.set(cv2.CAP_PROP_POS_FRAMES, index - 1)
ret, frame = cap.read()
cap.release()
if ret:
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
return Image.fromarray(frame)
else:
raise ValueError("Failed to read {image_index} image from video file")
images = []
for i, presentation_config in enumerate(self.presentation_configs):
for slide_config in tqdm(
presentation_config.slides,
desc=f"Generating video slides for config {i + 1}",
leave=False,
):
images.append(
read_image_from_video_file(slide_config.file, self.frame_index)
)
dest.parent.mkdir(parents=True, exist_ok=True)
images[0].save(
dest,
"PDF",
resolution=self.resolution,
save_all=True,
append_images=images[1:],
)
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
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: # noqa: C901
"""Convert this configuration into a PowerPoint presentation, saved to DEST."""
prs = pptx.Presentation()
prs.slide_width = self.width * 9525
prs.slide_height = self.height * 9525
layout = prs.slide_layouts[6] # Should be blank
# From GitHub issue comment:
# - https://github.com/scanny/python-pptx/issues/427#issuecomment-856724440
def auto_play_media(
media: pptx.shapes.picture.Movie, loop: bool = False
) -> None:
el_id = xpath(media.element, ".//p:cNvPr")[0].attrib["id"]
el_cnt = xpath(
media.element.getparent().getparent().getparent(),
'.//p:timing//p:video//p:spTgt[@spid="%s"]' % el_id,
)[0]
cond = xpath(el_cnt.getparent().getparent(), ".//p:cond")[0]
cond.set("delay", "0")
if loop:
ctn = xpath(el_cnt.getparent().getparent(), ".//p:cTn")[0]
ctn.set("repeatCount", "indefinite")
def xpath(el: etree.Element, query: str) -> etree.XPath:
nsmap = {"p": "http://schemas.openxmlformats.org/presentationml/2006/main"}
return etree.ElementBase.xpath(el, query, namespaces=nsmap)
def save_first_image_from_video_file(file: Path) -> Optional[str]:
cap = cv2.VideoCapture(file.as_posix())
ret, frame = cap.read()
cap.release()
if ret:
f = tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".png")
cv2.imwrite(f.name, frame)
f.close()
return f.name
else:
logger.warn("Failed to read first image from video file")
return None
for i, presentation_config in enumerate(self.presentation_configs):
for slide_config in tqdm(
presentation_config.slides,
desc=f"Generating video slides for config {i + 1}",
leave=False,
):
file = slide_config.file
mime_type = mimetypes.guess_type(file)[0]
if self.poster_frame_image is None:
poster_frame_image = save_first_image_from_video_file(file)
else:
poster_frame_image = str(self.poster_frame_image)
slide = prs.slides.add_slide(layout)
movie = slide.shapes.add_movie(
str(file),
self.left,
self.top,
self.width * 9525,
self.height * 9525,
poster_frame_image=poster_frame_image,
mime_type=mime_type,
)
if slide_config.notes != "":
slide.notes_slide.notes_text_frame.text = slide_config.notes
if self.auto_play_media:
auto_play_media(movie, loop=slide_config.loop)
dest.parent.mkdir(parents=True, exist_ok=True)
prs.save(dest)
def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
"""Wrap a function to add a `--show-config` option."""
def callback(ctx: Context, param: Parameter, value: bool) -> None:
if not value or ctx.resilient_parsing:
return
to = ctx.params.get("to", "html")
converter = Converter.from_string(to)(
presentation_configs=[PresentationConfig()]
)
for key, value in converter.dict().items():
click.echo(f"{key}: {value!r}")
ctx.exit()
return click.option( # type: ignore
"--show-config",
is_flag=True,
help="Show supported options for given format and exit.",
default=None,
expose_value=False,
show_envvar=True,
callback=callback,
)(function)
def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
"""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")
template = ctx.params.get("template", None)
converter = Converter.from_string(to)(
presentation_configs=[PresentationConfig()], template=template
)
click.echo(converter.load_template())
ctx.exit()
return click.option( # type: ignore
"--show-template",
is_flag=True,
help="Show the template (currently) used for a given conversion format and exit.",
default=None,
expose_value=False,
show_envvar=True,
callback=callback,
)(function)
@click.command()
@click.argument("scenes", nargs=-1)
@folder_path_option
@click.argument("dest", type=click.Path(dir_okay=False, path_type=Path))
@click.option(
"--to",
type=click.Choice(["auto", "html", "pdf", "pptx"], case_sensitive=False),
metavar="FORMAT",
default="auto",
show_default=True,
help="Set the conversion format to use. Use 'auto' to detect format from DEST.",
)
@click.option(
"--open",
"open_result",
is_flag=True,
help="Open the newly created file using the approriate application.",
)
@click.option("-f", "--force", is_flag=True, help="Overwrite any existing file.")
@click.option(
"-c",
"--config",
"config_options",
multiple=True,
callback=validate_config_option,
help="Configuration options passed to the converter. "
"E.g., pass ``-cslide_number=true`` to display slide numbers.",
)
@click.option(
"--use-template",
"template",
metavar="FILE",
type=click.Path(exists=True, dir_okay=False, path_type=Path),
help="Use the template given by FILE instead of default one. "
"To echo the default template, use ``--show-template``.",
)
@show_template_option
@show_config_options
@verbosity_option
def convert(
scenes: List[str],
folder: Path,
dest: Path,
to: str,
open_result: bool,
force: bool,
config_options: Dict[str, str],
template: Optional[Path],
) -> None:
"""Convert SCENE(s) into a given format and writes the result in DEST."""
presentation_configs = get_scenes_presentation_config(scenes, folder)
try:
if to == "auto":
fmt = dest.suffix[1:].lower()
try:
cls = Converter.from_string(fmt)
except KeyError:
logger.warn(
f"Could not guess conversion format from {dest!s}, defaulting to HTML."
)
cls = RevealJS
else:
cls = Converter.from_string(to)
converter = cls(
presentation_configs=presentation_configs,
template=template,
**config_options,
)
converter.convert_to(dest)
if open_result:
converter.open(dest)
except ValidationError as e:
errors = e.errors()
msg = [
f"{len(errors)} error(s) occured with configuration options for '{to}', see below."
]
for error in errors:
option = error["loc"][0]
_msg = error["msg"]
msg.append(f"Option '{option}': {_msg}")
raise click.UsageError("\n".join(msg)) from None

View File

@ -1,10 +1,12 @@
"""Manim Slides' configuration tools."""
import json
import shutil
from functools import wraps
from inspect import Parameter, signature
from pathlib import Path
from textwrap import dedent
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
from typing import Any, Callable, Optional
import rtoml
from pydantic import (
@ -13,6 +15,9 @@ from pydantic import (
FilePath,
PositiveInt,
PrivateAttr,
ValidationError,
conset,
field_serializer,
field_validator,
model_validator,
)
@ -24,45 +29,65 @@ Receiver = Callable[..., Any]
class Signal(BaseModel): # type: ignore[misc]
__receivers: List[Receiver] = PrivateAttr(default_factory=list)
"""Signal that notifies a list of receivers when it is emitted."""
__receivers: set[Receiver] = PrivateAttr(default_factory=set)
def connect(self, receiver: Receiver) -> None:
self.__receivers.append(receiver)
"""
Connect a receiver to this signal.
This is a no-op if the receiver was already connected to this signal.
:param receiver: The receiver to connect.
"""
self.__receivers.add(receiver)
def disconnect(self, receiver: Receiver) -> None:
self.__receivers.remove(receiver)
"""
Disconnect a receiver from this signal.
This is a no-op if the receiver was not connected to this signal.
:param receiver: The receiver to disconnect.
"""
self.__receivers.discard(receiver)
def emit(self, *args: Any) -> None:
"""
Emit this signal and call each of the attached receivers.
:param args: Positional arguments passed to each receiver.
"""
for receiver in self.__receivers:
receiver(*args)
def key_id(name: str) -> PositiveInt:
"""Avoid importing Qt too early."""
from qtpy.QtCore import Qt
"""
Return the id corresponding to the given key name.
:param str: The name of the key, e.g., 'Q'.
:return: The corresponding id.
"""
from qtpy.QtCore import Qt # Avoid importing Qt too early."""
return getattr(Qt, f"Key_{name}")
class Key(BaseModel): # type: ignore[misc]
"""Represents a list of key codes, with optionally a name."""
"""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."""
m = key_id in self.ids
if m:
@ -77,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"))
@ -98,8 +127,8 @@ class Keys(BaseModel): # type: ignore[misc]
@model_validator(mode="before")
@classmethod
def ids_are_unique_across_keys(cls, values: Dict[str, Key]) -> Dict[str, Key]:
ids: Set[int] = set()
def ids_are_unique_across_keys(cls, values: dict[str, Key]) -> dict[str, Key]:
ids: set[int] = set()
for key in values.values():
if len(ids.intersection(key["ids"])) != 0:
@ -136,6 +165,7 @@ class Config(BaseModel): # type: ignore[misc]
"""General Manim Slides config."""
keys: Keys = Field(default_factory=Keys)
"""The key mapping."""
@classmethod
def from_file(cls, path: Path) -> "Config":
@ -143,11 +173,16 @@ class Config(BaseModel): # type: ignore[misc]
return cls.model_validate(rtoml.load(path)) # type: ignore
def to_file(self, path: Path) -> None:
"""Dump the configuration to a file."""
"""Dump this configuration to a file."""
rtoml.dump(self.model_dump(), path, pretty=True)
def merge_with(self, other: "Config") -> "Config":
"""Merge with another config."""
"""
Merge with another config.
:param other: The other config to be merged with.
:return: This config, updated.
"""
self.keys = self.keys.merge_with(other.keys)
return self
@ -156,11 +191,19 @@ class BaseSlideConfig(BaseModel): # type: ignore
"""Base class for slide config."""
loop: bool = False
"""Whether this slide should loop."""
auto_next: bool = False
"""Whether this slide is skipped upon completion."""
playback_rate: float = 1.0
"""The speed at which the animation is played (1.0 is normal)."""
reversed_playback_rate: float = 1.0
"""The speed at which the reversed animation is played."""
notes: str = ""
"""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]:
@ -172,7 +215,11 @@ class BaseSlideConfig(BaseModel): # type: ignore
The wrapped function must follow two criteria:
- its last parameter must be ``**kwargs`` (or equivalent);
- and its second last parameter must be ``<arg_name>``.
:param arg_name: The name of the argument.
:return: The wrapped function.
"""
# TODO: improve docs and (maybe) type-hints too
def _wrapper_(fun: Callable[..., Any]) -> Callable[..., Any]:
@wraps(fun)
@ -180,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)
@ -194,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)
@ -205,21 +252,27 @@ class BaseSlideConfig(BaseModel): # type: ignore
return _wrapper_
@model_validator(mode="after")
@classmethod
def apply_dedent_notes(
cls, base_slide_config: "BaseSlideConfig"
self,
) -> "BaseSlideConfig":
if base_slide_config.dedent_notes:
base_slide_config.notes = dedent(base_slide_config.notes)
"""
Remove indentation from notes, if specified.
return base_slide_config
:return: The config, optionally modified.
"""
if self.dedent_notes:
self.notes = dedent(self.notes)
return self
class PreSlideConfig(BaseSlideConfig):
"""Slide config to be used prior to rendering."""
start_animation: int
"""The index of the first animation."""
end_animation: int
"""The index after the last animation."""
@classmethod
def from_base_slide_config_and_animation_indices(
@ -228,50 +281,64 @@ class PreSlideConfig(BaseSlideConfig):
start_animation: int,
end_animation: int,
) -> "PreSlideConfig":
"""
Create a config from a base config and animation indices.
:param base_slide_config: The base config.
:param start_animation: The index of the first animation.
:param end_animation: The index after the last animation.
"""
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")
@classmethod
def index_is_posint(cls, v: int) -> int:
"""
Validate that animation indices are positive integers.
:param v: An animation index.
:return: The animation index, if valid.
"""
if v < 0:
raise ValueError("Animation index (start or end) cannot be negative")
return v
@model_validator(mode="after")
@classmethod
def start_animation_is_before_end(
cls, pre_slide_config: "PreSlideConfig"
self,
) -> "PreSlideConfig":
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 approriate command-line option when presenting. IMPORTANT: when using ManimGL, `self.wait()` is not considered to be an animation, so prefer to directly use `self.play(...)`."
)
"""
Validate that start and end animation indices satisfy 'start < end'.
:return: The config, if indices are valid.
"""
raise ValueError(
"Start animation index must be strictly lower than end animation index"
)
return pre_slide_config
return self
@model_validator(mode="after")
@classmethod
def loop_and_auto_next_disallowed(
cls, pre_slide_config: "PreSlideConfig"
def has_src_or_more_than_zero_animations(
self,
) -> "PreSlideConfig":
if pre_slide_config.loop and pre_slide_config.auto_next:
if self.src is not None and self.start_animation != self.end_animation:
raise ValueError(
"You cannot have both `loop=True` and `auto_next=True`, "
"because a looping slide has no ending. "
"This may be supported in the future if "
"https://github.com/jeertmans/manim-slides/pull/299 gets merged."
"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 pre_slide_config
return self
@property
def slides_slice(self) -> slice:
@ -282,23 +349,34 @@ class SlideConfig(BaseSlideConfig):
"""Slide config to be used after rendering."""
file: FilePath
"""The file containing the animation."""
rev_file: FilePath
"""The file containing the reversed animation."""
@classmethod
def from_pre_slide_config_and_files(
cls, pre_slide_config: PreSlideConfig, file: Path, rev_file: Path
) -> "SlideConfig":
return cls(file=file, rev_file=rev_file, **pre_slide_config.dict())
return cls(file=file, rev_file=rev_file, **pre_slide_config.model_dump())
class PresentationConfig(BaseModel): # type: ignore[misc]
slides: List[SlideConfig] = Field(min_length=1)
resolution: Tuple[PositiveInt, PositiveInt] = (1920, 1080)
"""Presentation config that contains all necessary information for a presentation."""
slides: list[SlideConfig] = Field(min_length=1)
"""The non-empty list of slide configs."""
resolution: tuple[PositiveInt, PositiveInt] = (1920, 1080)
"""The resolution of the animation files."""
background_color: Color = "black"
"""The background color of the animation files."""
@classmethod
def from_file(cls, path: Path) -> "PresentationConfig":
"""Read a presentation configuration from a file."""
"""
Read a presentation configuration from a file.
:param path: The path where the config is read from.
"""
with open(path) as f:
obj = json.load(f)
@ -315,28 +393,40 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
return cls.model_validate(obj) # type: ignore
def to_file(self, path: Path) -> None:
"""Dump the presentation configuration to a file."""
"""
Dump the presentation configuration to a file.
:param path: The path to save this config.
"""
with open(path, "w") as f:
f.write(self.model_dump_json(indent=2))
def copy_to(
self, folder: Path, use_cached: bool = True, include_reversed: bool = True
) -> "PresentationConfig":
"""Copy the files to a given directory."""
self,
folder: Path,
use_cached: bool = True,
include_reversed: bool = True,
prefix: str = "",
) -> 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.
"""
for slide_config in self.slides:
file = slide_config.file
rev_file = slide_config.rev_file
dest = folder / file.name
rev_dest = folder / rev_file.name
slide_config.file = dest
slide_config.rev_file = rev_dest
dest = folder / f"{prefix}{file.name}"
rev_dest = folder / f"{prefix}{rev_file.name}"
if not use_cached or not dest.exists():
shutil.copy(file, dest)
if include_reversed and (not use_cached or not rev_dest.exists()):
# 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

View File

@ -1,4 +1,8 @@
"""Manim Slides' defaults."""
from pathlib import Path
FOLDER_PATH: Path = Path("./slides")
"""Folder where slides are stored."""
CONFIG_PATH: Path = Path(".manim-slides.toml")
"""Path to local Manim Slides config."""

View File

@ -31,7 +31,11 @@ HIGHLIGHTED_KEYWORDS = [ # these keywords are highlighted specially
def make_logger() -> logging.Logger:
"""Make a logger similar to the one used by Manim."""
"""
Make a logger similar to the one used by Manim.
:return: The logger instance.
"""
RichHandler.KEYWORDS = HIGHLIGHTED_KEYWORDS
rich_handler = RichHandler(
show_time=True,
@ -47,6 +51,5 @@ def make_logger() -> logging.Logger:
return logger
make_logger()
logger = logging.getLogger("manim-slides")
logger = make_logger()
"""The logger instance used across this project."""

197
manim_slides/core/utils.py Normal file
View File

@ -0,0 +1,197 @@
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."""
for file in files:
with av.open(str(file)) as container:
if len(container.streams.video) > 0:
yield file
else:
logger.warning(
f"Skipping video file {file} because it does "
"not contain any video stream. "
"This is probably caused by Manim, see: "
"https://github.com/jeertmans/manim-slides/issues/390."
)
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
f.writelines(f"file '{file}'\n" for file in _filter(files))
tmp_file = f.name
with (
av.open(tmp_file, format="concat", options={"safe": "0"}) as input_container,
av.open(str(dest), mode="w") as output_container,
):
input_video_stream = input_container.streams.video[0]
output_video_stream = output_container.add_stream(
template=input_video_stream,
)
if len(input_container.streams.audio) > 0:
input_audio_stream = input_container.streams.audio[0]
output_audio_stream = output_container.add_stream(
template=input_audio_stream,
)
for packet in input_container.demux():
if packet.dts is None:
continue
ptype = packet.stream.type
if ptype == "video":
packet.stream = output_video_stream
elif ptype == "audio":
packet.stream = output_audio_stream
else:
continue # We don't support subtitles
output_container.mux(packet)
os.unlink(tmp_file) # https://stackoverflow.com/a/54768241
def merge_basenames(files: list[Path]) -> Path:
"""Merge multiple filenames by concatenating basenames."""
if len(files) == 0:
raise ValueError("Cannot merge an empty list of files!")
dirname: Path = files[0].parent
ext = files[0].suffix
basenames = list(file.stem for file in files)
basenames_str = ",".join(f"{len(b)}:{b}" for b in basenames)
# We use hashes to prevent too-long filenames, see issue #123:
# https://github.com/jeertmans/manim-slides/issues/123
basename = hashlib.sha256(basenames_str.encode()).hexdigest()
logger.debug(f"Generated a new basename for basenames: {basenames} -> '{basename}'")
return dirname.joinpath(basename + ext)
def link_nodes(*nodes: av.filter.context.FilterContext) -> None:
"""Code from https://github.com/PyAV-Org/PyAV/issues/239."""
for c, n in zip(nodes, nodes[1:]):
c.link_to(n)
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,
):
input_stream = input_container.streams.video[0]
output_stream = output_container.add_stream(
codec_name="libx264", rate=input_stream.base_rate
)
output_stream.width = input_stream.width
output_stream.height = input_stream.height
output_stream.pix_fmt = input_stream.pix_fmt
graph = av.filter.Graph()
link_nodes(
graph.add_buffer(template=input_stream),
graph.add("reverse"),
graph.add("buffersink"),
)
graph.configure()
frames_count = 0
for frame in input_container.decode(video=0):
graph.push(frame)
frames_count += 1
graph.push(None) # EOF: https://github.com/PyAV-Org/PyAV/issues/886.
for _ in range(frames_count):
frame = graph.pull()
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

@ -116,7 +116,7 @@ class ManimSlidesMagic(Magics): # type: ignore
file) will be moved relative to the video locations. Use-cases include building
documentation with Sphinx and JupyterBook. See also the
:mod:`Manim Slides directive for Sphinx
<manim_slides.docs.manim_slides_directive>`.
<manim_slides.sphinxext.manim_slides_directive>`.
Examples
--------
@ -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")
@ -249,9 +261,7 @@ class ManimSlidesMagic(Magics): # type: ignore
)
else:
result = HTML(
"""<div style="position:relative;padding-bottom:56.25%;"><iframe style="width:100%;height:100%;position:absolute;left:0px;top:0px;" frameborder="0" width="100%" height="100%" allowfullscreen allow="autoplay" src="{src}"></iframe></div>""".format(
src=tmpfile.as_posix()
)
f"""<div style="position:relative;padding-bottom:56.25%;"><iframe style="width:100%;height:100%;position:absolute;left:0px;top:0px;" frameborder="0" width="100%" height="100%" allowfullscreen allow="autoplay" src="{tmpfile.as_posix()}"></iframe></div>"""
)
display(result)

View File

@ -1,10 +1,12 @@
__all__ = [
"""Slides module with logic to either import ManimCE or ManimGL."""
__all__ = (
"API_NAME",
"MANIM",
"MANIMGL",
"API_NAME",
"Slide",
"ThreeDSlide",
]
)
import os
@ -14,10 +16,10 @@ import sys
class ManimApiNotFoundError(ImportError):
"""Error raised if specified manim API could be imported."""
_msg = "Could not import the specified manim API"
_msg = "Could not import the specified manim API: `{api}`."
def __init__(self) -> None:
super().__init__(self._msg)
def __init__(self, api: str) -> None:
super().__init__(self._msg.format(api=api))
API_NAMES = {
@ -26,16 +28,19 @@ API_NAMES = {
"manimlib": "manimlib",
"manimgl": "manimlib",
}
"""Allowed values for API."""
MANIM_API: str = "MANIM_API"
"""API environ variable name."""
FORCE_MANIM_API: str = "FORCE_" + MANIM_API
"""FORCE API environ variable name."""
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]
@ -53,11 +58,14 @@ if MANIM:
try:
from .manim import Slide, ThreeDSlide
except ImportError as e:
raise ManimApiNotFoundError from e
raise ManimApiNotFoundError("manim") from e
elif MANIMGL:
try:
from .manimlib import Slide, ThreeDSlide
except ImportError as e:
raise ManimApiNotFoundError from e
raise ManimApiNotFoundError("manimlib") from e
else:
raise ManimApiNotFoundError
raise ValueError(
"This error should never occur. "
"Please report an issue on GitHub if you encounter it."
)

View File

@ -11,7 +11,8 @@ that directly calls ``self.play(Animation(...))``, see
__all__ = ["Wipe", "Zoom"]
from typing import Any, Mapping, Optional, Sequence
from collections.abc import Mapping, Sequence
from typing import Any, Optional
import numpy as np

View File

@ -1,25 +1,31 @@
"""Base class for the Slide class."""
from __future__ import annotations
__all__ = ["BaseSlide"]
__all__ = ("BaseSlide",)
import platform
import shutil
from abc import abstractmethod
from collections.abc import MutableMapping, Sequence, ValuesView
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
MutableMapping,
Sequence,
ValuesView,
)
import numpy as np
from tqdm import tqdm
from ..config import BaseSlideConfig, PresentationConfig, PreSlideConfig, SlideConfig
from ..defaults import FOLDER_PATH
from ..logger import logger
from ..utils import concatenate_video_files, merge_basenames, reverse_video_file
from ..core.config import (
BaseSlideConfig,
PresentationConfig,
PreSlideConfig,
SlideConfig,
)
from ..core.defaults import FOLDER_PATH
from ..core.logger import logger
from ..core.utils import concatenate_video_files, merge_basenames, reverse_video_file
from . import MANIM
if TYPE_CHECKING:
@ -34,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:
@ -46,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
@ -172,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)
@ -188,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
@ -258,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
@ -277,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.
@ -428,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
@ -447,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()
@ -460,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
@ -477,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}"
@ -494,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(
@ -518,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

@ -1,40 +1,92 @@
"""Manim's implementation of the Slide class."""
from pathlib import Path
from typing import Any, List, Optional, Tuple
from typing import Any, Optional
from manim import Scene, ThreeDScene, config
from manim.renderer.opengl_renderer import OpenGLRenderer
from manim.utils.color import rgba_to_color
from ..config import BaseSlideConfig
from ..core.config import BaseSlideConfig
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):
return self.renderer.camera.frame_shape # type: ignore
else:
return (
self.renderer.camera.frame_height,
self.renderer.camera.frame_width,
)
@property
def _frame_height(self) -> float:
return config["frame_height"] # type: ignore
return self._frame_shape[0]
@property
def _frame_width(self) -> float:
return config["frame_width"] # type: ignore
return self._frame_shape[1]
@property
def _background_color(self) -> str:
color = config["background_color"]
if hex_color := getattr(color, "hex", None):
return hex_color # type: ignore
else: # manim>=0.18, see https://github.com/ManimCommunity/manim/pull/3020
return color.to_hex() # type: ignore
if isinstance(self.renderer, OpenGLRenderer):
return rgba_to_color(self.renderer.background_color).to_hex() # type: ignore
else:
return self.renderer.camera.background_color.to_hex() # type: ignore
@property
def _resolution(self) -> Tuple[int, int]:
return config["pixel_width"], config["pixel_height"]
def _resolution(self) -> tuple[int, int]:
if isinstance(self.renderer, OpenGLRenderer):
return self.renderer.get_pixel_shape() # type: ignore
else:
return (
self.renderer.camera.pixel_width,
self.renderer.camera.pixel_height,
)
@property
def _partial_movie_files(self) -> List[Path]:
def _partial_movie_files(self) -> list[Path]:
# When rendering with -na,b (manim only)
# the animations not in [a,b] will be skipped,
# but animation before a will have a None source file.
@ -56,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`.
@ -78,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

@ -1,8 +1,9 @@
"""ManimGL's implementation of the Slide class."""
from pathlib import Path
from typing import Any, ClassVar, Dict, List, Optional, Tuple
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
@ -10,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"]
def _resolution(self) -> tuple[int, int]:
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
)
]
def _partial_movie_files(self) -> list[Path]:
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:
@ -62,11 +61,15 @@ 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):
CONFIG: ClassVar[Dict[str, Any]] = {
CONFIG: ClassVar[dict[str, Any]] = {
"camera_class": ThreeDCamera,
}
pass

View File

View File

@ -40,7 +40,7 @@ First, you must include the directive in the Sphinx configuration file:
extensions = [
# ...
"manim_slides.docs.manim_slides_directive",
"manim_slides.sphinxext.manim_slides_directive",
]
Its basic usage that allows processing **inline content**
@ -86,6 +86,19 @@ A third application is to render scenes from another specific file::
you should probably not include examples that rely on external files, since
relative paths risk to be broken.
.. note::
If you want to skip rendering the slides (e.g., for testing)
you can either set the ``SKIP_MANIM_SLIDES`` environ
variable (to any value) or pass the ``skip-manim-slides``
tag to ``sphinx``:
.. code-block:: bash
sphinx-build -t skip-manim-slides <OTHER_SPHINX_OPTIONS>
# or if you use a Makefile
make html O=-tskip-manim-slides
Options
-------
@ -176,10 +189,12 @@ Renders as follows:
""" # noqa: D400, D415
from __future__ import annotations
import csv
import itertools as it
import os
import re
import shlex
import sys
@ -268,9 +283,9 @@ 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
)
if should_skip:
node = SkipManimNode()
@ -324,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"]
@ -440,7 +455,7 @@ def _write_rendering_stats(scene_name, run_time, file_name):
[
re.sub(r"^(reference\/)|(manim\.)", "", file_name),
scene_name,
"%.3f" % run_time,
f"{run_time:.3f}",
],
)
@ -468,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

@ -1,98 +0,0 @@
import hashlib
import tempfile
from pathlib import Path
from typing import List
import av
from .logger import logger
def concatenate_video_files(files: List[Path], dest: Path) -> None:
"""Concatenate multiple video files into one."""
f = tempfile.NamedTemporaryFile(mode="w", delete=False)
f.writelines(f"file '{path.absolute()}'\n" for path in files)
f.close()
input_ = av.open(f.name, options={"safe": "0"}, format="concat")
input_stream = input_.streams.video[0]
output = av.open(str(dest), mode="w")
output_stream = output.add_stream(
template=input_stream,
)
for packet in input_.demux(input_stream):
# We need to skip the "flushing" packets that `demux` generates.
if packet.dts is None:
continue
# We need to assign the packet to the new stream.
packet.stream = output_stream
output.mux(packet)
input_.close()
output.close()
def merge_basenames(files: List[Path]) -> Path:
"""Merge multiple filenames by concatenating basenames."""
if len(files) == 0:
raise ValueError("Cannot merge an empty list of files!")
dirname: Path = files[0].parent
ext = files[0].suffix
basenames = list(file.stem for file in files)
basenames_str = ",".join(f"{len(b)}:{b}" for b in basenames)
# We use hashes to prevent too-long filenames, see issue #123:
# https://github.com/jeertmans/manim-slides/issues/123
basename = hashlib.sha256(basenames_str.encode()).hexdigest()
logger.debug(f"Generated a new basename for basenames: {basenames} -> '{basename}'")
return dirname.joinpath(basename + ext)
def link_nodes(*nodes: av.filter.context.FilterContext) -> None:
"""Code from https://github.com/PyAV-Org/PyAV/issues/239."""
for c, n in zip(nodes, nodes[1:]):
c.link_to(n)
def reverse_video_file(src: Path, dest: Path) -> None:
"""Reverses a video file, writting the result to `dest`."""
input_ = av.open(str(src))
input_stream = input_.streams.video[0]
output = av.open(str(dest), mode="w")
output_stream = output.add_stream(codec_name="libx264", rate=input_stream.base_rate)
output_stream.width = input_stream.width
output_stream.height = input_stream.height
output_stream.pix_fmt = input_stream.pix_fmt
graph = av.filter.Graph()
link_nodes(
graph.add_buffer(template=input_stream),
graph.add("reverse"),
graph.add("buffersink"),
)
graph.configure()
frames_count = 0
for frame in input_.decode(video=0):
graph.push(frame)
frames_count += 1
graph.push(None) # EOF: https://github.com/PyAV-Org/PyAV/issues/886.
for _ in range(frames_count):
frame = graph.pull()
frame.pict_type = 5 # Otherwise we get a warning saying it is changed
output.mux(output_stream.encode(frame))
for packet in output_stream.encode():
output.mux(packet)
input_.close()
output.close()

View File

@ -66,7 +66,7 @@ provide new features on a regular basis.
# Easy to Use Commitment
Manim Slides is commited to be an easy-to-use tool, with minimal installation
Manim Slides is committed to be an easy-to-use tool, with minimal installation
procedure and few modifications required. It can either be used locally with its
graphical user interface (GUI), or shared via one of the two formats it can
convert to:
@ -96,13 +96,13 @@ and posted it on YouTube.
# Stability and releases
Manim Slides is continously tested on most recent Python versions, both ManimCE
Manim Slides is continuously tested on most recent Python versions, both ManimCE
and ManimGL, and on all major platforms: **Ubuntu**, **macOS** and **Windows**. Due to Manim
Slide's exposed API being very minimal, and the variety of tests that are
performed, this tool can be considered stable over time.
New releases are very frequent, as they mostly introduce enhancements or small
documention fixes, and the command-line tool automatically notifies for new
documentation fixes, and the command-line tool automatically notifies for new
available updates. Therefore, regularly updating Manim Slides is highly
recommended.
@ -160,7 +160,7 @@ For new feature requests, we highly encourage users to
[create an issue](https://github.com/jeertmans/manim-slides/issues/new/choose)
with the appropriate template.
# Acknowledgements
# Acknowledgments
We acknowledge the work of @manim-presentation that paved the initial structure
of Manim Slides with the manim-presentation Python package.

3453
pdm.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,39 @@
[build-system]
build-backend = "pdm.backend"
requires = ["pdm-backend", "setuptools"]
build-backend = "hatchling.build"
requires = ["hatchling", "hatch-fancy-pypi-readme"]
[dependency-groups]
dev = [
{include-group = "docs"},
{include-group = "tests"},
"bump-my-version>=0.20.3",
"pre-commit>=3.5.0",
]
docs = [
"manim-slides[magic,manim,pyqt6,sphinx-directive]",
"furo>=2023.5.20",
"ipykernel>=6.25.1",
"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",
]
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",
"setuptools>=73.0.1",
]
[project]
authors = [{name = "Jérome Eertmans", email = "jeertmans@icloud.com"}]
@ -17,13 +50,13 @@ 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",
"lxml>=4.9.2",
"numpy>=1.19",
"opencv-python>=4.6.0.66",
"pillow>=9.5.0",
"pydantic>=2.0.1",
"pydantic-extra-types>=2.0.0",
@ -31,44 +64,31 @@ dependencies = [
"qtpy>=2.4.1",
"requests>=2.28.1",
"rich>=13.3.2",
"rtoml>=0.9.0",
"rtoml>=0.11.0",
"tqdm>=4.64.1",
]
description = "Tool for live presentations using manim"
dynamic = ["version"]
dynamic = ["readme", "version"]
keywords = ["manim", "slides", "plugin", "manimgl"]
license = {text = "MIT"}
license = "MIT"
name = "manim-slides"
readme = "README.md"
requires-python = ">=3.9,<3.13"
requires-python = ">=3.9"
[project.optional-dependencies]
docs = [
"manim-slides[magic,sphinx-directive]",
"furo>=2023.5.20",
"ipykernel>=6.25.1",
"myst-parser>=2.0.0",
"nbsphinx>=0.9.2",
"pandoc>=2.3",
"sphinx>=7.0.1",
"sphinx-click>=4.4.0",
"sphinx-copybutton>=0.5.1",
"sphinxext-opengraph>=0.7.5",
]
full = [
"manim-slides[magic,manim,manimgl,sphinx-directive]",
"manim-slides[magic,manim,sphinx-directive]",
]
magic = ["manim-slides[manim]", "ipython>=8.12.2"]
manim = ["manim>=0.17.3"]
manimgl = ["manimgl>=1.6.1"]
pyqt6 = ["pyqt6>=6.6.1"]
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 = ["pyside6>=6.6.1,!=6.8.1.1"]
pyside6-full = ["manim-slides[full,pyside6]"]
sphinx-directive = ["docutils>=0.20.1", "manim-slides[manim]"]
[project.scripts]
manim-slides = "manim_slides.__main__:cli"
manim-slides = "manim_slides.cli.commands:main"
[project.urls]
Changelog = "https://github.com/jeertmans/manim-slides/releases"
@ -77,6 +97,62 @@ Founding = "https://github.com/sponsors/jeertmans"
Homepage = "https://github.com/jeertmans/manim-slides"
Repository = "https://github.com/jeertmans/manim-slides"
[tool.bumpversion]
allow_dirty = false
commit = true
commit_args = ""
current_version = "5.5.1"
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+))?'
regex = false
replace = "{new_version}"
search = "{current_version}"
serialize = ["{major}.{minor}.{patch}-rc{release}", "{major}.{minor}.{patch}"]
sign_tags = false
tag = false
tag_message = "Bump version: {current_version} → {new_version}"
tag_name = "v{new_version}"
[[tool.bumpversion.files]]
filename = "manim_slides/__version__.py"
replace = '__version__ = "{new_version}"'
search = '__version__ = "{current_version}"'
[[tool.bumpversion.files]]
filename = "CITATION.cff"
replace = "version: v{new_version}"
search = "version: v{current_version}"
[[tool.bumpversion.files]]
filename = "CHANGELOG.md"
replace = "v{new_version}"
search = "Unreleased"
[[tool.bumpversion.files]]
filename = "CHANGELOG.md"
replace = "v{new_version}"
search = "unreleased"
[[tool.bumpversion.files]]
filename = "CHANGELOG.md"
replace = "v{current_version}...v{new_version}"
search = "v{current_version}...HEAD"
[[tool.bumpversion.files]]
filename = "CHANGELOG.md"
replace = '''<!-- start changelog -->
(unreleased)=
## [Unreleased](https://github.com/jeertmans/manim-slides/compare/v{new_version}...HEAD)'''
search = "<!-- start changelog -->"
[tool.codespell]
builtin = "clear,rare,informal,usage,names,en-GB_to_en-US"
check-hidden = true
ignore-words-list = "master"
skip = "uv.lock"
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
@ -86,49 +162,55 @@ exclude_lines = [
]
precision = 2
[tool.hatch.metadata.hooks.fancy-pypi-readme]
content-type = "text/markdown"
[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
text = """<p align="center">
<a href="https://www.github.com/jeertmans/manin-slides">
<img src="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/logo.png"/>
</a>
</p>
"""
[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]]
path = "README.md"
start-after = "<!-- start pypi -->"
[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
pattern = '> \[!([A-Z]+)\]'
replacement = '> **\1:**'
[tool.hatch.version]
path = "manim_slides/__version__.py"
[tool.mypy]
disallow_untyped_decorators = false
install_types = true
python_version = "3.8"
python_version = "3.9"
strict = true
[tool.pdm.dev-dependencies]
dev = [
"bump2version>=1.0.1",
"pre-commit>=3.5.0",
]
github-action = ["setuptools"]
test = [
"manim-slides[manim,manimgl,pyqt6]",
"pytest>=7.4.0",
"pytest-cov>=4.1.0",
"pytest-env>=0.8.2",
"pytest-qt>=4.2.0",
"pytest-xdist>=3.3.1",
]
[tool.pdm.resolution.overrides]
manimpango = "<1.0.0,>=0.5.0" # This conflicts with ManimGL, hopefully not an issue
skia-pathops = "0.8.0.post1" # From manim 0.18.0 (Python 3.12 support)
[tool.pdm.version]
path = "manim_slides/__version__.py"
source = "file"
[tool.pytest.ini_options]
addopts = [
"--cov-report=xml",
"--cov=manim_slides",
]
env = [
"QT_API=pyside6",
"QT_QPA_PLATFORM=offscreen",
]
filterwarnings = [
"error",
"ignore::DeprecationWarning",
'''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/resources.py"]
extend-exclude = ["manim_slides/cli/resources.py"]
extend-include = ["*.ipynb"]
line-length = 88
target-version = "py38"
[tool.ruff.lint]
extend-ignore = [
@ -147,3 +229,7 @@ extend-ignore = [
]
extend-select = ["B", "C90", "D", "I", "N", "RUF", "UP", "T"]
isort = {known-first-party = ["manim_slides", "tests"]}
[tool.ruff.lint.per-file-ignores]
"docs/source/reference/magic_example.ipynb" = ["F403", "F405"]
"tests/test_slide.py" = ["N801"]

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -51,3 +51,28 @@ class ManimSlidesLogo(Scene):
) # order matters
logo.move_to(ORIGIN)
self.add(logo)
class ManimSlidesFavicon(Scene):
def construct(self):
tex_template = TexTemplate()
tex_template.add_to_preamble(r"\usepackage{graphicx}\usepackage{fontawesome5}")
fill_color = "#c9d1d9"
stroke_color = "#343434"
play = Tex(
r"\faStepBackward\faStepForward",
fill_color=fill_color,
stroke_color=stroke_color,
tex_template=tex_template,
).scale(4)
comment = Tex(
r"\reflectbox{\faComment*[regular]}",
fill_color=fill_color,
stroke_color=stroke_color,
tex_template=tex_template,
).scale(9)
comment.move_to(play)
comment.shift(0.4 * DOWN)
favicon = VGroup(comment, play).scale(3)
favicon.move_to(ORIGIN)
self.add(favicon)

5
static/make_favicon.sh Executable file
View File

@ -0,0 +1,5 @@
#! /bin/bash
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 pdm run manim-slides render -qk -s --format png --resolution 2560,1280 static/logo.py && 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 pdm run manim-slides render -qk -s --format png --resolution 2560,1280 static/logo.py && mv media/images/logo/*.png static/logo_dark_docs.png
MANIM_SLIDES_THEME=dark_docs 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 pdm run manim-slides render -qk -s --format png --resolution 2560,1280 static/logo.py && mv media/images/logo/*.png static/logo_dark_github.png
MANIM_SLIDES_THEME=dark_github 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 pdm run manim-slides render -t -qk -s --format png --resolution 2560,1280 static/logo.py && mv media/images/logo/*.png static/logo_light_transparent.png
MANIM_SLIDES_THEME=light 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 pdm run manim-slides render -t -qk -s --format png --resolution 2560,1280 static/logo.py && mv media/images/logo/*.png static/logo_dark_transparent.png
MANIM_SLIDES_THEME=dark_docs 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

@ -1,7 +1,7 @@
import random
import string
from collections.abc import Generator, Iterator
from pathlib import Path
from typing import Generator, Iterator, List
import pytest
@ -65,14 +65,26 @@ def random_path(
@pytest.fixture
def paths() -> Generator[List[Path], None, None]:
def paths() -> Generator[list[Path], None, None]:
random.seed(1234)
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

@ -1,4 +1,4 @@
from typing import MutableMapping
from collections.abc import MutableMapping
import pytest
@ -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,19 +154,173 @@ 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_pdf_converter(
def test_revealjs_offline_converter(
self, tmp_path: Path, presentation_config: PresentationConfig
) -> None:
out_file = tmp_path / "slides.pdf"
PDF(presentation_configs=[presentation_config]).convert_to(out_file)
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()
def test_converter_no_presentation_config(self) -> None:
with pytest.raises(ValidationError):
Converter(presentation_configs=[])
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,
tmp_path: Path,
presentation_config: PresentationConfig,
num_presentation_configs: int,
) -> None:
out_file = tmp_path / "slides.html"
RevealJS(
presentation_configs=[
presentation_config for _ in range(num_presentation_configs)
]
).convert_to(out_file)
assert out_file.exists()
assets_dir = Path(tmp_path / "slides_assets")
assert assets_dir.is_dir()
got = sum(1 for _ in assets_dir.iterdir())
expected = num_presentation_configs * len(presentation_config.slides)
assert got == expected
@pytest.mark.parametrize("frame_index", ("first", "last"))
def test_pdf_converter(
self, frame_index: str, tmp_path: Path, presentation_config: PresentationConfig
) -> None:
out_file = tmp_path / "slides.pdf"
PDF(
presentation_configs=[presentation_config], frame_index=frame_index
).convert_to(out_file)
assert out_file.exists()
def test_pptx_converter(
self, tmp_path: Path, presentation_config: PresentationConfig

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,7 +25,9 @@ def assert_import(
assert slide.MANIMGL == manimgl
@skip_if_py39
def test_force_api() -> None:
pytest.importorskip("manimlib")
import manim # noqa: F401
if "manimlib" in sys.modules:
@ -52,8 +59,10 @@ 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")
import manim # noqa: F401
import manimlib # noqa: F401
@ -77,7 +86,9 @@ def test_manim_imported() -> None:
)
@skip_if_py39
def test_manimgl_imported() -> None:
pytest.importorskip("manimlib")
import manimlib # noqa: F401
if "manim" in sys.modules:

View File

@ -1,5 +1,5 @@
from collections.abc import Iterator
from pathlib import Path
from typing import Iterator, Tuple
import pytest
from click.testing import CliRunner
@ -20,11 +20,11 @@ def auto_shutdown_qapp() -> Iterator[None]:
@pytest.fixture(scope="session")
def args(slides_folder: Path) -> Iterator[Tuple[str, ...]]:
def args(slides_folder: Path) -> Iterator[tuple[str, ...]]:
yield ("--folder", str(slides_folder), "--skip-all", "--playback-rate", "25")
def test_present(args: Tuple[str, ...]) -> None:
def test_present(args: tuple[str, ...]) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
@ -34,7 +34,7 @@ def test_present(args: Tuple[str, ...]) -> None:
assert results.stdout == ""
def test_present_unexisting_slide(args: Tuple[str, ...]) -> None:
def test_present_unexisting_slide(args: tuple[str, ...]) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
@ -44,7 +44,7 @@ def test_present_unexisting_slide(args: Tuple[str, ...]) -> None:
assert "UnexistingSlide.json does not exist" in results.stdout
def test_present_full_screen(args: Tuple[str, ...]) -> None:
def test_present_full_screen(args: tuple[str, ...]) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
@ -54,7 +54,7 @@ def test_present_full_screen(args: Tuple[str, ...]) -> None:
assert results.stdout == ""
def test_present_hide_mouse(args: Tuple[str, ...]) -> None:
def test_present_hide_mouse(args: tuple[str, ...]) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
@ -64,7 +64,7 @@ def test_present_hide_mouse(args: Tuple[str, ...]) -> None:
assert results.stdout == ""
def test_present_ignore_aspect_ratio(args: Tuple[str, ...]) -> None:
def test_present_ignore_aspect_ratio(args: tuple[str, ...]) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
@ -76,7 +76,7 @@ def test_present_ignore_aspect_ratio(args: Tuple[str, ...]) -> None:
assert results.stdout == ""
def test_present_start_at(args: Tuple[str, ...]) -> None:
def test_present_start_at(args: tuple[str, ...]) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
@ -86,7 +86,7 @@ def test_present_start_at(args: Tuple[str, ...]) -> None:
assert results.stdout == ""
def test_present_start_at_invalid(args: Tuple[str, ...]) -> None:
def test_present_start_at_invalid(args: tuple[str, ...]) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
@ -96,7 +96,7 @@ def test_present_start_at_invalid(args: Tuple[str, ...]) -> None:
assert "Could not set presentation index to 1234"
def test_present_start_at_scene_number(args: Tuple[str, ...]) -> None:
def test_present_start_at_scene_number(args: tuple[str, ...]) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
@ -108,7 +108,7 @@ def test_present_start_at_scene_number(args: Tuple[str, ...]) -> None:
assert results.stdout == ""
def test_present_start_at_slide_number(args: Tuple[str, ...]) -> None:
def test_present_start_at_slide_number(args: tuple[str, ...]) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
@ -120,7 +120,7 @@ def test_present_start_at_slide_number(args: Tuple[str, ...]) -> None:
assert results.stdout == ""
def test_present_set_screen(args: Tuple[str, ...]) -> None:
def test_present_set_screen(args: tuple[str, ...]) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
@ -131,7 +131,7 @@ def test_present_set_screen(args: Tuple[str, ...]) -> None:
@pytest.mark.skip(reason="Fails when running the whole test suite.")
def test_present_set_invalid_screen(args: Tuple[str, ...]) -> None:
def test_present_set_invalid_screen(args: tuple[str, ...]) -> None:
runner = CliRunner()
with runner.isolated_filesystem():

View File

@ -1,8 +1,13 @@
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
import numpy as np
import pytest
from click.testing import CliRunner
from manim import (
@ -16,15 +21,42 @@ from manim import (
Dot,
FadeIn,
GrowFromCenter,
Square,
Text,
)
from packaging import version
from pydantic import ValidationError
from manim.renderer.opengl_renderer import OpenGLRenderer
from manim_slides.config import PresentationConfig
from manim_slides.defaults import FOLDER_PATH
from manim_slides.render import render
from manim_slides.slide.manim import Slide
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)
SlideType = Union[type[CESlide], type[_GLSlide], type[CEGLSlide]]
Slide = Union[CESlide, _GLSlide, CEGLSlide]
@pytest.mark.parametrize(
@ -34,11 +66,13 @@ from manim_slides.slide.manim import Slide
pytest.param(
"--GL",
marks=pytest.mark.skipif(
version.parse(np.__version__) >= version.parse("1.25"),
reason="ManimGL requires numpy<1.25, which is outdate",
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,
@ -51,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
@ -79,261 +113,562 @@ def test_render_basic_slide(
assert local_presentation_config.resolution == presentation_config.resolution
def assert_constructs(cls: type) -> type:
class Wrapper:
@classmethod
def test_construct(_) -> None: # noqa: N804
cls().construct()
def test_clear_cache(
slides_file: Path,
) -> None:
runner = CliRunner()
return Wrapper
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()
def assert_renders(cls: type) -> type:
class Wrapper:
@classmethod
def test_render(_) -> None: # noqa: N804
cls().render()
@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()
return Wrapper
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 parse_cli
_args = parse_cli()
return cls()
raise ValueError(f"Unsupported class {cls}")
parametrize_base_cls = pytest.mark.parametrize(
"base_cls", (CESlide, GLSlide, CEGLSlide), ids=("CE", "GL", "CE(GL)")
)
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:
with tmp_cwd():
init_slide(cls).render()
class TestSlide:
@assert_constructs
class TestDefaultProperties(Slide):
def construct(self) -> None:
assert self._output_folder == FOLDER_PATH
assert len(self._slides) == 0
assert self._current_slide == 1
assert self._start_animation == 0
assert len(self._canvas) == 0
assert self._wait_time_between_slides == 0.0
def test_default_properties(self) -> None:
@assert_constructs
class _(CESlide):
def construct(self) -> None:
assert self._output_folder == FOLDER_PATH
assert len(self._slides) == 0
assert self._current_slide == 1
assert self._start_animation == 0
assert len(self._canvas) == 0
assert self._wait_time_between_slides == 0.0
@assert_renders
class TestMultipleAnimationsInLastSlide(Slide):
"""Check against solution for issue #161."""
@parametrize_base_cls
def test_frame_height(self, base_cls: SlideType) -> None:
@assert_constructs
class _(base_cls): # type: ignore
def construct(self) -> None:
assert self._frame_height > 0 and isinstance(self._frame_height, float)
def construct(self) -> None:
circle = Circle(color=BLUE)
dot = Dot()
@parametrize_base_cls
def test_frame_width(self, base_cls: SlideType) -> None:
@assert_constructs
class _(base_cls): # type: ignore
def construct(self) -> None:
assert self._frame_width > 0 and isinstance(self._frame_width, float)
self.play(GrowFromCenter(circle))
self.play(FadeIn(dot))
self.next_slide()
@parametrize_base_cls
def test_resolution(self, base_cls: SlideType) -> None:
@assert_constructs
class _(base_cls): # type: ignore
def construct(self) -> None:
pw, ph = self._resolution
assert isinstance(pw, int) and pw > 0
assert isinstance(ph, int) and ph > 0
self.play(dot.animate.move_to(RIGHT))
self.play(dot.animate.move_to(UP))
self.play(dot.animate.move_to(LEFT))
self.play(dot.animate.move_to(DOWN))
@parametrize_base_cls
def test_backround_color(self, base_cls: SlideType) -> None:
@assert_constructs
class _(base_cls): # type: ignore
def construct(self) -> None:
assert self._background_color in ["#000000", "#000"] # DEFAULT
@assert_renders
class TestFileTooLong(Slide):
"""Check against solution for issue #123."""
def test_multiple_animations_in_last_slide(self) -> None:
@assert_renders
class _(CESlide):
"""Check against solution for issue #161."""
def construct(self) -> None:
circle = Circle(radius=3, color=BLUE)
dot = Dot()
self.play(GrowFromCenter(circle), run_time=0.1)
def construct(self) -> None:
circle = Circle(color=BLUE)
dot = Dot()
for _ in range(30):
direction = (random.random() - 0.5) * LEFT + (
random.random() - 0.5
) * UP
self.play(dot.animate.move_to(direction), run_time=0.1)
self.play(dot.animate.move_to(ORIGIN), run_time=0.1)
@assert_constructs
class TestLoop(Slide):
def construct(self) -> None:
text = Text("Some text")
self.add(text)
assert not self._base_slide_config.loop
self.next_slide(loop=True)
self.play(text.animate.scale(2))
assert self._base_slide_config.loop
self.next_slide(loop=False)
assert not self._base_slide_config.loop
@assert_constructs
class TestAutoNext(Slide):
def construct(self) -> None:
text = Text("Some text")
self.add(text)
assert not self._base_slide_config.auto_next
self.next_slide(auto_next=True)
self.play(text.animate.scale(2))
assert self._base_slide_config.auto_next
self.next_slide(auto_next=False)
assert not self._base_slide_config.auto_next
@assert_constructs
class TestLoopAndAutoNextFails(Slide):
def construct(self) -> None:
text = Text("Some text")
self.add(text)
self.next_slide(loop=True, auto_next=True)
self.play(text.animate.scale(2))
with pytest.raises(ValidationError):
self.play(GrowFromCenter(circle))
self.play(FadeIn(dot))
self.next_slide()
@assert_constructs
class TestPlaybackRate(Slide):
def construct(self) -> None:
text = Text("Some text")
self.play(dot.animate.move_to(RIGHT))
self.play(dot.animate.move_to(UP))
self.play(dot.animate.move_to(LEFT))
self.play(dot.animate.move_to(DOWN))
self.add(text)
def test_split_reverse(self) -> None:
@assert_renders
class _(CESlide):
max_duration_before_split_reverse = 3.0
assert self._base_slide_config.playback_rate == 1.0
def construct(self) -> None:
self.wait(2.0)
for _ in range(3):
self.next_slide()
self.wait(10.0)
self.next_slide(playback_rate=2.0)
self.play(text.animate.scale(2))
@assert_renders
class __(CESlide):
max_duration_before_split_reverse = None
assert self._base_slide_config.playback_rate == 2.0
def construct(self) -> None:
self.wait(5.0)
self.next_slide()
self.wait(5.0)
@assert_constructs
class TestReversedPlaybackRate(Slide):
def construct(self) -> None:
text = Text("Some text")
def test_file_too_long(self) -> None:
@assert_renders
class _(CESlide):
"""Check against solution for issue #123."""
self.add(text)
def construct(self) -> None:
circle = Circle(radius=3, color=BLUE)
dot = Dot()
self.play(GrowFromCenter(circle), run_time=0.1)
assert self._base_slide_config.reversed_playback_rate == 1.0
for _ in range(30):
direction = (random.random() - 0.5) * LEFT + (
random.random() - 0.5
) * UP
self.play(dot.animate.move_to(direction), run_time=0.1)
self.play(dot.animate.move_to(ORIGIN), run_time=0.1)
self.next_slide(reversed_playback_rate=2.0)
self.play(text.animate.scale(2))
def test_loop(self) -> None:
@assert_constructs
class _(CESlide):
def construct(self) -> None:
text = Text("Some text")
assert self._base_slide_config.reversed_playback_rate == 2.0
self.add(text)
@assert_constructs
class TestNotes(Slide):
def construct(self) -> None:
text = Text("Some text")
assert not self._base_slide_config.loop
self.add(text)
self.next_slide(loop=True)
self.play(text.animate.scale(2))
assert self._base_slide_config.notes == ""
assert self._base_slide_config.loop
self.next_slide(notes="test")
self.play(text.animate.scale(2))
self.next_slide(loop=False)
assert self._base_slide_config.notes == "test"
assert not self._base_slide_config.loop
@assert_constructs
class TestWipe(Slide):
def construct(self) -> None:
text = Text("Some text")
bye = Text("Bye")
def test_auto_next(self) -> None:
@assert_constructs
class _(CESlide):
def construct(self) -> None:
text = Text("Some text")
self.add(text)
self.add(text)
assert text in self.mobjects
assert bye not in self.mobjects
assert not self._base_slide_config.auto_next
self.wipe([text], [bye])
self.next_slide(auto_next=True)
self.play(text.animate.scale(2))
assert text not in self.mobjects
assert bye in self.mobjects
assert self._base_slide_config.auto_next
@assert_constructs
class TestZoom(Slide):
def construct(self) -> None:
text = Text("Some text")
bye = Text("Bye")
self.next_slide(auto_next=False)
self.add(text)
assert not self._base_slide_config.auto_next
assert text in self.mobjects
assert bye not in self.mobjects
def test_loop_and_auto_next_succeeds(self) -> None:
@assert_constructs
class _(CESlide):
def construct(self) -> None:
text = Text("Some text")
self.zoom([text], [bye])
self.add(text)
assert text not in self.mobjects
assert bye in self.mobjects
self.next_slide(loop=True, auto_next=True)
self.play(text.animate.scale(2))
@assert_constructs
class TestPlay(Slide):
def construct(self) -> None:
assert self._current_animation == 0
circle = Circle(color=BLUE)
dot = Dot()
self.next_slide()
self.play(GrowFromCenter(circle))
assert self._current_animation == 1
self.play(FadeIn(dot))
assert self._current_animation == 2
def test_playback_rate(self) -> None:
@assert_constructs
class _(CESlide):
def construct(self) -> None:
text = Text("Some text")
@assert_constructs
class TestWaitTimeBetweenSlides(Slide):
def construct(self) -> None:
self._wait_time_between_slides = 1.0
assert self._current_animation == 0
circle = Circle(color=BLUE)
self.play(GrowFromCenter(circle))
assert self._current_animation == 1
self.next_slide()
assert self._current_animation == 2 # self.wait = +1
self.add(text)
@assert_constructs
class TestNextSlide(Slide):
def construct(self) -> None:
assert self._current_slide == 1
self.next_slide()
assert self._current_slide == 1
circle = Circle(color=BLUE)
self.play(GrowFromCenter(circle))
self.next_slide()
assert self._current_slide == 2
self.next_slide()
assert self._current_slide == 2
assert self._base_slide_config.playback_rate == 1.0
@assert_constructs
class TestCanvas(Slide):
def construct(self) -> None:
text = Text("Some text")
bye = Text("Bye")
self.next_slide(playback_rate=2.0)
self.play(text.animate.scale(2))
assert len(self.canvas) == 0
assert self._base_slide_config.playback_rate == 2.0
self.add(text)
def test_reversed_playback_rate(self) -> None:
@assert_constructs
class _(CESlide):
def construct(self) -> None:
text = Text("Some text")
assert len(self.canvas) == 0
self.add(text)
self.add_to_canvas(text=text)
assert self._base_slide_config.reversed_playback_rate == 1.0
assert len(self.canvas) == 1
self.next_slide(reversed_playback_rate=2.0)
self.play(text.animate.scale(2))
self.add(bye)
assert self._base_slide_config.reversed_playback_rate == 2.0
assert len(self.canvas) == 1
def test_notes(self) -> None:
@assert_constructs
class _(CESlide):
def construct(self) -> None:
text = Text("Some text")
assert text not in self.mobjects_without_canvas
assert bye in self.mobjects_without_canvas
self.add(text)
self.remove(text)
assert self._base_slide_config.notes == ""
assert len(self.canvas) == 1
self.next_slide(notes="test")
self.play(text.animate.scale(2))
self.add_to_canvas(bye=bye)
assert self._base_slide_config.notes == "test"
assert len(self.canvas) == 2
def test_wipe(self) -> None:
@assert_constructs
class _(CESlide):
def construct(self) -> None:
text = Text("Some text")
bye = Text("Bye")
self.remove_from_canvas("text", "bye")
self.add(text)
assert len(self.canvas) == 0
assert text in self.mobjects
assert bye not in self.mobjects
with pytest.raises(KeyError):
self.remove_from_canvas("text")
self.wipe([text], [bye])
assert text not in self.mobjects
assert bye in self.mobjects
def test_zoom(self) -> None:
@assert_constructs
class _(CESlide):
def construct(self) -> None:
text = Text("Some text")
bye = Text("Bye")
self.add(text)
assert text in self.mobjects
assert bye not in self.mobjects
self.zoom([text], [bye])
assert text not in self.mobjects
assert bye in self.mobjects
def test_animation_count(self) -> None:
@assert_constructs
class _(CESlide):
def construct(self) -> None:
assert self._current_animation == 0
circle = Circle(color=BLUE)
dot = Dot()
self.play(GrowFromCenter(circle))
assert self._current_animation == 1
self.play(FadeIn(dot))
assert self._current_animation == 2
def test_wait_time_between_slides(self) -> None:
@assert_constructs
class _(CESlide):
def construct(self) -> None:
self._wait_time_between_slides = 1.0
assert self._current_animation == 0
circle = Circle(color=BLUE)
self.play(GrowFromCenter(circle))
assert self._current_animation == 1
self.next_slide()
assert self._current_animation == 2 # self.wait = +1
def test_next_slide(self) -> None:
@assert_constructs
class _(CESlide):
def construct(self) -> None:
assert self._current_slide == 1
self.next_slide()
assert self._current_slide == 1
circle = Circle(color=BLUE)
self.play(GrowFromCenter(circle))
self.next_slide()
assert self._current_slide == 2
self.next_slide()
assert self._current_slide == 2
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):
def construct(self) -> None:
text = Text("Some text")
bye = Text("Bye")
assert len(self.canvas) == 0
self.add(text)
assert len(self.canvas) == 0
self.add_to_canvas(text=text)
assert len(self.canvas) == 1
self.add(bye)
assert len(self.canvas) == 1
assert text not in self.mobjects_without_canvas
assert bye in self.mobjects_without_canvas
self.remove(text)
assert len(self.canvas) == 1
self.add_to_canvas(bye=bye)
assert len(self.canvas) == 2
self.remove_from_canvas("text", "bye")
assert len(self.canvas) == 0
with pytest.raises(KeyError):
self.remove_from_canvas("text")

View File

@ -1,17 +1,16 @@
from pathlib import Path
from typing import List
from manim_slides.utils import merge_basenames
def test_merge_basenames(paths: List[Path]) -> None:
def test_merge_basenames(paths: list[Path]) -> None:
path = merge_basenames(paths)
assert path.suffix == paths[0].suffix
assert path.parent == paths[0].parent
def test_merge_basenames_same_with_different_parent_directories(
paths: List[Path],
paths: list[Path],
) -> None:
d1 = Path("a/b/c")
d2 = Path("d/e/f")

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:

4323
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff