Compare commits

...

74 Commits

Author SHA1 Message Date
2fa0301935 Merge remote-tracking branch 'origin/main' into main 2023-10-23 17:39:01 +02:00
61b983db3a chore(version): bump 5.0.0-rc2 to 5.0.0-rc3 2023-10-23 17:38:49 +02:00
56b1ffe430 fix(deps): PySide6 player black screen (#297)
Fixes #293
2023-10-23 17:38:24 +02:00
541bf96945 feat(lib): add Slide.next_section method (#295)
* feat(lib): add `Slide.next_section` method

* [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>
2023-10-23 17:34:20 +02:00
626764146a chore(docs): add explicit targets 2023-10-20 09:26:08 +02:00
0322dae743 feat(lib): add loop option to next_slide and remove start/end_loop (#294)
* feat(lib): add `loop` option to `next_slide` and remove `start/end_loop`

* fix(docs): PR number
2023-10-19 15:37:54 +02:00
7928f6020c Merge branch 'color' into main 2023-10-19 14:32:37 +02:00
f3dfa782b0 fix(convert): ClassVar was ignored by Pydantic (#292)
* fix(convert): ClassVar was ignored by Pydantic

Using default factory function instead.

* update comments
2023-10-19 14:31:39 +02:00
e13ca7e0dc chore(lib): use pydantic color 2023-10-19 14:17:30 +02:00
6c9505b98a fix(lib): use optional instead of mutable defaults (#291)
* fix(lib): use optional instead of mutable defaults

* fix: add missing check for none
2023-10-19 11:52:14 +02:00
7b3a5c4824 fix(ci): OpenGL issue on Windows server (#290)
* fix(ci): OpenGL issue on Windows server

Attempt at fixing OpenGL issue on Windows server

* fix(ci): unmark xfail

* [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>
2023-10-19 11:39:16 +02:00
5daa94b823 chore(ci): enhance current lint rules (#289)
* chore(ci): enhance current lint rules

* run linter

* fix all warnings

* fix(convert): release VideoCapture

* fix(ci): properly close file

* better tests

* fix(ci): setup opengl

* Revert "fix(ci): setup opengl"

This reverts commit a33f53a1c04f909d7660f2b5221c763a9ef97d53.

* fix(ci): skipif Windows in workflows

* actually xfail
2023-10-18 19:24:14 +02:00
498e33ad8d chore(deps): bump urllib3 from 2.0.6 to 2.0.7 (#288)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.0.6 to 2.0.7.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.0.6...2.0.7)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-17 23:33:25 +02:00
860ab231b5 chore(tests): add tests for converter methods (#283)
* chore(tests): add tests for converter methods

* chore(tests): update ppt converter test

---------

Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
2023-10-17 22:33:01 +02:00
c075904a27 fix(ci): wrong logic 2023-10-17 16:58:59 +02:00
387d0f76b5 fix(convert): disallow empty list 2023-10-17 16:50:48 +02:00
1126a20785 fix(docs): remove return type 2023-10-17 16:22:56 +02:00
802f6406ae refactor(lib): change how manim API is imported (#285)
* refactor(lib): change how manim API is imported

* chore(lib): delete old files

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

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

* wip: moving all commands

* adding animations

* fix tests

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

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

* fix mypy

* fixes

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

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

* trying to fix docs

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

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

* wip: docs

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

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

* make it work

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

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

* wip test

* tests are working

* improving docs

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

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

* fix index

* docs: nicer shift

* docs: nicer quickstart example

* fix tests

* change tests

* move coverage to test workflow

* fix(tests): remove resolve

* strict resolve

* change local path test

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

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

* documented changes

* cleanup docs

* cleanup files

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

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

* fix(ci): set type

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-10-17 16:06:19 +02:00
685f871186 [pre-commit.ci] pre-commit autoupdate (#286)
updates:
- [github.com/macisamuele/language-formatters-pre-commit-hooks: v2.10.0 → v2.11.0](https://github.com/macisamuele/language-formatters-pre-commit-hooks/compare/v2.10.0...v2.11.0)
- [github.com/pre-commit/mirrors-mypy: v1.5.1 → v1.6.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.5.1...v1.6.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-10-17 12:52:17 +02:00
da14b5f24a [pre-commit.ci] pre-commit autoupdate (#284)
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.4.0 → v4.5.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.4.0...v4.5.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-10-14 10:15:38 +02:00
18a9906ae5 chore(deps): bump urllib3 from 2.0.4 to 2.0.6 (#280)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.0.4 to 2.0.6.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.0.4...2.0.6)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-05 10:04:34 +02:00
a0ee723c89 [pre-commit.ci] pre-commit autoupdate (#281)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.0.291 → v0.0.292](https://github.com/astral-sh/ruff-pre-commit/compare/v0.0.291...v0.0.292)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-10-05 10:04:22 +02:00
2b7bd0a68d Add github template to sharing page (#279)
* chore(docs): add github template to sharing page

* chore(docs): update according to style requirements
2023-10-05 10:03:55 +02:00
497e4e964f fix(docs): changelog link 2023-10-01 21:43:09 +02:00
86fc774a3d [pre-commit.ci] pre-commit autoupdate (#274)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.0.290 → v0.0.291](https://github.com/astral-sh/ruff-pre-commit/compare/v0.0.290...v0.0.291)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-09-27 18:46:02 +02:00
ce14c79230 [pre-commit.ci] pre-commit autoupdate (#273)
* [pre-commit.ci] pre-commit autoupdate

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

* [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>
2023-09-19 08:44:45 +02:00
6272d3f7ec fix(cli/convert): ensure dest path can be written (#262)
Fixes the issue that non-existing parent(s) in the dest path would raise an error.
2023-09-15 11:29:30 +02:00
bbe8b96030 chore(version): bump 5.0.0-rc1 to 5.0.0-rc2 2023-09-14 11:31:13 +02:00
1bc8423381 chore(deps): bump RevealJS' default version to 4.6.1 (#272)
Add 3 new themes: dracula, dark-contrast and white-contrast.
2023-09-14 11:30:13 +02:00
67147442f3 chore(convert): use Jinja2 for templating (#271)
* chore(convert): use Jinja2 for templating

Use Jinja2 templating for more customizable rendering

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

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

* fix(deps): jinja2 is no more optional

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-09-14 11:21:20 +02:00
859d48ad2e [pre-commit.ci] pre-commit autoupdate (#270)
* [pre-commit.ci] pre-commit autoupdate

updates:
- [github.com/psf/black: 23.7.0 → 23.9.1](https://github.com/psf/black/compare/23.7.0...23.9.1)
- [github.com/astral-sh/ruff-pre-commit: v0.0.287 → v0.0.288](https://github.com/astral-sh/ruff-pre-commit/compare/v0.0.287...v0.0.288)

* [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>
2023-09-12 16:03:46 +02:00
9a23296fa2 fix(docs): change example to not use LaTeX 2023-09-06 17:54:17 +02:00
0f5b374bce fix(docs): remove TeX deps in examples 2023-09-06 15:26:54 +02:00
2dc4c1ab99 chore(docs): update docs about data URI 2023-09-06 15:23:54 +02:00
f2ee29ad58 chore(docs): render subclass example 2023-09-06 15:12:29 +02:00
d127af9dd2 fix(docs): typo in rst directive 2023-09-06 10:45:38 +02:00
05ebf40543 chore(deps): bump actions/checkout from 3 to 4 (#264)
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  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>
2023-09-05 19:22:43 +02:00
9cc7957e35 [pre-commit.ci] pre-commit autoupdate (#263)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.0.286 → v0.0.287](https://github.com/astral-sh/ruff-pre-commit/compare/v0.0.286...v0.0.287)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-09-05 19:22:24 +02:00
b72b7bc256 fix(docs): 404 file not found fixed 2023-09-05 19:21:58 +02:00
4a1b8aea87 fix(docs): casing 2023-08-29 23:22:46 +02:00
c2b12d16eb fix(docs): syntax hl 2023-08-29 16:36:11 +02:00
bb5b294f40 feat(lib): Sphinx directive can now read files (#261)
* feat(lib): Sphinx directive can now read files

You can optionally read a file instead of the Sphinx directive's content

* fix(lib): rst syntax and docs

* [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>
2023-08-29 16:23:54 +02:00
9a3a343231 [pre-commit.ci] pre-commit autoupdate (#260)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.0.285 → v0.0.286](https://github.com/astral-sh/ruff-pre-commit/compare/v0.0.285...v0.0.286)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-08-29 14:50:56 +02:00
0f07d36f52 fix(deps): patch color for future manim versions (#255) 2023-08-28 17:09:19 +02:00
806b7d00f6 fix(lib): correctly format enums on Python>=3.11 (#257)
Closes #256

fix(tests): update tests and fix


chore(lib): simplify fix and more tests


chore(docs): document patch
2023-08-24 13:22:04 +02:00
3d9522cbb0 chore(deps): remove notebook dependencies (#248)
* chore(deps): remove notebook dependencies

Unused optional dependency.

* [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>
2023-08-22 11:25:33 +02:00
28c5336b83 [pre-commit.ci] pre-commit autoupdate (#247)
* [pre-commit.ci] pre-commit autoupdate

updates:
- [github.com/astral-sh/ruff-pre-commit: v0.0.284 → v0.0.285](https://github.com/astral-sh/ruff-pre-commit/compare/v0.0.284...v0.0.285)
- [github.com/pre-commit/mirrors-mypy: v1.5.0 → v1.5.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.5.0...v1.5.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>
2023-08-22 11:06:37 +02:00
48614105bd chore(version): bump 4.16.0 to 5.0.0-rc1 2023-08-21 22:15:15 +02:00
933afdd465 fix 2023-08-21 22:08:34 +02:00
599f9f22ae chore(ci): update bump config
fix(ci): regex


fix(ci): bump regex


fix


fix
2023-08-21 22:07:39 +02:00
5490a0a5ef chore(docs): add changelog to online docs 2023-08-21 21:34:37 +02:00
c875363b40 [ImgBot] Optimize images (#246)
*Total -- 5,933.58kb -> 1,375.26kb (76.82%)

/paper/docs.png -- 4,390.51kb -> 157.54kb (96.41%)
/static/example.gif -- 669.78kb -> 484.93kb (27.6%)
/static/logo_dark_github.png -- 112.68kb -> 86.31kb (23.4%)
/static/logo_dark_docs.png -- 111.55kb -> 86.00kb (22.9%)
/static/logo.png -- 109.63kb -> 89.99kb (17.91%)
/static/windows_quality_fix.png -- 34.61kb -> 29.19kb (15.65%)
/static/docs.png -- 209.36kb -> 177.73kb (15.11%)
/static/logo_light_transparent.png -- 116.05kb -> 102.36kb (11.79%)
/static/icon.png -- 2.03kb -> 1.79kb (11.61%)
/static/logo_dark_transparent.png -- 123.63kb -> 109.45kb (11.46%)
/static/wizard_light.png -- 26.28kb -> 24.23kb (7.8%)
/static/wizard_dark.png -- 27.48kb -> 25.72kb (6.43%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
Co-authored-by: ImgBotApp <ImgBotHelp@gmail.com>
2023-08-21 20:53:39 +02:00
bd9bf06876 chore(cli): remove unused PERF verbosity level (#245)
* chore(cli): remove unused PERF verbosity level

As titled.

* fix(tests): remove perf in tests
2023-08-21 18:00:56 +02:00
4d76f2ccc1 chore(deps): bump tornado from 6.3.2 to 6.3.3 (#244)
Bumps [tornado](https://github.com/tornadoweb/tornado) from 6.3.2 to 6.3.3.
- [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst)
- [Commits](https://github.com/tornadoweb/tornado/compare/v6.3.2...v6.3.3)

---
updated-dependencies:
- dependency-name: tornado
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-21 17:10:29 +02:00
8cf05ea44d chore(docs): correctly quote code 2023-08-21 17:10:13 +02:00
638616c94f feat(cli): rewrite presentation player (#243)
* wip: rewrite player

* wip(cli): new player

* wip(player): allow to close

* Auto stash before merge of "rewrite-player" and "origin/rewrite-player"

* feat(cli): new player

* chore(docs): document changes

* feat(cli): add info window
2023-08-21 16:50:03 +02:00
b321161717 chore(lib/cli): one video per slide (#242)
* chore(lib/cli): one video per slide

As titled, this PR changes how Manim Slides used to work by only storing one video file per slide.

Previously, a slide would store all animations that occur during the given slide. Up to now, the only "advantage" of this was that it would allow the user to know which animation is played.
But, at the cost of a very complex logic in present, just especially for reversed slides.

On top of top, all converter actually need to concatenate the animations from each slide into one, so it is now performed at rendering time.

To migrate from previous Manim Slides versions, the best is the render the slides again, using `manim render` or `manimgl render`.

Currently, it is not possible to start at a given animation anymore. However, if wanted, I may re-implement this, but this would require to change the config file again.

* fix(ci): trying to fix tests

* chore(test): renaming files

* chore(docs): remove old line from changelog

* fix(docs): typo

* fix(ci): manimgl and smarter comparison

* [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>
2023-08-20 19:40:39 +02:00
7363281ff0 [pre-commit.ci] pre-commit autoupdate (#241)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.0.282 → v0.0.284](https://github.com/astral-sh/ruff-pre-commit/compare/v0.0.282...v0.0.284)
- [github.com/pre-commit/mirrors-mypy: v1.4.1 → v1.5.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.4.1...v1.5.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-08-15 09:50:02 +02:00
d056d8d8b1 chore(version): bump 4.15.0 to 4.16.0 2023-08-08 14:45:03 +02:00
c95929dc7d fix(ci): version bump 2023-08-08 14:44:59 +02:00
e08edb6fe1 chore(docs): make 2nd example more different 2023-08-08 14:30:43 +02:00
efc3017df8 chore(paper): add paper's doi, update badges and add CFF 2023-08-08 14:13:29 +02:00
788727ea22 Merge branch 'optional' into main 2023-08-08 10:47:37 +02:00
0bb0285b18 fix(docs): typo in magics name 2023-08-08 10:46:44 +02:00
ce878bece2 [pre-commit.ci] pre-commit autoupdate (#239)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.0.281 → v0.0.282](https://github.com/astral-sh/ruff-pre-commit/compare/v0.0.281...v0.0.282)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-08-08 09:47:39 +02:00
1275e9119f chore(deps): make all groups optional (#238)
* chore(deps): make all groups optional

* chore(deps): update lock file

* chore(ci): run apt-get update
2023-08-07 18:25:06 +02:00
5555ac2e54 chore(ci): run apt-get update 2023-08-07 18:14:42 +02:00
838de83c75 chore(deps): update lock file 2023-08-07 18:07:22 +02:00
f1f7146c7e chore(deps): make all groups optional 2023-08-07 18:06:12 +02:00
fb02764bb7 feat(lib): add Jupyter magic (#237)
* feat(lib): add Jupyter magic

And also use the same logger level as manim (by default)

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

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

* fix(lib): remove deleted module

* chore(lib): fix typing issues

* chore(docs): document magic

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

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

* fix(ci): install kernel

* fix(ci): spawning is not necessary (and fails)

* chore(ci): add ipykernel

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-08-07 17:47:03 +02:00
f6f2e4090f fix(lib): fix import * (typo in name) 2023-08-07 10:11:16 +02:00
146a2f7839 chore(paper): fix brackets
As proposed by @labarba
2023-08-06 21:38:08 +02:00
d282766f2d feat(convert): support base64 encoded videos (#236)
* feat(convert): support base64 encoded videos

Thanks to @t-fritsch, Manim Slides can now convert to a fully self-contained HTML presentation using base64 encoded videos!

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

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

* chore(lib): explicit decode type

* feat(lib): auto detect mime type

* [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>
2023-08-06 19:06:43 +02:00
dec2f5e724 chore(docs): actually apply my own recomm. in example :-) 2023-08-04 16:49:37 +02:00
93 changed files with 6813 additions and 5384 deletions

View File

@ -1,5 +1,9 @@
[bumpversion]
current_version = 4.15.0
current_version = 5.0.0-rc3
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}
@ -10,3 +14,7 @@ replace = __version__ = "{new_version}"
[bumpversion:file:pyproject.toml]
search = version = "{current_version}"
replace = version = "{new_version}"
[bumpversion:file:CITATION.cff]
search = version: v{current_version}
replace = version: v{new_version}

View File

@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Cleanup
run: |

View File

@ -38,7 +38,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL

View File

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

View File

@ -16,7 +16,7 @@ jobs:
name: Paper Draft
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Build draft PDF
uses: openjournals/openjournals-draft-action@master
with:

View File

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

View File

@ -32,7 +32,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Install Poetry
run: pipx install poetry
- name: Install Python
@ -46,24 +46,18 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev
- name: Setup Pandoc
uses: nikeee/setup-pandoc@v1
- name: Install local Python package
run: poetry install --with docs
- name: Install IPython kernel
run: poetry run ipython kernel install --name "manim-slides" --user
- name: Restore cached media
id: cache-media-restore
uses: actions/cache/restore@v3
with:
path: media
key: ${{ runner.os }}-media
- name: Build animations
run: |
poetry run manim example.py ConvertExample BasicExample ThreeDExample
- name: Convert animations to HTML slides
run: |
poetry run manim-slides convert -v DEBUG ConvertExample docs/source/_static/slides.html -ccontrols=true
poetry run manim-slides convert -v DEBUG BasicExample docs/source/_static/basic_example.html -ccontrols=true
poetry run manim-slides convert -v DEBUG ThreeDExample docs/source/_static/three_d_example.html -ccontrols=true
- name: Show docs/source/_static/ dir content (video only)
run: tree -L 3 docs/source/_static/ -P '*.mp4'
path: docs/media
key: ${{ runner.os }}-docs-media
- name: Clear cache
run: |
gh extension install actions/gh-actions-cache
@ -74,7 +68,7 @@ jobs:
id: cache-media-save
uses: actions/cache/save@v3
with:
path: media
path: docs/media
key: ${{ steps.cache-media-restore.outputs.cache-primary-key }}
- name: Build docs
run: cd docs && poetry run make html

View File

@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Install Poetry
run: pipx install poetry

View File

@ -9,79 +9,18 @@ jobs:
strategy:
fail-fast: false
matrix:
pyversion: ['3.8', '3.9', '3.10', '3.11']
runs-on: ubuntu-latest
env:
QT_QPA_PLATFORM: offscreen
MANIM_SLIDES_VERBOSITY: debug
PYTHONFAULTHANDLER: 1
DISPLAY: :99
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Install Poetry
run: pipx install poetry
- name: Install Python
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.pyversion }}
cache: poetry
- name: Install manim dependencies on Ubuntu
run: |
sudo apt-get install libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev
- name: Install xvfb on Ubuntu
run: |
sudo apt-get install xvfb
nohup Xvfb $DISPLAY &
- name: Install Manim Slides
run: |
poetry install --with test
- name: Run pytest
run: poetry run pytest
build-examples:
strategy:
fail-fast: false
matrix:
manim: [manim, manimgl]
os: [macos-latest, ubuntu-latest, windows-latest]
pyversion: ['3.8', '3.9', '3.10', '3.11']
exclude:
# excludes manimgl on Windows because if throws errors
# related to OpenGL, which seems hard to fix:
# Your graphics drivers do not support OpenGL 2.0.
- os: windows-latest
manim: manimgl
# We only test Python 3.11 on Windows and MacOS
- os: windows-latest
pyversion: '3.8'
- os: windows-latest
pyversion: '3.9'
- os: windows-latest
pyversion: '3.10'
manim: manim
- os: macos-latest
pyversion: '3.8'
- os: macos-latest
pyversion: '3.9'
- os: macos-latest
pyversion: '3.10'
manim: manim
runs-on: ${{ matrix.os }}
env:
QT_QPA_PLATFORM: offscreen
MANIM_SLIDES_VERBOSITY: debug
PYTHONFAULTHANDLER: 1
DISPLAY: :99
GITHUB_WORKFLOWS: 1
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Install Poetry
run: pipx install poetry
@ -108,31 +47,15 @@ jobs:
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' && matrix.manim == 'manim'
- name: Install manim dependencies on MacOS
if: matrix.os == 'macos-latest'
run: brew install ffmpeg py3cairo
- name: Install manimgl dependencies on MacOS
if: matrix.os == 'macos-latest' && matrix.manim == 'manimgl'
run: brew install ffmpeg
- name: Run apt-get update on Ubuntu
if: matrix.os == 'ubuntu-latest'
run: sudo apt-get update
- name: Install manim dependencies on Ubuntu
if: matrix.os == 'ubuntu-latest' && matrix.manim == 'manim'
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev
- name: Install manimgl dependencies on Ubuntu
if: matrix.os == 'ubuntu-latest' && matrix.manim == 'manimgl'
run: |
sudo apt-get install libpango1.0-dev ffmpeg freeglut3-dev
- name: Install xvfb on Ubuntu
if: matrix.os == 'ubuntu-latest' && matrix.manim == 'manimgl'
run: |
sudo apt-get install xvfb
nohup Xvfb $DISPLAY &
@ -140,27 +63,26 @@ jobs:
if: matrix.os == 'windows-latest'
run: choco install ffmpeg
# Install Manim Slides
- name: Install Mesa
if: matrix.os == 'windows-latest'
uses: ssciwr/setup-mesa-dist-win@v1
- name: Install Manim Slides
run: |
poetry install --extras ${{ matrix.manim }}
poetry install --with test --all-extras
# Render slides
- name: Render slides
if: matrix.manim == 'manim'
run: poetry run manim -ql example.py BasicExample ThreeDExample
- name: Run pytest
if: matrix.os != 'ubuntu-latest' || matrix.pyversion != '3.11'
run: poetry run pytest
- name: Render slides
if: matrix.manim == 'manimgl'
run: poetry run -v manimgl -l example.py BasicExample ThreeDExample
- name: Run pytest and coverage
if: matrix.os == 'ubuntu-latest' && matrix.pyversion == '3.11'
run: poetry run pytest --cov-report xml --cov=manim_slides tests/
# Play slides
- name: Test slides
run: poetry run manim-slides BasicExample ThreeDExample --skip-all
# Test slides to html
- name: Test convert on Ubuntu
if: matrix.os == 'ubuntu-latest' && matrix.manim == 'manim'
run: |
poetry run manim -ql example.py ConvertExample
poetry run manim-slides convert --to=html ConvertExample index.html
- name: Upload to codecov.io
if: matrix.os == 'ubuntu-latest' && matrix.pyversion == '3.11'
uses: codecov/codecov-action@v3
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
fail_ci_if_error: true

8
.gitignore vendored
View File

@ -8,6 +8,7 @@ __pycache__/
# Manim files
images/
/media
tests/data/media/
docs/source/media/
# ManimGL files
@ -17,7 +18,7 @@ videos/
.manim-slides.toml
slides/
!tests/slides/
!tests/data/slides/
slides_assets/
@ -26,6 +27,8 @@ docs/build/
slides.html
docs/source/reference/.ipynb_checkpoints/
docs/source/_static/basic_example_assets/
docs/source/_static/basic_example.html
@ -34,6 +37,7 @@ docs/source/_static/three_d_example.html
docs/source/_static/three_d_example_assets/
docs/source/reference/media/
# JOSE Paper
paper/paper.pdf
@ -41,3 +45,5 @@ paper/media/
# Others
coverage.xml
rendering_times.csv

View File

@ -1,18 +1,13 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
rev: v4.5.0
hooks:
- id: check-yaml
- id: check-toml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/pycqa/isort
rev: 5.12.0
hooks:
- id: isort
name: isort (python)
- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
rev: v2.10.0
rev: v2.11.0
hooks:
- id: pretty-format-yaml
args: [--autofix]
@ -20,15 +15,22 @@ repos:
exclude: poetry.lock
args: [--autofix]
- repo: https://github.com/psf/black
rev: 23.7.0
rev: 23.9.1
hooks:
- id: black
- repo: https://github.com/adamchainz/blacken-docs
rev: 1.16.0
hooks:
- id: blacken-docs
additional_dependencies:
- black==23.9.1
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.0.281
rev: v0.0.292
hooks:
- id: ruff
args: [--fix]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.4.1
rev: v1.6.0
hooks:
- id: mypy
additional_dependencies: [types-requests, types-setuptools]

120
CHANGELOG.md Normal file
View File

@ -0,0 +1,120 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
<!-- start changelog -->
## [v5 (Unreleased)](https://github.com/jeertmans/manim-slides/compare/v4.16.0...HEAD)
Prior to v5, there was no real CHANGELOG other than the GitHub releases,
with most of the content automatically generated by GitHub from merged
pull requests.
In an effort to better document changes, this CHANGELOG document is now created.
(v5-added)=
### Added
- Added the following option aliases to `manim-slides present`:
`-F` and `--full-screen` for `fullscreen`,
`-H` for `--hide-mouse`,
and `-S` for `--screen-number`.
[#243](https://github.com/jeertmans/manim-slides/pull/243)
- Added a full screen key binding (defaults to <kbd>F</kbd>) in the
presenter.
[#243](https://github.com/jeertmans/manim-slides/pull/243)
- Added support for including code from a file in Manim Slides
Sphinx directive.
[#261](https://github.com/jeertmans/manim-slides/pull/261)
- Added the `manim_slides.slide.animation` module and created the
`Wipe` and `Zoom` classes, that return a new animation.
[#285](https://github.com/jeertmans/manim-slides/pull/285)
- Added two environ variables, `MANIM_API` and `FORCE_MANIM_API`,
to specify the `MANIM_API` to be used: `manim` and `manimce` will
import `manim`, while `manimgl` and `manimlib` will import `manimlib`.
If one of the two APIs is already imported, use `FORCE_MANIM_API=1` to
override this.
[#285](https://github.com/jeertmans/manim-slides/pull/285)
- Added a working `ThreeDSlide` class compatible with `manimlib`.
[#285](https://github.com/jeertmans/manim-slides/pull/285)
- Added `loop` option to `Slide`'s `next_slide` method.
Calling `next_slide` will never fail anymore.
[#294](https://github.com/jeertmans/manim-slides/pull/294)
- Added `Slide.next_section` for compatibility with `manim`'s
`Scene.next_section` method.
[#295](https://github.com/jeertmans/manim-slides/pull/295)
(v5-changed)=
### Changed
- Automatically concatenate all animations from a slide into one.
This is a **breaking change** because the config file format is
different from the previous one. For migration help, see associated PR.
[#242](https://github.com/jeertmans/manim-slides/pull/242)
- Changed the player interface to only use PySide6, and not a combination of
PySide6 and OpenCV. A few features have been removed (see removed section),
but the new player should be much easier to maintain and more performant,
than its predecessor.
[#243](https://github.com/jeertmans/manim-slides/pull/243)
- Changed the slide config format to exclude unecessary information.
`StypeType` is removed in favor to one boolean `loop` field. This is
a **breaking change** and one should re-render the slides to apply changes.
[#243](https://github.com/jeertmans/manim-slides/pull/243)
- Renamed key bindings in the config. This is a **breaking change** and one
should either manually rename them (see list below) or re-init a config.
List of changes: `CONTINUE` to `NEXT`, `BACK` to `PREVIOUS`, and
`REWIND` to `REPLAY`.
[#243](https://github.com/jeertmans/manim-slides/pull/243)
- Conversion to HTML now uses Jinja2 templating. The template file has
been modified accordingly, and old templates will not work anymore.
This is a **breaking change**.
[#271](https://github.com/jeertmans/manim-slides/pull/271)
- Bumped RevealJS' default version to v4.6.1, and added three new themes.
[#272](https://github.com/jeertmans/manim-slides/pull/272)
- Changed the logger such that `make_logger` is called at module import,
and we do not use Manim's logger anymore.
[#285](https://github.com/jeertmans/manim-slides/pull/285)
- Changed `Slide.wipe` and `Slide.zoom` to automatically call `self.play`.
This is a **breaking change** as calling `self.play(self.wipe(...))` now
raises an error (because `None` is not an animation).
[#285](https://github.com/jeertmans/manim-slides/pull/285)
- Changed the `manim_slides.slide` module to contain submodules, i.e.,
`slide.manim`, `slide.manimlib`, `slide.animation`.
Only `slide.animation` is part of the public API.
Rules for choosing the Manim API (either `manim` or `manimlib`) has changed,
and defaults to the currently imported module, with a preference for `manim`.
[#285](https://github.com/jeertmans/manim-slides/pull/285)
(v5-fixed)=
### Fixed
- Patched enums in `manim_slides/convert.py` to correctly call `str`'s
`__str__` method, and not the `Enum` one.
This bug was discovered by
[@alexanderskulikov](https://github.com/alexanderskulikov) in
[#253](https://github.com/jeertmans/manim-slides/discussions/253), caused by
Python 3.11's change in how `Enum` work.
[#257](https://github.com/jeertmans/manim-slides/pull/257).
- Fixed potential non-existing parent path issue in
`manim convert`'s destination path.
[#262](https://github.com/jeertmans/manim-slides/pull/262)
(v5-removed)=
### Removed
- Removed `--start-at-animation-number` option from `manim-slides present`.
[#242](https://github.com/jeertmans/manim-slides/pull/242)
- Removed the following options from `manim-slides present`:
`--resolution`, `--record-to`, `--resize-mode`, and `--background-color`.
[#243](https://github.com/jeertmans/manim-slides/pull/243)
- Removed `PERF` verbosity level because not used anymore.
[#245](https://github.com/jeertmans/manim-slides/pull/245)
- Remove `Slide`'s method `start_loop` and `self.end_loop`
in favor to `self.next_slide(loop=True)`.
This is a **breaking change**.
[#294](https://github.com/jeertmans/manim-slides/pull/294)
<!-- end changelog -->

32
CITATION.cff Normal file
View File

@ -0,0 +1,32 @@
# This CITATION.cff file was generated with cffinit.
# Visit https://bit.ly/cffinit to generate yours today!
cff-version: 1.2.0
title: Manim Slides
message: A Python package for presenting Manim content anywhere
type: software
authors:
- name: Jérome Eertmans
orcid: 'https://orcid.org/0000-0002-5579-5360'
website: 'https://eertmans.be'
identifiers:
- type: doi
value: 10.21105/jose.00206
description: The paper presenting the software.
repository-code: 'https://github.com/jeertmans/manim-slides'
url: 'https://eertmans.be/manim-slides'
abstract: >-
Manim Slides is a Python package that makes presenting
Manim animations straightforward. With minimal changes
required to pre-existing code, one can slide through
animations in a PowerPoint-like manner, or share its
slides online using ReavealJS power.
keywords:
- Education
- Math Animations
- Presentation Tool
- PowerPoint
- Python
license: MIT
version: v5.0.0-rc3

View File

@ -9,6 +9,7 @@
[![PyPI - Downloads][pypi-download-badge]][pypi-version-url]
[![Documentation][documentation-badge]][documentation-url]
[![DOI][doi-badge]][doi-url]
[![JOSE Paper][jose-badge]][jose-url]
[![codecov][codecov-badge]][codecov-url]
# Manim Slides
@ -88,15 +89,18 @@ The documentation is available [online](https://eertmans.be/manim-slides/).
### Basic Example
Wrap a series of animations between `self.start_loop()` and `self.stop_loop()` when you want to loop them (until input to continue):
Call `self.next_slide()` everytime 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:
```python
# example.py
from manim import *
# or: from manimlib import *
from manim import * # or: from manimlib import *
from manim_slides import Slide
class BasicExample(Slide):
def construct(self):
circle = Circle(radius=3, color=BLUE)
@ -105,12 +109,11 @@ class BasicExample(Slide):
self.play(GrowFromCenter(circle))
self.next_slide() # Waits user to press continue to go to the next slide
self.start_loop() # Start loop
self.next_slide(loop=True) # Start loop
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
self.end_loop() # This will loop until user inputs a key
self.next_slide() # This will start a new non-looping slide
self.play(dot.animate.move_to(ORIGIN))
self.next_slide() # Waits user to press continue to go to the next slide
```
First, render the animation files:
@ -261,7 +264,10 @@ you can do so at: [jeertmans@icloud.com](mailto:jeertmans@icloud.com).
[pypi-download-badge]: https://img.shields.io/pypi/dm/manim-slides
[documentation-badge]: https://img.shields.io/website?down_color=lightgrey&down_message=offline&label=documentation&up_color=green&up_message=online&url=https%3A%2F%2Feertmans.be%2Fmanim-slides%2F
[documentation-url]: https://eertmans.be/manim-slides/
[doi-badge]: https://zenodo.org/badge/DOI/10.5281/zenodo.7971361.svg
[doi-url]: https://doi.org/10.5281/zenodo.7971361
[doi-badge]: https://zenodo.org/badge/DOI/10.5281/zenodo.8215167.svg
[doi-url]: https://doi.org/10.5281/zenodo.8215167
[jose-badge]: https://jose.theoj.org/papers/10.21105/jose.00206/status.svg
[jose-url]: https://doi.org/10.21105/jose.00206
[codecov-badge]: https://codecov.io/gh/jeertmans/manim-slides/branch/main/graph/badge.svg?token=8P4DY9JCE4
[codecov-url]: https://codecov.io/gh/jeertmans/manim-slides

2
custom_config.yml Normal file
View File

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

View File

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

View File

@ -20,6 +20,7 @@ extensions = [
"sphinx.ext.intersphinx",
"sphinx.ext.viewcode",
# Additional
"nbsphinx",
"myst_parser",
"sphinxext.opengraph",
"sphinx_click",
@ -28,9 +29,7 @@ extensions = [
"manim_slides.docs.manim_slides_directive",
]
typehints_defaults = "comma"
typehints_use_signature = True
typehints_use_signature_return = True
autodoc_typehints = "both"
myst_enable_extensions = [
"colon_fence",
@ -40,6 +39,8 @@ myst_enable_extensions = [
templates_path = ["_templates"]
exclude_patterns = []
# Removes the 'package.module' part from package.module.Class
add_module_names = False
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
@ -73,6 +74,7 @@ intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
"manim": ("https://docs.manim.community/en/stable/", None),
"manimlib": ("https://3b1b.github.io/manim/", None),
"numpy": ("https://numpy.org/doc/stable/", None),
}
# -- OpenGraph settings

View File

@ -0,0 +1,2 @@
[restructuredtext parser]
syntax_highlight = short

View File

@ -13,7 +13,6 @@ The following summarizes the different presentation features Manim Slides offers
| Pause animation | Yes | No | No | N/A |
| Play slide in reverse | Yes | No | No | N/A |
| Slide count | Yes | Yes (optional) | Yes (optional) | N/A |
| Animation count | Yes | No | No | N/A |
| Needs Python with Manim Slides installed | Yes | No | No | No
| Requires internet access | No | Yes | No | No |
| Auto. play slides | Yes | Yes | Yes | N/A |
@ -23,5 +22,8 @@ The following summarizes the different presentation features Manim Slides offers
| Works cross-platforms | Yes | Yes | Partly[^1][^2] | Yes |
:::
[^1]: If you encounter a problem where slides do not automatically play or loops do not work, please [file an issue on GitHub](https://github.com/jeertmans/manim-slides/issues/new/choose).
[^2]: PowerPoint online does not seem to support automatic playing of videos, so you need LibreOffice Impress on Linux platforms.
[^1]: If you encounter a problem where slides do not automatically play or loops do not work,
please
[file an issue on GitHub](https://github.com/jeertmans/manim-slides/issues/new/choose).
[^2]: PowerPoint online does not seem to support automatic playing of videos,
so you need LibreOffice Impress on Linux platforms.

View File

@ -23,16 +23,18 @@ 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), you can create slides and present them either using the GUI, or your browser.
In a [very few steps](./quickstart),
you can create slides and present them either using the GUI, or your browser.
Slide through the demo below to get a quick glimpse on what you can do with Manim Slides.
<!-- From: https://faq.dailymotion.com/hc/en-us/articles/360022841393-How-to-preserve-the-player-aspect-ratio-on-a-responsive-page -->
<div style="position:relative;padding-bottom:56.25%;"> <iframe style="width:100%;height:100%;position:absolute;left:0px;top:0px;" frameborder="0" width="100%" height="100%" allowfullscreen allow="autoplay" src="_static/slides.html"></iframe></div>
Slide through the demo below to get a quick glimpse on what you can do with
Manim Slides.
```{eval-rst}
.. manim-slides:: ../../example.py:ConvertExample
:hide_source:
:quality: high
```
```{toctree}
:hidden:
@ -40,6 +42,7 @@ Slide through the demo below to get a quick glimpse on what you can do with Mani
quickstart
reference/index
features_table
manim_or_manimgl
```
```{toctree}

View File

@ -0,0 +1,71 @@
# Manim or ManimGL
Manim Slides supports both Manim (Community Edition) and ManimGL (by 3b1b).
Because both modules have slightly different APIs, Manim Slides needs to know
which Manim API you are using, to import the correct module.
## Default Behavior
By default, Manim Slides looks at {py:data}`sys.modules` and chooses the first
Manim package that is already imported: `manim` for Manim,
`manimlib` for ManimGL. This works pretty well when rendering
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:
1. first import the Manim API;
2. and, then, import `manim_slides`.
Example for `manim`:
```python
from manim import *
from manim_slides import Slide
```
Example for `manimlib`:
```python
from manimlib import *
from manim_slides import Slide
```
### Example of Default Import
The following code shows how Manim Slides detected that `manimlib`
was imported, so the {py:class}`Slide<manim_slides.slide.Slide>`
automatically subclasses the class from ManimGL, not Manim.
```python
from manimlib import Scene
from manim_slides import Slide
assert issubclass(Slide, Scene) # Slide subclasses Scene from ManimGL
from manim import Scene
assert not issubclass(Slide, Scene) # but not Scene from Manim
```
## Custom Manim API
If you want to override the default Manim API, you can set the `MANIM_API`
environment variable to:
- `manim` or `manimce` to import `manim`;
- `manimlib` or `manimgl` to import `manimlib`;
prior to importing `manim_slides`.
Note that Manim Slides will still first look at {py:data}`sys.modules` to check
if any of the two modules is already imported.
If you want to force Manim Slides to obey the `MANIM_API` environment variable,
you must also set `FORCE_MANIM_API=1`.

View File

@ -16,6 +16,10 @@
The output slides should look this this:
<div style="position:relative;padding-bottom:56.25%;"> <iframe style="width:100%;height:100%;position:absolute;left:0px;top:0px;" frameborder="0" width="100%" height="100%" allowfullscreen allow="autoplay" src="_static/basic_example.html"></iframe></div>
```{eval-rst}
.. manim-slides:: ../../example.py:BasicExample
:hide_source:
:quality: high
```
For more advanced examples, see the [Examples](reference/examples) section.

View File

@ -6,22 +6,35 @@ and `ThreeDSlide`, which are subclasses of `Scene` and `ThreeDScene` from Manim.
Therefore, we only document here the methods we think the end-user will ever
use, not the methods used internally when rendering.
## Slide
```{eval-rst}
.. autoclass:: manim_slides.Slide
.. autoclass:: manim_slides.slide.Slide
:members:
add_to_canvas,
canvas,
canvas_mobjects,
end_loop,
mobjects_without_canvas,
next_section,
next_slide,
pause,
remove_from_canvas,
start_loop,
wait_time_between_slides,
wipe,
zoom,
```
.. autoclass:: manim_slides.ThreeDSlide
## 3D Slide
```{eval-rst}
.. autoclass:: manim_slides.slide.ThreeDSlide
:members:
```
## Animations
```{eval-rst}
.. automodule:: manim_slides.slide.animation
:members:
Wipe,
Zoom,
```

View File

@ -29,9 +29,11 @@ where `-ccontrols=true` indicates that we want to display the blue navigation ar
Basic example from quickstart.
<div style="position:relative;padding-bottom:56.25%;"> <iframe style="width:100%;height:100%;position:absolute;left:0px;top:0px;" frameborder="0" width="100%" height="100%" allowfullscreen allow="autoplay" src="../_static/basic_example.html"></iframe></div>
```{eval-rst}
.. manim-slides:: ../../../example.py:BasicExample
:hide_source:
:quality: high
.. literalinclude:: ../../../example.py
:language: python
:linenos:
@ -40,13 +42,16 @@ Basic example from quickstart.
## 3D Example
Example using 3D camera. As Manim and ManimGL handle 3D differently, definitions are slightly different.
<div style="position:relative;padding-bottom:56.25%;"> <iframe style="width:100%;height:100%;position:absolute;left:0px;top:0px;" frameborder="0" width="100%" height="100%" allowfullscreen allow="autoplay" src="../_static/three_d_example.html"></iframe></div>
Example using 3D camera. As Manim and ManimGL handle 3D differently,
definitions are slightly different.
### With Manim
```{eval-rst}
.. manim-slides:: ../../../example.py:ThreeDExample
:hide_source:
:quality: high
.. literalinclude:: ../../../example.py
:language: python
:linenos:
@ -95,19 +100,23 @@ And later use this class anywhere in your code:
:linenos:
class SubclassExample(MovingCameraSlide):
"""Example taken from ManimCE's docs."""
def construct(self):
eq1 = MathTex("x", "=", "1")
eq2 = MathTex("x", "=", "2")
self.camera.frame.save_state()
self.play(Write(eq1))
ax = Axes(x_range=[-1, 10], y_range=[-1, 10])
graph = ax.plot(lambda x: np.sin(x), color=WHITE, x_range=[0, 3 * PI])
dot_1 = Dot(ax.i2gp(graph.t_min, graph))
dot_2 = Dot(ax.i2gp(graph.t_max, graph))
self.add(ax, graph, dot_1, dot_2)
self.play(self.camera.frame.animate.scale(0.5).move_to(dot_1))
self.next_slide()
self.play(
TransformMatchingTex(eq1, eq2),
self.camera.frame.animate.scale(0.5)
)
self.play(self.camera.frame.animate.move_to(dot_2))
self.next_slide()
self.play(Restore(self.camera.frame))
self.wait()
```
@ -116,13 +125,46 @@ If you do not plan to reuse `MovingCameraSlide` more than once, then you can
directly write the `construct` method in the body of `MovingCameraSlide`.
:::
```{eval-rst}
.. manim-slides:: SubclassExample
:hide_source:
:quality: high
from manim import *
from manim_slides import Slide
class MovingCameraSlide(Slide, MovingCameraScene):
pass
class SubclassExample(MovingCameraSlide):
def construct(self):
self.camera.frame.save_state()
ax = Axes(x_range=[-1, 10], y_range=[-1, 10])
graph = ax.plot(lambda x: np.sin(x), color=WHITE, x_range=[0, 3 * PI])
dot_1 = Dot(ax.i2gp(graph.t_min, graph))
dot_2 = Dot(ax.i2gp(graph.t_max, graph))
self.add(ax, graph, dot_1, dot_2)
self.play(self.camera.frame.animate.scale(0.5).move_to(dot_1))
self.next_slide()
self.play(self.camera.frame.animate.move_to(dot_2))
self.next_slide()
self.play(Restore(self.camera.frame))
self.wait()
```
## Advanced Example
A more advanced example is `ConvertExample`, which is used as demo slide and tutorial.
<div style="position:relative;padding-bottom:56.25%;"> <iframe style="width:100%;height:100%;position:absolute;left:0px;top:0px;" frameborder="0" width="100%" height="100%" allowfullscreen allow="autoplay" src="../_static/slides.html"></iframe></div>
```{eval-rst}
.. manim-slides:: ../../../example.py:ConvertExample
:hide_source:
:quality: high
.. literalinclude:: ../../../example.py
:language: python
:linenos:

View File

@ -10,6 +10,7 @@ cli
examples
gui
html
IPython magic <ipython_magic>
sharing
Sphinx Extension <sphinx_extension>
```
@ -26,6 +27,11 @@ Slides' executable.
[HTML Presentation](./html): an alternative way of presenting your animations.
[IPython Magic](./ipython_magic): a magic to render and display Manim Slides inside notebooks.
+ [Example](./magic_example): example notebook using the magics.
[Sharing](./sharing): how to share your presentation with others.
[Sphinx Extension](./sphinx_extension): a Sphinx extension for diplaying Manim Slides animations within your documentation.

View File

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

View File

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

View File

@ -131,13 +131,13 @@ and it there to preserve the original aspect ratio (16:9).
### Sharing ONE HTML file
A future feature, that will be available once
[#122](https://github.com/jeertmans/manim-slides/issues/122) is solved, will be
to include all animations as data URI encoded, within the HTML file itself.
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.
### Over the internet
Finally, HTML conversion makes it convenient to play your presentation on a
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
@ -148,6 +148,15 @@ to know how to share your slide with GitHub pages, see the
can take some time, and *glitches* may occur between slide transitions for this
reason.
### Using the Github starter template
A [starter template](https://github.com/jeertmans/manim-slides-starter) is
available which allows to quickly get going with a new Manim slides
presentation on your Github account. The template comes ready with
functionality to automate tasks using Github actions and publish to Github
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

View File

@ -1,19 +1,13 @@
# flake8: noqa: F403, F405
# type: ignore
import sys
if "manim" in sys.modules:
from manim import *
MANIMGL = False
elif "manimlib" in sys.modules:
from manimlib import *
MANIMGL = True
else:
raise ImportError("This script must be run with either `manim` or `manimgl`")
from manim_slides import Slide, ThreeDSlide
from manim_slides.slide import MANIM, MANIMGL
if MANIM:
from manim import *
elif MANIMGL:
from manimlib import *
class BasicExample(Slide):
@ -22,60 +16,20 @@ class BasicExample(Slide):
dot = Dot()
self.play(GrowFromCenter(circle))
self.next_slide() # Waits user to press continue to go to the next slide
self.start_loop() # Start loop
self.next_slide(loop=True)
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
self.end_loop() # This will loop until user inputs a key
self.next_slide()
self.play(dot.animate.move_to(ORIGIN))
self.next_slide() # Waits user to press continue to go to the next slide
class MultipleAnimationsInLastSlide(Slide):
"""This is used to check against solution for issue #161."""
def construct(self):
circle = Circle(color=BLUE)
dot = Dot()
self.play(GrowFromCenter(circle))
self.play(FadeIn(dot))
self.next_slide()
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.next_slide()
class TestFileTooLong(Slide):
"""This is used to check against solution for issue #123."""
def construct(self):
import random
circle = Circle(radius=3, color=BLUE)
dot = Dot()
self.play(GrowFromCenter(circle), run_time=0.1)
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()
class ConvertExample(Slide):
"""WARNING: this example does not seem to work with ManimGL."""
def tinywait(self):
self.wait(0.1)
def construct(self):
self.wait_time_between_slides = 0.1
title = VGroup(
Text("From Manim animations", t2c={"From": BLUE}),
Text("to slides presentation", t2c={"to": BLUE}),
@ -182,9 +136,9 @@ class Example(Slide):
def construct(self):
dot = Dot()
self.add(dot)
self.start_loop()
self.next_slide(loop=True)
self.play(Indicate(dot, scale_factor=2))
self.end_loop()
self.next_slide()
square = Square()
self.play(Transform(dot, square))
self.next_slide()
@ -210,64 +164,53 @@ class Example(Slide):
language="console",
).shift(DOWN)
self.clear()
self.play(FadeIn(code))
self.tinywait()
self.wipe(title, code)
self.next_slide()
self.play(FadeIn(step, shift=RIGHT))
self.play(Transform(code, code_step_1))
self.tinywait()
self.next_slide()
self.play(Transform(step, step_2))
self.play(Transform(code, code_step_2))
self.tinywait()
self.next_slide()
self.play(Transform(step, step_3))
self.play(Transform(code, code_step_3))
self.tinywait()
self.next_slide()
self.play(Transform(step, step_4))
self.play(Transform(code, code_step_4))
self.tinywait()
self.next_slide()
self.play(Transform(step, step_5))
self.play(Transform(code, code_step_5))
self.tinywait()
self.next_slide()
self.play(Transform(step, step_6))
self.play(Transform(code, code_step_6))
self.play(code.animate.shift(UP), FadeIn(code_step_7), FadeIn(or_text))
self.tinywait()
self.next_slide()
watch_text = Text("Watch result on next slides!").shift(2 * DOWN).scale(0.5)
self.start_loop()
self.next_slide(loop=True)
self.play(FadeIn(watch_text))
self.play(FadeOut(watch_text))
self.end_loop()
self.next_slide()
self.clear()
dot = Dot()
self.add(dot)
self.start_loop()
self.next_slide(loop=True)
self.play(Indicate(dot, scale_factor=2))
self.end_loop()
self.next_slide()
square = Square()
self.play(Transform(dot, square))
self.remove(dot)
self.add(square)
self.tinywait()
self.next_slide()
self.play(Rotate(square, angle=PI / 4))
self.tinywait()
self.next_slide()
learn_more_text = (
@ -280,7 +223,6 @@ class Example(Slide):
)
self.play(Transform(square, learn_more_text))
self.tinywait()
# For ThreeDExample, things are different
@ -302,9 +244,9 @@ if not MANIMGL:
self.next_slide()
self.start_loop()
self.next_slide(loop=True)
self.play(MoveAlongPath(dot, circle), run_time=4, rate_func=linear)
self.end_loop()
self.next_slide()
self.stop_ambient_camera_rotation()
self.move_camera(phi=75 * DEGREES, theta=30 * DEGREES)
@ -315,9 +257,9 @@ if not MANIMGL:
self.play(dot.animate.move_to(RIGHT * 3))
self.next_slide()
self.start_loop()
self.next_slide(loop=True)
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
self.end_loop()
self.next_slide()
self.play(dot.animate.move_to(ORIGIN))
@ -326,11 +268,7 @@ else:
# [manimgl-3d]
# WARNING: 3b1b's manim change how ThreeDScene work,
# this is why things have to be managed differently.
class ThreeDExample(Slide):
CONFIG = {
"camera_class": ThreeDCamera,
}
class ThreeDExample(ThreeDSlide):
def construct(self):
axes = ThreeDAxes()
circle = Circle(radius=3, color=BLUE)
@ -342,7 +280,6 @@ else:
frame.set_euler_angles(
theta=30 * DEGREES,
phi=75 * DEGREES,
gamma=0,
)
self.play(GrowFromCenter(circle))
@ -354,9 +291,9 @@ else:
self.next_slide()
self.start_loop()
self.next_slide(loop=True)
self.play(MoveAlongPath(dot, circle), run_time=4, rate_func=linear)
self.end_loop()
self.next_slide()
frame.remove_updater(updater)
self.play(frame.animate.set_theta(30 * DEGREES))
@ -366,9 +303,9 @@ else:
self.play(dot.animate.move_to(RIGHT * 3))
self.next_slide()
self.start_loop()
self.next_slide(loop=True)
self.play(MoveAlongPath(dot, circle), run_time=2, rate_func=linear)
self.end_loop()
self.next_slide()
self.play(dot.animate.move_to(ORIGIN))

View File

@ -1,4 +1,3 @@
# flake8: noqa: F401
import sys
from types import ModuleType
from typing import Any, List
@ -6,13 +5,27 @@ from typing import Any, List
from .__version__ import __version__
class module(ModuleType):
class Module(ModuleType):
def __getattr__(self, name: str) -> Any:
if name == "Slide" or name == "ThreeDSlide":
module = __import__(
"manim_slides.slide", None, None, ["Slide", "ThreeDSlide"]
)
return getattr(module, name)
elif name == "ManimSlidesMagic":
module = __import__(
"manim_slides.ipython.ipython_magic", None, None, ["ManimSlidesMagic"]
)
magic = getattr(module, name)
from IPython import get_ipython
ipy = get_ipython()
if ipy is not None:
ipy.register_magics(magic)
return magic
return ModuleType.__getattribute__(self, name)
@ -34,7 +47,7 @@ class module(ModuleType):
old_module = sys.modules["manim_slides"]
new_module = sys.modules["manim_slides"] = module("manim_slides")
new_module = sys.modules["manim_slides"] = Module("manim_slides")
new_module.__dict__.update(
{
@ -43,6 +56,6 @@ new_module.__dict__.update(
"__path__": __path__,
"__doc__": __doc__,
"__version__": __version__,
"__all__": ("__version__", "Slides", "ThreeDSlide"),
"__all__": ("__version__", "ManimSlidesMagic", "Slide", "ThreeDSlide"),
}
)

View File

@ -6,7 +6,7 @@ from click_default_group import DefaultGroup
from .__version__ import __version__
from .convert import convert
from .logger import make_logger
from .logger import logger
from .present import list_scenes, present
from .wizard import init, wizard
@ -27,7 +27,6 @@ def cli(notify_outdated_version: bool) -> None:
If no command is specified, defaults to `present`.
"""
logger = make_logger()
# Code below is mostly a copy from:
# https://github.com/ManimCommunity/manim/blob/main/manim/cli/render/commands.py
if notify_outdated_version:

View File

@ -1 +1 @@
__version__ = "4.15.0"
__version__ = "5.0.0-rc3"

View File

@ -12,7 +12,7 @@ Wrapper = Callable[[F], F]
def config_path_option(function: F) -> F:
"""Wraps a function to add configuration path option."""
"""Wrap a function to add configuration path option."""
wrapper: Wrapper = click.option(
"-c",
"--config",
@ -27,7 +27,7 @@ def config_path_option(function: F) -> F:
def config_options(function: F) -> F:
"""Wraps a function to add configuration options."""
"""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."
@ -42,9 +42,9 @@ def config_options(function: F) -> F:
def verbosity_option(function: F) -> F:
"""Wraps a function to add verbosity option."""
"""Wrap a function to add verbosity option."""
def callback(ctx: Context, param: Parameter, value: bool) -> None:
def callback(ctx: Context, param: Parameter, value: str) -> None:
if not value or ctx.resilient_parsing:
return
@ -54,10 +54,10 @@ def verbosity_option(function: F) -> F:
"-v",
"--verbosity",
type=click.Choice(
["PERF", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
case_sensitive=False,
),
help="Verbosity of CLI output. PERF will log performances (timing) information.",
help="Verbosity of CLI output.",
default=None,
expose_value=False,
envvar="MANIM_SLIDES_VERBOSITY",
@ -69,7 +69,7 @@ def verbosity_option(function: F) -> F:
def folder_path_option(function: F) -> F:
"""Wraps a function to add folder path option."""
"""Wrap a function to add folder path option."""
wrapper: Wrapper = click.option(
"--folder",
metavar="DIRECTORY",

View File

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

View File

@ -1,33 +1,39 @@
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, Generator, List, Optional, Type, Union
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 data
from . import templates
from .commons import folder_path_option, verbosity_option
from .config import PresentationConfig
from .logger import logger
@ -56,33 +62,43 @@ def validate_config_option(
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}"
class Converter(BaseModel): # type: ignore
presentation_configs: List[PresentationConfig] = []
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:
"""Converts self, i.e., a list of presentations, into a given format."""
"""Convert self, i.e., a list of presentations, into a given format."""
raise NotImplementedError
def load_template(self) -> str:
"""Returns the template as a string.
"""
Return the template as a string.
An empty string is returned if no template is used."""
An empty string is returned if no template is used.
"""
return ""
def open(self, file: Path) -> Any:
"""Opens a file, generated with converter, using appropriate application."""
"""Open a file, generated with converter, using appropriate application."""
raise NotImplementedError
@classmethod
def from_string(cls, s: str) -> Type["Converter"]:
"""Returns the appropriate converter from a string name."""
"""Return the appropriate converter from a string name."""
return {
"html": RevealJS,
"pdf": PDF,
@ -103,45 +119,50 @@ class Str(str):
return core_schema.str_schema()
def __str__(self) -> str:
"""Ensures that the string is correctly quoted."""
"""Ensure that the string is correctly quoted."""
if self in ["true", "false", "null"]:
return super().__str__()
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, Enum):
class JsTrue(str, StrEnum):
true = "true"
class JsFalse(str, Enum):
class JsFalse(str, StrEnum):
false = "false"
class JsBool(Str, Enum): # type: ignore
class JsBool(Str, StrEnum): # type: ignore
true = "true"
false = "false"
class JsNull(Str, Enum): # type: ignore
class JsNull(Str, StrEnum): # type: ignore
null = "null"
class ControlsLayout(Str, Enum): # type: ignore
class ControlsLayout(Str, StrEnum): # type: ignore
edges = "edges"
bottom_right = "bottom-right"
class ControlsBackArrows(Str, Enum): # type: ignore
class ControlsBackArrows(Str, StrEnum): # type: ignore
faded = "faded"
hidden = "hidden"
visibly = "visibly"
class SlideNumber(Str, Enum): # type: ignore
class SlideNumber(Str, StrEnum): # type: ignore
true = "true"
false = "false"
hdotv = "h.v"
@ -150,24 +171,24 @@ class SlideNumber(Str, Enum): # type: ignore
candt = "c/t"
class ShowSlideNumber(Str, Enum): # type: ignore
class ShowSlideNumber(Str, StrEnum): # type: ignore
all = "all"
print = "print"
speaker = "speaker"
class KeyboardCondition(Str, Enum): # type: ignore
class KeyboardCondition(Str, StrEnum): # type: ignore
null = "null"
focused = "focused"
class NavigationMode(Str, Enum): # type: ignore
class NavigationMode(Str, StrEnum): # type: ignore
default = "default"
linear = "linear"
grid = "grid"
class AutoPlayMedia(Str, Enum): # type: ignore
class AutoPlayMedia(Str, StrEnum): # type: ignore
null = "null"
true = "true"
false = "false"
@ -176,25 +197,25 @@ class AutoPlayMedia(Str, Enum): # type: ignore
PreloadIframes = AutoPlayMedia
class AutoAnimateMatcher(Str, Enum): # type: ignore
class AutoAnimateMatcher(Str, StrEnum): # type: ignore
null = "null"
class AutoAnimateEasing(Str, Enum): # type: ignore
class AutoAnimateEasing(Str, StrEnum): # type: ignore
ease = "ease"
AutoSlide = Union[PositiveInt, JsFalse]
class AutoSlideMethod(Str, Enum): # type: ignore
class AutoSlideMethod(Str, StrEnum): # type: ignore
null = "null"
MouseWheel = Union[JsNull, float]
class Transition(Str, Enum): # type: ignore
class Transition(Str, StrEnum): # type: ignore
none = "none"
fade = "fade"
slide = "slide"
@ -203,13 +224,13 @@ class Transition(Str, Enum): # type: ignore
zoom = "zoom"
class TransitionSpeed(Str, Enum): # type: ignore
class TransitionSpeed(Str, StrEnum): # type: ignore
default = "default"
fast = "fast"
slow = "slow"
class BackgroundSize(Str, Enum): # type: ignore
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"
@ -219,11 +240,11 @@ class BackgroundSize(Str, Enum): # type: ignore
BackgroundTransition = Transition
class Display(Str, Enum): # type: ignore
class Display(Str, StrEnum): # type: ignore
block = "block"
class RevealTheme(str, Enum):
class RevealTheme(str, StrEnum):
black = "black"
white = "white"
league = "league"
@ -235,9 +256,14 @@ class RevealTheme(str, Enum):
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%")
@ -279,20 +305,22 @@ class RevealJS(Converter):
auto_animate_easing: AutoAnimateEasing = AutoAnimateEasing.ease
auto_animate_duration: float = 1.0
auto_animate_unmatched: JsBool = JsBool.true
auto_animate_styles: List[str] = [
"opacity",
"color",
"background-color",
"padding",
"font-size",
"line-height",
"letter-spacing",
"border-width",
"border-color",
"border-radius",
"outline",
"outline-offset",
]
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
@ -314,69 +342,62 @@ class RevealJS(Converter):
display: Display = Display.block
hide_inactive_cursor: JsBool = JsBool.true
hide_cursor_time: int = 5000
# Add. options
background_color: str = "black" # TODO: use pydantic.color.Color
reveal_version: str = "4.4.0"
# 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 get_sections_iter(self, assets_dir: Path) -> Generator[str, None, None]:
"""Generates a sequence of sections, one per slide, that will be included into the html template."""
for presentation_config in self.presentation_configs:
for slide_config in presentation_config.slides:
file = presentation_config.files[slide_config.start_animation]
file = assets_dir / file.name
logger.debug(f"Writing video section with file {file}")
# TODO: document this
# Videos are muted because, otherwise, the first slide never plays correctly.
# This is due to a restriction in playing audio without the user doing anything.
# Later, this might be useful to only mute the first video, or to make it optional.
# Read more about this:
# https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide#autoplay_and_autoplay_blocking
if slide_config.is_loop():
yield f'<section data-background-size={self.background_size.value} data-background-color="{presentation_config.background_color}" data-background-video="{file}" data-background-video-muted data-background-video-loop></section>'
else:
yield f'<section data-background-size={self.background_size.value} data-background-color="{presentation_config.background_color}" data-background-video="{file}" data-background-video-muted></section>'
def load_template(self) -> str:
"""Returns the RevealJS HTML template as a string."""
"""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(data, "revealjs_template.html")
return resources.read_text(templates, "revealjs.html")
return resources.files(data).joinpath("revealjs_template.html").read_text()
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:
"""Converts this configuration into a RevealJS HTML presentation, saved to DEST."""
dirname = dest.parent
basename = dest.stem
ext = dest.suffix
"""
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
assets_dir = Path(
self.assets_dir.format(dirname=dirname, basename=basename, ext=ext)
)
full_assets_dir = dirname / assets_dir
logger.debug(f"Assets will be saved to: {full_assets_dir}")
logger.debug(f"Assets will be saved to: {full_assets_dir}")
os.makedirs(full_assets_dir, exist_ok=True)
full_assets_dir.mkdir(parents=True, exist_ok=True)
for presentation_config in self.presentation_configs:
presentation_config.concat_animations().copy_to(full_assets_dir)
for presentation_config in self.presentation_configs:
presentation_config.copy_to(full_assets_dir)
dest.parent.mkdir(parents=True, exist_ok=True)
with open(dest, "w") as f:
sections = "".join(self.get_sections_iter(assets_dir))
revealjs_template = Template(self.load_template())
revealjs_template = self.load_template()
content = revealjs_template.format(sections=sections, **self.dict())
options = self.dict()
options["assets_dir"] = assets_dir
content = revealjs_template.render(
file_to_data_uri=file_to_data_uri, **options
)
f.write(content)
@ -395,7 +416,7 @@ class PDF(Converter):
return open_with_default(file)
def convert_to(self, dest: Path) -> None:
"""Converts this configuration into a PDF presentation, saved to DEST."""
"""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))
@ -405,6 +426,7 @@ class PDF(Converter):
cap.set(cv2.CAP_PROP_POS_FRAMES, index - 1)
ret, frame = cap.read()
cap.release()
if ret:
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
@ -415,15 +437,16 @@ class PDF(Converter):
images = []
for i, presentation_config in enumerate(self.presentation_configs):
presentation_config.concat_animations()
for slide_config in tqdm(
presentation_config.slides,
desc=f"Generating video slides for config {i + 1}",
leave=False,
):
file = presentation_config.files[slide_config.start_animation]
images.append(
read_image_from_video_file(slide_config.file, self.frame_index)
)
images.append(read_image_from_video_file(file, self.frame_index))
dest.parent.mkdir(parents=True, exist_ok=True)
images[0].save(
dest,
@ -447,7 +470,7 @@ class PowerPoint(Converter):
return open_with_default(file)
def convert_to(self, dest: Path) -> None:
"""Converts this configuration into a PowerPoint presentation, saved to DEST."""
"""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
@ -476,25 +499,28 @@ class PowerPoint(Converter):
return etree.ElementBase.xpath(el, query, namespaces=nsmap)
def save_first_image_from_video_file(file: Path) -> Optional[str]:
cap = cv2.VideoCapture(str(file))
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):
presentation_config.concat_animations()
for slide_config in tqdm(
presentation_config.slides,
desc=f"Generating video slides for config {i + 1}",
leave=False,
):
file = presentation_config.files[slide_config.start_animation]
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)
@ -509,16 +535,17 @@ class PowerPoint(Converter):
self.width * 9525,
self.height * 9525,
poster_frame_image=poster_frame_image,
mime_type="video/mp4",
mime_type=mime_type,
)
if self.auto_play_media:
auto_play_media(movie, loop=slide_config.is_loop())
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]:
"""Wraps a function to add a `--show-config` option."""
"""Wrap a function to add a `--show-config` option."""
def callback(ctx: Context, param: Parameter, value: bool) -> None:
if not value or ctx.resilient_parsing:
@ -526,9 +553,11 @@ def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
to = ctx.params.get("to", "html")
converter = Converter.from_string(to)(presentation_configs=[])
converter = Converter.from_string(to)(
presentation_configs=[PresentationConfig()]
)
for key, value in converter.dict().items():
click.echo(f"{key}: {repr(value)}")
click.echo(f"{key}: {value!r}")
ctx.exit()
@ -544,7 +573,7 @@ def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
"""Wraps a function to add a `--show-template` option."""
"""Wrap a function to add a `--show-template` option."""
def callback(ctx: Context, param: Parameter, value: bool) -> None:
if not value or ctx.resilient_parsing:
@ -554,7 +583,7 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
template = ctx.params.get("template", None)
converter = Converter.from_string(to)(
presentation_configs=[], template=template
presentation_configs=[PresentationConfig()], template=template
)
click.echo(converter.load_template())
@ -617,10 +646,7 @@ def convert(
config_options: Dict[str, str],
template: Optional[Path],
) -> None:
"""
Convert SCENE(s) into a given format and writes the result in DEST.
"""
"""Convert SCENE(s) into a given format and writes the result in DEST."""
presentation_configs = get_scenes_presentation_config(scenes, folder)
try:
@ -647,4 +673,4 @@ def convert(
_msg = error["msg"]
msg.append(f"Option '{option}': {_msg}")
raise click.UsageError("\n".join(msg))
raise click.UsageError("\n".join(msg)) from None

View File

@ -1,291 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>{title}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{reveal_version}/reveal.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{reveal_version}/theme/{reveal_theme}.min.css">
<!-- Theme used for syntax highlighting of code -->
<!-- <link rel="stylesheet" href="lib/css/zenburn.css"> -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.13.1/styles/zenburn.min.css">
<!-- <link rel="stylesheet" href="index.css"> -->
</head>
<body>
<div class="reveal">
<div class="slides">
{sections}
</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/ -->
<!-- <script src="index.js"></script> -->
<script>
Reveal.initialize({{
// The "normal" size of the presentation, aspect ratio will
// be preserved when the presentation is scaled to fit different
// resolutions. Can be specified using percentage units.
width: {width},
height: {height},
// Factor of the display size that should remain empty around
// the content
margin: {margin},
// Bounds for smallest/largest possible scale to apply to content
minScale: {min_scale},
maxScale: {max_scale},
// Display presentation control arrows
controls: {controls},
// Help the user learn the controls by providing hints, for example by
// bouncing the down arrow when they first encounter a vertical slide
controlsTutorial: {controls_tutorial},
// Determines where controls appear, "edges" or "bottom-right"
controlsLayout: {controls_layout},
// Visibility rule for backwards navigation arrows; "faded", "hidden"
// or "visible"
controlsBackArrows: {controls_back_arrows},
// Display a presentation progress bar
progress: {progress},
// Display the page number of the current slide
// - true: Show slide number
// - false: Hide slide number
//
// Can optionally be set as a string that specifies the number formatting:
// - "h.v": Horizontal . vertical slide number (default)
// - "h/v": Horizontal / vertical slide number
// - "c": Flattened slide number
// - "c/t": Flattened slide number / total slides
//
// Alternatively, you can provide a function that returns the slide
// number for the current slide. The function should take in a slide
// object and return an array with one string [slideNumber] or
// three strings [n1,delimiter,n2]. See #formatSlideNumber().
slideNumber: {slide_number},
// Can be used to limit the contexts in which the slide number appears
// - "all": Always show the slide number
// - "print": Only when printing to PDF
// - "speaker": Only in the speaker view
showSlideNumber: {show_slide_number},
// Use 1 based indexing for # links to match slide number (default is zero
// based)
hashOneBasedIndex: {hash_one_based_index},
// Add the current slide number to the URL hash so that reloading the
// page/copying the URL will return you to the same slide
hash: {hash},
// Flags if we should monitor the hash and change slides accordingly
respondToHashChanges: {respond_to_hash_changes},
// Push each slide change to the browser history. Implies `hash: true`
history: {history},
// Enable keyboard shortcuts for navigation
keyboard: {keyboard},
// Optional function that blocks keyboard events when retuning false
//
// If you set this to 'focused', we will only capture keyboard events
// for embedded decks when they are in focus
keyboardCondition: {keyboard_condition},
// Disables the default reveal.js slide layout (scaling and centering)
// so that you can use custom CSS layout
disableLayout: {disable_layout},
// Enable the slide overview mode
overview: {overview},
// Vertical centering of slides
center: {center},
// Enables touch navigation on devices with touch input
touch: {touch},
// Loop the presentation
loop: {loop},
// Change the presentation direction to be RTL
rtl: {rtl},
// Changes the behavior of our navigation directions.
//
// "default"
// Left/right arrow keys step between horizontal slides, up/down
// arrow keys step between vertical slides. Space key steps through
// all slides (both horizontal and vertical).
//
// "linear"
// Removes the up/down arrows. Left/right arrows step through all
// slides (both horizontal and vertical).
//
// "grid"
// When this is enabled, stepping left/right from a vertical stack
// to an adjacent vertical stack will land you at the same vertical
// index.
//
// Consider a deck with six slides ordered in two vertical stacks:
// 1.1 2.1
// 1.2 2.2
// 1.3 2.3
//
// If you're on slide 1.3 and navigate right, you will normally move
// from 1.3 -> 2.1. If "grid" is used, the same navigation takes you
// from 1.3 -> 2.3.
navigationMode: {navigation_mode},
// Randomizes the order of slides each time the presentation loads
shuffle: {shuffle},
// Turns fragments on and off globally
fragments: {fragments},
// Flags whether to include the current fragment in the URL,
// so that reloading brings you to the same fragment position
fragmentInURL: {fragment_in_url},
// Flags if the presentation is running in an embedded mode,
// i.e. contained within a limited portion of the screen
embedded: {embedded},
// Flags if we should show a help overlay when the question-mark
// key is pressed
help: {help},
// Flags if it should be possible to pause the presentation (blackout)
pause: {pause},
// Flags if speaker notes should be visible to all viewers
showNotes: {show_notes},
// Global override for autolaying embedded media (video/audio/iframe)
// - null: Media will only autoplay if data-autoplay is present
// - true: All media will autoplay, regardless of individual setting
// - false: No media will autoplay, regardless of individual setting
autoPlayMedia: {auto_play_media},
// Global override for preloading lazy-loaded iframes
// - null: Iframes with data-src AND data-preload will be loaded when within
// the viewDistance, iframes with only data-src will be loaded when visible
// - true: All iframes with data-src will be loaded when within the viewDistance
// - false: All iframes with data-src will be loaded only when visible
preloadIframes: {preload_iframes},
// Can be used to globally disable auto-animation
autoAnimate: {auto_animate},
// Optionally provide a custom element matcher that will be
// used to dictate which elements we can animate between.
autoAnimateMatcher: {auto_animate_matcher},
// Default settings for our auto-animate transitions, can be
// overridden per-slide or per-element via data arguments
autoAnimateEasing: {auto_animate_easing},
autoAnimateDuration: {auto_animate_duration},
autoAnimateUnmatched: {auto_animate_unmatched},
// CSS properties that can be auto-animated. Position & scale
// is matched separately so there's no need to include styles
// like top/right/bottom/left, width/height or margin.
autoAnimateStyles: {auto_animate_styles},
// Controls automatic progression to the next slide
// - 0: Auto-sliding only happens if the data-autoslide HTML attribute
// is present on the current slide or fragment
// - 1+: All slides will progress automatically at the given interval
// - false: No auto-sliding, even if data-autoslide is present
autoSlide: {auto_slide},
// Stop auto-sliding after user input
autoSlideStoppable: {auto_slide_stoppable},
// Use this method for navigation when auto-sliding (defaults to navigateNext)
autoSlideMethod: {auto_slide_method},
// Specify the average time in seconds that you think you will spend
// presenting each slide. This is used to show a pacing timer in the
// speaker view
defaultTiming: {default_timing},
// Enable slide navigation via mouse wheel
mouseWheel: {mouse_wheel},
// Opens links in an iframe preview overlay
// Add `data-preview-link` and `data-preview-link="false"` to customise each link
// individually
previewLinks: {preview_links},
// Exposes the reveal.js API through window.postMessage
postMessage: {post_message},
// Dispatches all reveal.js events to the parent window through postMessage
postMessageEvents: {post_message_events},
// Focuses body when page changes visibility to ensure keyboard shortcuts work
focusBodyOnPageVisibilityChange: {focus_body_on_page_visibility_change},
// Transition style
transition: {transition}, // none/fade/slide/convex/concave/zoom
// Transition speed
transitionSpeed: {transition_speed}, // default/fast/slow
// Transition style for full page slide backgrounds
backgroundTransition: {background_transition}, // none/fade/slide/convex/concave/zoom
// The maximum number of pages a single slide can expand onto when printing
// to PDF, unlimited by default
pdfMaxPagesPerSlide: {pdf_max_pages_per_slide},
// Prints each fragment on a separate slide
pdfSeparateFragments: {pdf_separate_fragments},
// Offset used to reduce the height of content within exported PDF pages.
// This exists to account for environment differences based on how you
// print to PDF. CLI printing options, like phantomjs and wkpdf, can end
// on precisely the total height of the document whereas in-browser
// printing has to end one pixel before.
pdfPageHeightOffset: {pdf_page_height_offset},
// Number of slides away from the current that are visible
viewDistance: {view_distance},
// Number of slides away from the current that are visible on mobile
// devices. It is advisable to set this to a lower number than
// viewDistance in order to save resources.
mobileViewDistance: {mobile_view_distance},
// The display mode that will be used to show slides
display: {display},
// Hide cursor if inactive
hideInactiveCursor: {hide_inactive_cursor},
// Time before the cursor is hidden (in ms)
hideCursorTime: {hide_cursor_time}
}});
</script>
</body>
</html>

View File

@ -1,6 +1,6 @@
# type: ignore
r"""
A directive for including Manim slides in a Sphinx document
A directive for including Manim Slides in a Sphinx document
===========================================================
.. warning::
@ -16,6 +16,17 @@ A directive for including Manim slides in a Sphinx document
When rendering the HTML documentation, the ``.. manim-slides::``
directive implemented here allows to include rendered videos.
This directive requires three additional dependencies:
``manim``, ``docutils`` and ``jinja2``. The last two are usually bundled
with Sphinx.
You can install them manually, or with the extra keyword:
pip install manim-slides[sphinx-directive]
Note that you will still need to install Manim's platform-specific dependencies,
see
`their installation page <https://docs.manim.community/en/stable/installation.html>`_.
Usage
-----
@ -59,12 +70,25 @@ render scenes that are defined within doctests, for example::
... def construct(self):
... self.play(Create(dot))
A third application is to render scenes from another specific file::
.. manim-slides:: file.py:FileExample
:hide_source:
:quality: high
.. warning::
The code will be executed with the current working directory
being the same as the one containing the source file. This being said,
you should probably not include examples that rely on external files, since
relative paths risk to be broken.
Options
-------
Options can be passed as follows::
.. manim-slides:: <Class name>
.. manim-slides:: <file>:<Class name>
:<option name>: <value>
The following configuration options are supported by the
@ -90,7 +114,7 @@ directive:
A list of methods, separated by spaces,
that is rendered in a reference block after the source code.
"""
""" # noqa: D400, D415
from __future__ import annotations
import csv
@ -113,8 +137,9 @@ classnamedict = {}
class SkipManimNode(nodes.Admonition, nodes.Element):
"""Auxiliary node class that is used when the ``skip-manim-slides`` tag is
present or ``.pot`` files are being built.
"""
Auxiliary node class that is used when the ``skip-manim-slides`` tag is present or
``.pot`` files are being built.
Skips rendering the manim-slides directive and outputs a placeholder instead.
"""
@ -133,8 +158,9 @@ def depart(self, node):
def process_name_list(option_input: str, reference_type: str) -> list[str]:
r"""Reformats a string of space separated class names
as a list of strings containing valid Sphinx references.
r"""
Reformats a string of space separated class names as a list of strings containing
valid Sphinx references.
Tests
-----
@ -150,15 +176,16 @@ def process_name_list(option_input: str, reference_type: str) -> list[str]:
class ManimSlidesDirective(Directive):
r"""The manim-slides directive, rendering videos while building
the documentation.
r"""
The manim-slides directive, rendering videos while building the documentation.
See the module docstring for documentation.
"""
has_content = True
required_arguments = 1
optional_arguments = 0
option_spec = {
option_spec = { # noqa: RUF012
"hide_source": bool,
"quality": lambda arg: directives.choice(
arg,
@ -171,7 +198,7 @@ class ManimSlidesDirective(Directive):
}
final_argument_whitespace = True
def run(self):
def run(self): # noqa: C901
# Rendering is skipped if the tag skip-manim is present,
# or if we are making the pot-files
should_skip = (
@ -200,7 +227,17 @@ class ManimSlidesDirective(Directive):
global classnamedict
clsname = self.arguments[0]
def split_file_cls(arg: str) -> tuple[Path, str]:
if ":" in arg:
file, cls = arg.split(":", maxsplit=1)
_, file = self.state.document.settings.env.relfn2path(file)
return Path(file), cls
else:
return None, arg
arguments = [split_file_cls(arg) for arg in self.arguments]
clsname = arguments[0][1]
if clsname not in classnamedict:
classnamedict[clsname] = 1
else:
@ -260,20 +297,24 @@ class ManimSlidesDirective(Directive):
"output_file": output_file,
}
user_code = self.content
if file := arguments[0][0]:
user_code = file.absolute().read_text().splitlines()
else:
user_code = self.content
if user_code[0].startswith(">>> "): # check whether block comes from doctest
user_code = [
line[4:] for line in user_code if line.startswith((">>> ", "... "))
]
code = [
"from manim import *",
*user_code,
f"{clsname}().render()",
]
try:
with tempconfig(example_config):
print(f"Rendering {clsname}...") # noqa: T201
run_time = timeit(lambda: exec("\n".join(code), globals()), number=1)
video_dir = config.get_dir("video_dir")
except Exception as e:
@ -295,9 +336,6 @@ class ManimSlidesDirective(Directive):
RevealJS(presentation_configs=presentation_configs, controls="true").convert_to(
destfile
)
# shutil.copyfile(filesrc, destfile)
print("CLASS NAME:", clsname)
rendered_template = jinja2.Template(TEMPLATE).render(
clsname=clsname,
@ -337,7 +375,7 @@ def _log_rendering_times(*args):
if len(data) == 0:
sys.exit()
print("\nRendering Summary\n-----------------\n")
print("\nRendering Summary\n-----------------\n") # noqa: T201
max_file_length = max(len(row[0]) for row in data)
for key, group in it.groupby(data, key=lambda row: row[0]):
@ -345,15 +383,17 @@ def _log_rendering_times(*args):
group = list(group)
if len(group) == 1:
row = group[0]
print(f"{key}{row[2].rjust(7, '.')}s {row[1]}")
print(f"{key}{row[2].rjust(7, '.')}s {row[1]}") # noqa: T201
continue
time_sum = sum(float(row[2]) for row in group)
print(
print( # noqa: T201
f"{key}{f'{time_sum:.3f}'.rjust(7, '.')}s => {len(group)} EXAMPLES",
)
for row in group:
print(f"{' '*(max_file_length)} {row[2].rjust(7)}s {row[1]}")
print("")
print( # noqa: T201
f"{' '*(max_file_length)} {row[2].rjust(7)}s {row[1]}"
)
print("") # noqa: T201
def _delete_rendering_times(*args):
@ -389,6 +429,7 @@ TEMPLATE = r"""
.. raw:: html
<!-- From: https://faq.dailymotion.com/hc/en-us/articles/360022841393-How-to-preserve-the-player-aspect-ratio-on-a-responsive-page -->
<div style="position:relative;padding-bottom:56.25%;">
<iframe

View File

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

View File

@ -1,5 +1,7 @@
"""
Logger utils, mostly copied from Manim Community:
Logger utils, mostly copied from Manim Community.
Source code:
https://github.com/ManimCommunity/manim/blob/d5b65b844b8ce8ff5151a2f56f9dc98cebbc1db4/manim/_config/logger_utils.py#L29-L101
"""
@ -7,9 +9,8 @@ import logging
from rich.console import Console
from rich.logging import RichHandler
from rich.theme import Theme
__all__ = ["logger", "make_logger"]
__all__ = ["logger"]
HIGHLIGHTED_KEYWORDS = [ # these keywords are highlighted specially
"Played",
@ -30,19 +31,19 @@ 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."""
RichHandler.KEYWORDS = HIGHLIGHTED_KEYWORDS
rich_handler = RichHandler(
show_time=True,
console=Console(theme=Theme({"logging.level.perf": "magenta"})),
console=Console(),
)
logging.addLevelName(5, "PERF")
logger = logging.getLogger("manim-slides")
logger.setLevel(logging.getLogger("manim").level)
logger.addHandler(rich_handler)
return logger
make_logger()
logger = logging.getLogger("manim-slides")

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -1,738 +0,0 @@
import platform
import shutil
import subprocess
from pathlib import Path
from typing import (
Any,
List,
Mapping,
MutableMapping,
Optional,
Sequence,
Tuple,
ValuesView,
)
from warnings import warn
import numpy as np
from tqdm import tqdm
from .config import PresentationConfig, SlideConfig, SlideType
from .defaults import FOLDER_PATH
from .manim import (
FFMPEG_BIN,
LEFT,
MANIMGL,
AnimationGroup,
FadeIn,
FadeOut,
Mobject,
Scene,
ThreeDScene,
config,
logger,
)
def reverse_video_file(src: Path, dst: Path) -> None:
"""Reverses a video file, writting the result to `dst`."""
command = [str(FFMPEG_BIN), "-y", "-i", str(src), "-vf", "reverse", str(dst)]
logger.debug(" ".join(command))
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output, error = process.communicate()
if output:
logger.debug(output.decode())
if error:
logger.debug(error.decode())
class Slide(Scene): # type:ignore
"""
Inherits from :class:`Scene<manim.scene.scene.Scene>` and provide necessary tools for slides rendering.
"""
def __init__(
self, *args: Any, output_folder: Path = FOLDER_PATH, **kwargs: Any
) -> None:
if MANIMGL:
Path("videos").mkdir(exist_ok=True)
kwargs["file_writer_config"] = {
"break_into_partial_movies": True,
"output_directory": "",
"write_to_movie": True,
}
kwargs["preview"] = False
super().__init__(*args, **kwargs)
self.__output_folder: Path = output_folder
self.__slides: List[SlideConfig] = []
self.__current_slide = 1
self.__current_animation = 0
self.__loop_start_animation: Optional[int] = None
self.__pause_start_animation = 0
self.__canvas: MutableMapping[str, Mobject] = {}
self.__wait_time_between_slides = 0.0
@property
def __frame_height(self) -> float:
"""Returns the scene's frame height."""
if MANIMGL:
return self.frame_height # type: ignore
else:
return config["frame_height"] # type: ignore
@property
def __frame_width(self) -> float:
"""Returns the scene's frame width."""
if MANIMGL:
return self.frame_width # type: ignore
else:
return config["frame_width"] # type: ignore
@property
def __background_color(self) -> str:
"""Returns the scene's background color."""
if MANIMGL:
return self.camera_config["background_color"].hex # type: ignore
else:
return config["background_color"].hex # type: ignore
@property
def __resolution(self) -> Tuple[int, int]:
"""Returns the scene's resolution used during rendering."""
if MANIMGL:
return self.camera_config["pixel_width"], self.camera_config["pixel_height"]
else:
return config["pixel_width"], config["pixel_height"]
@property
def __partial_movie_files(self) -> List[Path]:
"""Returns a list of partial movie files, a.k.a animations."""
if MANIMGL:
from manimlib.utils.file_ops import get_sorted_integer_files
kwargs = {
"remove_non_integer_files": True,
"extension": self.file_writer.movie_file_extension,
}
files = get_sorted_integer_files(
self.file_writer.partial_movie_directory, **kwargs
)
else:
files = self.renderer.file_writer.partial_movie_files
return [Path(file) for file in files]
@property
def __show_progress_bar(self) -> bool:
"""Returns True if progress bar should be displayed."""
if MANIMGL:
return getattr(self, "show_progress_bar", True)
else:
return config["progress_bar"] != "none" # type: ignore
@property
def __leave_progress_bar(self) -> bool:
"""Returns True if progress bar should be left after completed."""
if MANIMGL:
return getattr(self, "leave_progress_bars", False)
else:
return config["progress_bar"] == "leave" # type: ignore
@property
def __start_at_animation_number(self) -> Optional[int]:
if MANIMGL:
return getattr(self, "start_at_animation_number", None)
else:
return config["from_animation_number"] # type: ignore
@property
def canvas(self) -> MutableMapping[str, Mobject]:
"""
Returns the canvas associated to the current slide.
The canvas is a mapping between names and Mobjects,
for objects that are assumed to stay in multiple slides.
For example, a section title or a slide number.
Examples
--------
.. manim-slides:: CanvasExample
from manim import *
from manim_slides import Slide
class CanvasExample(Slide):
def update_canvas(self):
self.counter += 1
old_slide_number = self.canvas["slide_number"]
new_slide_number = Text(f"{self.counter}").move_to(old_slide_number)
self.play(Transform(old_slide_number, new_slide_number))
def construct(self):
title = Text("My Title").to_corner(UL)
self.counter = 1
slide_number = Text("1").to_corner(DL)
self.add_to_canvas(title=title, slide_number=slide_number)
self.play(FadeIn(title), FadeIn(slide_number))
self.next_slide()
circle = Circle(radius=2)
dot = Dot()
self.update_canvas()
self.play(Create(circle))
self.play(MoveAlongPath(dot, circle))
self.next_slide()
self.update_canvas()
square = Square()
self.play(self.wipe(self.mobjects_without_canvas, square))
self.next_slide()
self.update_canvas()
self.play(
Transform(
self.canvas["title"],
Text("New Title").to_corner(UL)
)
)
self.next_slide()
self.remove_from_canvas("title", "slide_number")
self.play(self.wipe(self.mobjects_without_canvas, []))
"""
return self.__canvas
def add_to_canvas(self, **objects: Mobject) -> Mobject:
"""
Adds objects to the canvas, using key values as names.
:param objects: A mapping between names and Mobjects.
.. note::
This method does not actually do anything in terms of
animations. You must still call :code:`self.add` or
play some animation that introduces each Mobject for
it to appear. The same applies when removing objects.
"""
self.__canvas.update(objects)
def remove_from_canvas(self, *names: str) -> None:
"""
Removes objects from the canvas.
"""
for name in names:
self.__canvas.pop(name)
@property
def canvas_mobjects(self) -> ValuesView[Mobject]:
"""
Returns Mobjects contained in the canvas.
"""
return self.canvas.values()
@property
def mobjects_without_canvas(self) -> Sequence[Mobject]:
"""
Returns the list of objects contained in the scene,
minus those present in the canvas.
"""
return [
mobject for mobject in self.mobjects if mobject not in self.canvas_mobjects
]
@property
def wait_time_between_slides(self) -> float:
r"""
Returns the wait duration (in seconds) added between two slides.
By default, this value is set to 0.
Setting this value to something bigger than 0 will result in a
:code:`self.wait` animation called at the end of every slide.
.. note::
This is useful because animations are usually only terminated
when a new animation is played. You can observe the small difference
in the examples below: the circle is not fully complete in the first
slide of the first example, but well in the second example.
Examples
--------
.. manim-slides:: WithoutWaitExample
from manim import *
from manim_slides import Slide
class WithoutWaitExample(Slide):
def construct(self):
circle = Circle(radius=2)
arrow = Arrow().next_to(circle, RIGHT).scale(-1)
text = Text("Small\ngap").next_to(arrow, RIGHT)
self.play(Create(arrow), FadeIn(text))
self.play(Create(circle))
self.next_slide()
self.play(FadeOut(circle))
.. manim-slides:: WithWaitExample
from manim import *
from manim_slides import Slide
class WithWaitExample(Slide):
def construct(self):
self.wait_time_between_slides = 0.1 # A small value > 1 / FPS
circle = Circle(radius=2)
arrow = Arrow().next_to(circle, RIGHT).scale(-1)
text = Text("No more\ngap").next_to(arrow, RIGHT)
self.play(Create(arrow), FadeIn(text))
self.play(Create(circle))
self.next_slide()
self.play(FadeOut(circle))
"""
return self.__wait_time_between_slides
@wait_time_between_slides.setter
def wait_time_between_slides(self, wait_time: float) -> None:
self.__wait_time_between_slides = max(wait_time, 0.0)
def play(self, *args: Any, **kwargs: Any) -> None:
"""Overloads `self.play` and increment animation count."""
super().play(*args, **kwargs)
self.__current_animation += 1
def next_slide(self) -> None:
"""
Creates a new slide with previous animations.
This usually means that the user will need to press some key before the
next slide is played. By default, this is the right arrow key.
.. note::
Calls to :func:`next_slide` at the very beginning or at the end are
not needed, since they are automatically added.
.. warning::
This is not allowed to call :func:`next_slide` inside a loop.
Examples
--------
The following contains 3 slides:
#. the first with nothing on it;
#. the second with "Hello World!" fading in;
#. and the last with the text fading out;
.. manim-slides:: NextSlideExample
from manim import *
from manim_slides import Slide
class NextSlideExample(Slide):
def construct(self):
text = Text("Hello World!")
self.play(FadeIn(text))
self.next_slide()
self.play(FadeOut(text))
"""
assert (
self.__loop_start_animation is None
), "You cannot call `self.next_slide()` inside a loop"
if self.wait_time_between_slides > 0.0:
self.wait(self.wait_time_between_slides)
self.__slides.append(
SlideConfig(
type=SlideType.slide,
start_animation=self.__pause_start_animation,
end_animation=self.__current_animation,
number=self.__current_slide,
)
)
self.__current_slide += 1
self.__pause_start_animation = self.__current_animation
def pause(self) -> None:
"""
Creates a new slide with previous animations.
.. deprecated:: 4.10.0
Use :func:`next_slide` instead.
"""
warn(
"`self.pause()` is deprecated. Use `self.next_slide()` instead.",
DeprecationWarning,
stacklevel=2,
)
Slide.next_slide(self)
def __add_last_slide(self) -> None:
"""Adds a 'last' slide to the end of slides."""
if (
len(self.__slides) > 0
and self.__current_animation == self.__slides[-1].end_animation
):
self.__slides[-1].type = SlideType.last
return
self.__slides.append(
SlideConfig(
type=SlideType.last,
start_animation=self.__pause_start_animation,
end_animation=self.__current_animation,
number=self.__current_slide,
)
)
def start_loop(self) -> None:
"""
Starts a loop. End it with :func:`end_loop`.
A loop will automatically replay the slide, i.e., everything between
:func:`start_loop` and :func:`end_loop`, upon reaching end.
.. warning::
When rendered with RevealJS, loops cannot be in the first nor
the last slide.
Examples
--------
The following contains one slide that will loop endlessly.
.. manim-slides:: LoopExample
from manim import *
from manim_slides import Slide
class LoopExample(Slide):
def construct(self):
dot = Dot(color=BLUE, radius=1)
self.play(FadeIn(dot))
self.next_slide()
self.start_loop()
self.play(Indicate(dot, scale_factor=2))
self.end_loop()
self.play(FadeOut(dot))
"""
assert self.__loop_start_animation is None, "You cannot nest loops"
self.__loop_start_animation = self.__current_animation
def end_loop(self) -> None:
"""Ends an existing loop. See :func:`start_loop` for more details."""
assert (
self.__loop_start_animation is not None
), "You have to start a loop before ending it"
self.__slides.append(
SlideConfig(
type=SlideType.loop,
start_animation=self.__loop_start_animation,
end_animation=self.__current_animation,
number=self.__current_slide,
)
)
self.__current_slide += 1
self.__loop_start_animation = None
self.__pause_start_animation = self.__current_animation
def __save_slides(self, use_cache: bool = True) -> None:
"""
Saves slides, optionally using cached files.
Note that cached files only work with Manim.
"""
self.__add_last_slide()
files_folder = self.__output_folder / "files"
scene_name = str(self)
scene_files_folder = files_folder / scene_name
scene_files_folder.mkdir(parents=True, exist_ok=True)
files = []
for src_file in tqdm(
self.__partial_movie_files,
desc=f"Copying animation files to '{scene_files_folder}' and generating reversed animations",
leave=self.__leave_progress_bar,
ascii=True if platform.system() == "Windows" else None,
disable=not self.__show_progress_bar,
):
if src_file is None and not MANIMGL:
# This happens if rendering with -na,b (manim only)
# where animations not in [a,b] will be skipped
# but animations before a will have a None src_file
continue
dst_file = scene_files_folder / src_file.name
rev_file = scene_files_folder / f"{src_file.stem}_reversed{src_file.suffix}"
# We only copy animation if it was not present
if not use_cache or not dst_file.exists():
shutil.copyfile(src_file, dst_file)
# We only reverse video if it was not present
if not use_cache or not rev_file.exists():
reverse_video_file(src_file, rev_file)
files.append(dst_file)
if offset := self.__start_at_animation_number:
self.__slides = [
slide for slide in self.__slides if slide.end_animation > offset
]
for slide in self.__slides:
slide.start_animation -= offset
slide.end_animation -= offset
logger.info(
f"Copied {len(files)} animations to '{scene_files_folder.absolute()}' and generated reversed animations"
)
slide_path = self.__output_folder / f"{scene_name}.json"
PresentationConfig(
slides=self.__slides,
files=files,
resolution=self.__resolution,
background_color=self.__background_color,
).to_file(slide_path)
logger.info(
f"Slide '{scene_name}' configuration written in '{slide_path.absolute()}'"
)
def run(self, *args: Any, **kwargs: Any) -> None:
"""MANIMGL renderer"""
super().run(*args, **kwargs)
self.__save_slides(use_cache=False)
def render(self, *args: Any, **kwargs: Any) -> None:
"""MANIM render"""
# 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")
super().render(*args, **kwargs)
config["max_files_cached"] = max_files_cached
self.__save_slides()
def wipe(
self,
current: Sequence[Mobject] = [],
future: Sequence[Mobject] = [],
direction: np.ndarray = LEFT,
fade_in_kwargs: Mapping[str, Any] = {},
fade_out_kwargs: Mapping[str, Any] = {},
**kwargs: Any,
) -> AnimationGroup:
"""
Returns a wipe animation that will shift all the current objects outside
of the current scene's scope, and all the future objects inside.
:param current: A sequence of mobjects to remove from the scene.
:param future: A sequence of mobjects to add to the scene.
:param direction: The wipe direction.
:param fade_in_kwargs: Keyword arguments passed to
:class:`FadeIn<manim.animation.fading.FadeIn>`.
:param fade_out_kwargs: Keyword arguments passed to
:class:`FadeOut<manim.animation.fading.FadeOut>`.
:param kwargs: Keyword arguments passed to
:class:`AnimationGroup<manim.animation.composition.AnimationGroup>`.
Examples
--------
.. manim-slides:: WipeExample
from manim import *
from manim_slides import Slide
class WipeExample(Slide):
def construct(self):
circle = Circle(radius=3, color=BLUE)
square = Square()
text = Text("This is a wipe example").next_to(square, DOWN)
beautiful = Text("Beautiful, no?")
self.play(FadeIn(circle))
self.next_slide()
self.play(self.wipe(circle, Group(square, text)))
self.next_slide()
self.play(self.wipe(Group(square, text), beautiful, direction=UP))
self.next_slide()
self.play(self.wipe(beautiful, circle, direction=DOWN + RIGHT))
"""
shift_amount = np.asarray(direction) * np.array(
[self.__frame_width, self.__frame_height, 0.0]
)
animations = []
for mobject in future:
animations.append(FadeIn(mobject, shift=shift_amount, **fade_in_kwargs))
for mobject in current:
animations.append(FadeOut(mobject, shift=shift_amount, **fade_out_kwargs))
return AnimationGroup(*animations, **kwargs)
def zoom(
self,
current: Sequence[Mobject] = [],
future: Sequence[Mobject] = [],
scale: float = 4.0,
out: bool = False,
fade_in_kwargs: Mapping[str, Any] = {},
fade_out_kwargs: Mapping[str, Any] = {},
**kwargs: Any,
) -> AnimationGroup:
"""
Returns a zoom animation that will fade out all the current objects,
and fade in all the future objects. Objects are faded in a direction
that goes towards the camera.
:param current: A sequence of mobjects to remove from the scene.
:param future: A sequence of mobjects to add to the scene.
:param scale: How much the objects are scaled (up or down).
:param out: If set, the objects fade in the opposite direction.
:param fade_in_kwargs: Keyword arguments passed to
:class:`FadeIn<manim.animation.fading.FadeIn>`.
:param fade_out_kwargs: Keyword arguments passed to
:class:`FadeOut<manim.animation.fading.FadeOut>`.
:param kwargs: Keyword arguments passed to
:class:`AnimationGroup<manim.animation.composition.AnimationGroup>`.
Examples
--------
.. manim-slides:: ZoomExample
from manim import *
from manim_slides import Slide
class ZoomExample(Slide):
def construct(self):
circle = Circle(radius=3, color=BLUE)
square = Square()
self.play(FadeIn(circle))
self.next_slide()
self.play(self.zoom(circle, square))
self.next_slide()
self.play(self.zoom(square, circle, out=True, scale=10.))
"""
scale_in = 1.0 / scale
scale_out = scale
if out:
scale_in, scale_out = scale_out, scale_in
animations = []
for mobject in future:
animations.append(FadeIn(mobject, scale=scale_in, **fade_in_kwargs))
for mobject in current:
animations.append(FadeOut(mobject, scale=scale_out, **fade_out_kwargs))
return AnimationGroup(*animations, **kwargs)
class ThreeDSlide(Slide, ThreeDScene): # type: ignore
"""
Inherits from :class:`Slide` and :class:`ThreeDScene<manim.scene.three_d_scene.ThreeDScene>` and provide necessary tools for slides rendering.
.. note:: ManimGL does not need ThreeDScene for 3D rendering in recent versions, see `example.py`.
Examples
--------
.. manim-slides:: ThreeDExample
from manim import *
from manim_slides import ThreeDSlide
class ThreeDExample(ThreeDSlide):
def construct(self):
title = Text("A 2D Text")
self.play(FadeIn(title))
self.next_slide()
sphere = Sphere([0, 0, -3])
self.move_camera(phi=PI/3, theta=-PI/4, distance=7)
self.play(
GrowFromCenter(sphere),
Transform(title, Text("A 3D Text"))
)
self.next_slide()
bye = Text("Bye!")
self.start_loop()
self.play(
self.wipe(
self.mobjects_without_canvas,
[bye],
direction=UP
)
)
self.wait(.5)
self.play(
self.wipe(
self.mobjects_without_canvas,
[title, sphere],
direction=DOWN
)
)
self.wait(.5)
self.end_loop()
self.play(*[FadeOut(mobject) for mobject in self.mobjects])
"""
pass

View File

@ -0,0 +1,63 @@
__all__ = [
"MANIM",
"MANIMGL",
"API_NAME",
"Slide",
"ThreeDSlide",
]
import os
import sys
class ManimApiNotFoundError(ImportError):
"""Error raised if specified manim API could be imported."""
_msg = "Could not import the specified manim API"
def __init__(self) -> None:
super().__init__(self._msg)
API_NAMES = {
"manim": "manim",
"manimce": "manim",
"manimlib": "manimlib",
"manimgl": "manimlib",
}
MANIM_API: str = "MANIM_API"
FORCE_MANIM_API: str = "FORCE_" + MANIM_API
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}",
)
API_NAME = API_NAMES[API]
if not os.environ.get(FORCE_MANIM_API):
if "manim" in sys.modules:
API_NAME = "manim"
elif "manimlib" in sys.modules:
API_NAME = "manimlib"
MANIM: bool = API_NAME == "manim"
MANIMGL: bool = API_NAME == "manimlib"
if MANIM:
try:
from .manim import Slide, ThreeDSlide
except ImportError as e:
raise ManimApiNotFoundError from e
elif MANIMGL:
try:
from .manimlib import Slide, ThreeDSlide
except ImportError as e:
raise ManimApiNotFoundError from e
else:
raise ManimApiNotFoundError

View File

@ -0,0 +1,159 @@
"""
Additional animations for Manim objects.
Like with Manim, animations are classes that must be put inside a
:meth:`Scene.play<manim.scene.scene.Scene.play>` call.
For each of the provided classes, there exists a method variant
that directly calls ``self.play(Animation(...))``, see
:class:`Slide<manim_slides.slide.Slide>`.
"""
__all__ = ["Wipe", "Zoom"]
from typing import Any, Mapping, Optional, Sequence
import numpy as np
from . import MANIM
if MANIM:
from manim import LEFT, AnimationGroup, FadeIn, FadeOut
from manim.mobject.mobject import Mobject
else:
from manimlib import LEFT, AnimationGroup, FadeIn, FadeOut
Mobject = Any
class Wipe(AnimationGroup): # type: ignore[misc]
"""
Creates a wipe animation that will shift all the current objects and future objects
by a given value.
:param current: A sequence of mobjects to remove from the scene.
:param future: A sequence of mobjects to add to the scene.
:param shift: The shift vector, used for both fading in and out.
:param fade_in_kwargs: Keyword arguments passed to
:class:`FadeIn<manim.animation.fading.FadeIn>`.
:param fade_out_kwargs: Keyword arguments passed to
:class:`FadeOut<manim.animation.fading.FadeOut>`.
:param kwargs: Keyword arguments passed to
:class:`AnimationGroup<manim.animation.composition.AnimationGroup>`.
Examples
--------
.. manim-slides:: WipeClassExample
from manim import *
from manim_slides import Slide
from manim_slides.slide.animation import Wipe
class WipeClassExample(Slide):
def construct(self):
circle = Circle(radius=3, color=BLUE)
square = Square()
self.play(FadeIn(circle))
self.next_slide()
self.play(Wipe(circle, square, shift=3 * LEFT))
"""
def __init__(
self,
current: Optional[Sequence[Mobject]] = None,
future: Optional[Sequence[Mobject]] = None,
shift: np.ndarray = LEFT,
fade_in_kwargs: Optional[Mapping[str, Any]] = None,
fade_out_kwargs: Optional[Mapping[str, Any]] = None,
**kwargs: Any,
):
animations = []
if future:
if fade_in_kwargs is None:
fade_in_kwargs = {}
for mobject in future:
animations.append(FadeIn(mobject, shift=shift, **fade_in_kwargs))
if current:
if fade_out_kwargs is None:
fade_out_kwargs = {}
for mobject in current:
animations.append(FadeOut(mobject, shift=shift, **fade_out_kwargs))
super().__init__(*animations, **kwargs)
class Zoom(AnimationGroup): # type: ignore[misc]
"""
Creates a zoom animation that will fade out all the current objects, and fade in all
the future objects. Objects are faded in a direction that goes towards the camera.
:param current: A sequence of mobjects to remove from the scene.
:param future: A sequence of mobjects to add to the scene.
:param scale: How much the objects are scaled (up or down).
:param out: If set, the objects fade in the opposite direction.
:param fade_in_kwargs: Keyword arguments passed to
:class:`FadeIn<manim.animation.fading.FadeIn>`.
:param fade_out_kwargs: Keyword arguments passed to
:class:`FadeOut<manim.animation.fading.FadeOut>`.
:param kwargs: Keyword arguments passed to
:class:`AnimationGroup<manim.animation.composition.AnimationGroup>`.
Examples
--------
.. manim-slides:: ZoomClassExample
from manim import *
from manim_slides import Slide
from manim_slides.slide.animation import Zoom
class ZoomClassExample(Slide):
def construct(self):
circles = [Circle(radius=i) for i in range(1, 4)]
self.play(FadeIn(circles[0]))
self.next_slide()
for i in range(2):
self.play(Zoom(circles[i], circles[i+1]))
self.next_slide()
"""
def __init__(
self,
current: Optional[Sequence[Mobject]] = None,
future: Optional[Sequence[Mobject]] = None,
scale: float = 4.0,
out: bool = False,
fade_in_kwargs: Optional[Mapping[str, Any]] = None,
fade_out_kwargs: Optional[Mapping[str, Any]] = None,
**kwargs: Any,
) -> None:
scale_in = 1.0 / scale
scale_out = scale
if out:
scale_in, scale_out = scale_out, scale_in
animations = []
if future:
if fade_in_kwargs is None:
fade_in_kwargs = {}
for mobject in future:
animations.append(FadeIn(mobject, scale=scale_in, **fade_in_kwargs))
if current:
if fade_out_kwargs is None:
fade_out_kwargs = {}
for mobject in current:
animations.append(FadeOut(mobject, scale=scale_out, **fade_out_kwargs))
super().__init__(*animations, **kwargs)

530
manim_slides/slide/base.py Normal file
View File

@ -0,0 +1,530 @@
__all__ = ["BaseSlide"]
import platform
from abc import abstractmethod
from pathlib import Path
from typing import Any, List, MutableMapping, Optional, Sequence, Tuple, ValuesView
import numpy as np
from tqdm import tqdm
from ..config import PresentationConfig, PreSlideConfig, SlideConfig
from ..defaults import FFMPEG_BIN, FOLDER_PATH
from ..logger import logger
from ..utils import concatenate_video_files, merge_basenames, reverse_video_file
from . import MANIM
if MANIM:
from manim.mobject.mobject import Mobject
else:
Mobject = Any
LEFT: np.ndarray = np.array([-1.0, 0.0, 0.0])
class BaseSlide:
def __init__(
self, *args: Any, output_folder: Path = FOLDER_PATH, **kwargs: Any
) -> None:
super().__init__(*args, **kwargs)
self._output_folder: Path = output_folder
self._slides: List[PreSlideConfig] = []
self._pre_slide_config_kwargs: MutableMapping[str, Any] = {}
self._current_slide = 1
self._current_animation = 0
self._start_animation = 0
self._canvas: MutableMapping[str, Mobject] = {}
self._wait_time_between_slides = 0.0
@property
def _ffmpeg_bin(self) -> Path:
"""Return the path to the ffmpeg binaries."""
return FFMPEG_BIN
@property
@abstractmethod
def _frame_height(self) -> float:
"""Return the scene's frame height."""
...
@property
@abstractmethod
def _frame_width(self) -> float:
"""Return the scene's frame width."""
...
@property
@abstractmethod
def _background_color(self) -> str:
"""Return the scene's background color."""
...
@property
@abstractmethod
def _resolution(self) -> Tuple[int, int]:
"""Return the scene's resolution used during rendering."""
...
@property
@abstractmethod
def _partial_movie_files(self) -> List[Path]:
"""Return a list of partial movie files, a.k.a animations."""
...
@property
@abstractmethod
def _show_progress_bar(self) -> bool:
"""Return True if progress bar should be displayed."""
...
@property
@abstractmethod
def _leave_progress_bar(self) -> bool:
"""Return True if progress bar should be left after completed."""
...
@property
@abstractmethod
def _start_at_animation_number(self) -> Optional[int]:
"""If set, return the animation number at which rendering start."""
...
@property
def canvas(self) -> MutableMapping[str, Mobject]:
"""
Return the canvas associated to the current slide.
The canvas is a mapping between names and Mobjects,
for objects that are assumed to stay in multiple slides.
For example, a section title or a slide number.
Examples
--------
.. manim-slides:: CanvasExample
from manim import *
from manim_slides import Slide
class CanvasExample(Slide):
def update_canvas(self):
self.counter += 1
old_slide_number = self.canvas["slide_number"]
new_slide_number = Text(f"{self.counter}").move_to(old_slide_number)
self.play(Transform(old_slide_number, new_slide_number))
def construct(self):
title = Text("My Title").to_corner(UL)
self.counter = 1
slide_number = Text("1").to_corner(DL)
self.add_to_canvas(title=title, slide_number=slide_number)
self.play(FadeIn(title), FadeIn(slide_number))
self.next_slide()
circle = Circle(radius=2)
dot = Dot()
self.update_canvas()
self.play(Create(circle))
self.play(MoveAlongPath(dot, circle))
self.next_slide()
self.update_canvas()
square = Square()
self.wipe(self.mobjects_without_canvas, square)
self.next_slide()
self.update_canvas()
self.play(
Transform(
self.canvas["title"],
Text("New Title").to_corner(UL)
)
)
self.next_slide()
self.remove_from_canvas("title", "slide_number")
self.wipe(self.mobjects_without_canvas, [])
"""
return self._canvas
def add_to_canvas(self, **objects: Mobject) -> None:
"""
Add objects to the canvas, using key values as names.
:param objects: A mapping between names and Mobjects.
.. note::
This method does not actually do anything in terms of
animations. You must still call :code:`self.add` or
play some animation that introduces each Mobject for
it to appear. The same applies when removing objects.
"""
self._canvas.update(objects)
def remove_from_canvas(self, *names: str) -> None:
"""Remove objects from the canvas."""
for name in names:
self._canvas.pop(name)
@property
def canvas_mobjects(self) -> ValuesView[Mobject]:
"""Return Mobjects contained in the canvas."""
return self.canvas.values()
@property
def mobjects_without_canvas(self) -> Sequence[Mobject]:
"""
Return the list of objects contained in the scene, minus those present in
the canvas.
"""
return [
mobject for mobject in self.mobjects if mobject not in self.canvas_mobjects # type: ignore[attr-defined]
]
@property
def wait_time_between_slides(self) -> float:
r"""
Return the wait duration (in seconds) added between two slides.
By default, this value is set to 0.
Setting this value to something bigger than 0 will result in a
:code:`self.wait` animation called at the end of every slide.
.. note::
This is useful because animations are usually only terminated
when a new animation is played. You can observe the small difference
in the examples below: the circle is not fully complete in the first
slide of the first example, but well in the second example.
Examples
--------
.. manim-slides:: WithoutWaitExample
from manim import *
from manim_slides import Slide
class WithoutWaitExample(Slide):
def construct(self):
circle = Circle(radius=2)
arrow = Arrow().next_to(circle, RIGHT).scale(-1)
text = Text("Small\ngap").next_to(arrow, RIGHT)
self.play(Create(arrow), FadeIn(text))
self.play(Create(circle))
self.next_slide()
self.play(FadeOut(circle))
.. manim-slides:: WithWaitExample
from manim import *
from manim_slides import Slide
class WithWaitExample(Slide):
def construct(self):
self.wait_time_between_slides = 0.1 # A small value > 1 / FPS
circle = Circle(radius=2)
arrow = Arrow().next_to(circle, RIGHT).scale(-1)
text = Text("No more\ngap").next_to(arrow, RIGHT)
self.play(Create(arrow), FadeIn(text))
self.play(Create(circle))
self.next_slide()
self.play(FadeOut(circle))
"""
return self._wait_time_between_slides
@wait_time_between_slides.setter
def wait_time_between_slides(self, wait_time: float) -> None:
self._wait_time_between_slides = max(wait_time, 0.0)
def play(self, *args: Any, **kwargs: Any) -> None:
"""Overload `self.play` and increment animation count."""
super().play(*args, **kwargs) # type: ignore[misc]
self._current_animation += 1
def next_slide(self, *, loop: bool = False, **kwargs: Any) -> None:
"""
Create a new slide with previous animations, and setup options
for the next slide.
This usually means that the user will need to press some key before the
next slide is played. By default, this is the right arrow key.
:param args:
Positional arguments to be passed to
:meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`,
or ignored if `manimlib` API is used.
:param loop:
If set, next slide will be looping.
:param kwargs:
Keyword arguments to be passed to
:meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`,
or ignored if `manimlib` API is used.
.. note::
Calls to :func:`next_slide` at the very beginning or at the end are
not needed, since they are automatically added.
.. warning::
When rendered with RevealJS, loops cannot be in the first nor
the last slide.
.. seealso::
When using ``manim`` API, this method will also call
:meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`.
Examples
--------
The following contains 3 slides:
#. the first with nothing on it;
#. the second with "Hello World!" fading in;
#. and the last with the text fading out;
.. manim-slides:: NextSlideExample
from manim import *
from manim_slides import Slide
class NextSlideExample(Slide):
def construct(self):
text = Text("Hello World!")
self.play(FadeIn(text))
self.next_slide()
self.play(FadeOut(text))
The following contains one slide that will loop endlessly.
.. manim-slides:: LoopExample
from manim import *
from manim_slides import Slide
class LoopExample(Slide):
def construct(self):
dot = Dot(color=BLUE, radius=1)
self.play(FadeIn(dot))
self.next_slide(loop=True)
self.play(Indicate(dot, scale_factor=2))
self.next_slide()
self.play(FadeOut(dot))
"""
if self._current_animation > self._start_animation:
if self.wait_time_between_slides > 0.0:
self.wait(self.wait_time_between_slides) # type: ignore[attr-defined]
self._slides.append(
PreSlideConfig(
start_animation=self._start_animation,
end_animation=self._current_animation,
**self._pre_slide_config_kwargs,
)
)
self._pre_slide_config_kwargs = dict(loop=loop)
self._current_slide += 1
self._start_animation = self._current_animation
def _add_last_slide(self) -> None:
"""Add a 'last' slide to the end of slides."""
if (
len(self._slides) > 0
and self._current_animation == self._slides[-1].end_animation
):
return
self._slides.append(
PreSlideConfig(
start_animation=self._start_animation,
end_animation=self._current_animation,
**self._pre_slide_config_kwargs,
)
)
def _save_slides(self, use_cache: bool = True) -> None:
"""
Save slides, optionally using cached files.
Note that cached files only work with Manim.
"""
self._add_last_slide()
files_folder = self._output_folder / "files"
scene_name = str(self)
scene_files_folder = files_folder / scene_name
scene_files_folder.mkdir(parents=True, exist_ok=True)
files: List[Path] = self._partial_movie_files
# We must filter slides that end before the animation offset
if offset := self._start_at_animation_number:
self._slides = [
slide for slide in self._slides if slide.end_animation > offset
]
for slide in self._slides:
slide.start_animation = max(0, slide.start_animation - offset)
slide.end_animation -= offset
slides: List[SlideConfig] = []
for pre_slide_config in tqdm(
self._slides,
desc=f"Concatenating animation files to '{scene_files_folder}' and generating reversed animations",
leave=self._leave_progress_bar,
ascii=True if platform.system() == "Windows" else None,
disable=not self._show_progress_bar,
):
slide_files = files[pre_slide_config.slides_slice]
file = merge_basenames(slide_files)
dst_file = scene_files_folder / file.name
rev_file = scene_files_folder / f"{file.stem}_reversed{file.suffix}"
# We only concat animations if it was not present
if not use_cache or not dst_file.exists():
concatenate_video_files(self._ffmpeg_bin, slide_files, dst_file)
# We only reverse video if it was not present
if not use_cache or not rev_file.exists():
reverse_video_file(self._ffmpeg_bin, dst_file, rev_file)
slides.append(
SlideConfig.from_pre_slide_config_and_files(
pre_slide_config, dst_file, rev_file
)
)
logger.info(
f"Generated {len(slides)} slides to '{scene_files_folder.absolute()}'"
)
slide_path = self._output_folder / f"{scene_name}.json"
PresentationConfig(
slides=slides,
resolution=self._resolution,
background_color=self._background_color,
).to_file(slide_path)
logger.info(
f"Slide '{scene_name}' configuration written in '{slide_path.absolute()}'"
)
def wipe(
self,
*args: Any,
direction: np.ndarray = LEFT,
**kwargs: Any,
) -> None:
"""
Play a wipe animation that will shift all the current objects outside of the
current scene's scope, and all the future objects inside.
:param args: Positional arguments passed to
:class:`Wipe<manim_slides.slide.animation.Wipe>`.
:param direction: The wipe direction, that will be scaled by the scene size.
:param kwargs: Keyword arguments passed to
:class:`Wipe<manim_slides.slide.animation.Wipe>`.
Examples
--------
.. manim-slides:: WipeExample
from manim import *
from manim_slides import Slide
class WipeExample(Slide):
def construct(self):
circle = Circle(radius=3, color=BLUE)
square = Square()
text = Text("This is a wipe example").next_to(square, DOWN)
beautiful = Text("Beautiful, no?")
self.play(FadeIn(circle))
self.next_slide()
self.wipe(circle, Group(square, text))
self.next_slide()
self.wipe(Group(square, text), beautiful, direction=UP)
self.next_slide()
self.wipe(beautiful, circle, direction=DOWN + RIGHT)
"""
from .animation import Wipe
shift_amount = np.asarray(direction) * np.array(
[self._frame_width, self._frame_height, 0.0]
)
kwargs.setdefault("shift", shift_amount)
animation = Wipe(
*args,
**kwargs,
)
self.play(animation)
def zoom(
self,
*args: Any,
**kwargs: Any,
) -> None:
"""
Play a zoom animation that will fade out all the current objects, and fade in
all the future objects. Objects are faded in a direction that goes towards the
camera.
:param args: Positional arguments passed to
:class:`Zoom<manim_slides.slide.animation.Zoom>`.
:param kwargs: Keyword arguments passed to
:class:`Zoom<manim_slides.slide.animation.Zoom>`.
Examples
--------
.. manim-slides:: ZoomExample
from manim import *
from manim_slides import Slide
class ZoomExample(Slide):
def construct(self):
circle = Circle(radius=3, color=BLUE)
square = Square()
self.play(FadeIn(circle))
self.next_slide()
self.zoom(circle, square)
self.next_slide()
self.zoom(square, circle, out=True, scale=10.0)
"""
from .animation import Zoom
animation = Zoom(*args, **kwargs)
self.play(animation)

148
manim_slides/slide/manim.py Normal file
View File

@ -0,0 +1,148 @@
from pathlib import Path
from typing import Any, List, Optional, Tuple
from manim import Scene, ThreeDScene, config
from .base import BaseSlide
class Slide(BaseSlide, Scene): # type: ignore[misc]
"""
Inherits from :class:`Scene<manim.scene.scene.Scene>` and provide necessary tools
for slides rendering.
"""
@property
def _ffmpeg_bin(self) -> Path:
# Prior to v0.16.0.post0,
# ffmpeg was stored as a constant in manim.constants
try:
return Path(config.ffmpeg_executable)
except AttributeError:
return super()._ffmpeg_bin
@property
def _frame_height(self) -> float:
return config["frame_height"] # type: ignore
@property
def _frame_width(self) -> float:
return config["frame_width"] # type: ignore
@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
@property
def _resolution(self) -> Tuple[int, int]:
return config["pixel_width"], config["pixel_height"]
@property
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.
return [
Path(file)
for file in self.renderer.file_writer.partial_movie_files
if file is not None
]
@property
def _show_progress_bar(self) -> bool:
return config["progress_bar"] != "none" # type: ignore
@property
def _leave_progress_bar(self) -> bool:
return config["progress_bar"] == "leave" # type: ignore
@property
def _start_at_animation_number(self) -> Optional[int]:
return config["from_animation_number"] # type: ignore
def next_section(self, *args: Any, **kwargs: Any) -> None:
"""
Alias to :meth:`next_slide`.
:param args:
Positional arguments to be passed to :meth:`next_slide`.
:param kwargs:
Keyword arguments to be passed to :meth:`next_slide`.
.. attention::
This method is only available when using ``manim`` API.
"""
self.next_slide(*args, **kwargs)
def next_slide(self, *args: Any, loop: bool = False, **kwargs: Any) -> None:
Scene.next_section(self, *args, **kwargs)
BaseSlide.next_slide(self, loop=loop)
def render(self, *args: Any, **kwargs: Any) -> None:
"""MANIM render."""
# 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")
super().render(*args, **kwargs)
config["max_files_cached"] = max_files_cached
self._save_slides()
class ThreeDSlide(Slide, ThreeDScene): # type: ignore[misc]
"""
Inherits from :class:`Slide` and
:class:`ThreeDScene<manim.scene.three_d_scene.ThreeDScene>` and provide necessary
tools for slides rendering.
Examples
--------
.. manim-slides:: ThreeDExample
from manim import *
from manim_slides import ThreeDSlide
class ThreeDExample(ThreeDSlide):
def construct(self):
title = Text("A 2D Text")
self.play(FadeIn(title))
self.next_slide()
sphere = Sphere([0, 0, -3])
self.move_camera(phi=PI/3, theta=-PI/4, distance=7)
self.play(
GrowFromCenter(sphere),
Transform(title, Text("A 3D Text"))
)
self.next_slide()
bye = Text("Bye!")
self.next_slide(loop=True)
self.wipe(
self.mobjects_without_canvas,
[bye],
direction=UP
)
self.wait(.5)
self.wipe(
self.mobjects_without_canvas,
[title, sphere],
direction=DOWN
)
self.wait(.5)
self.next_slide()
self.play(*[FadeOut(mobject) for mobject in self.mobjects])
"""
pass

View File

@ -0,0 +1,72 @@
from pathlib import Path
from typing import Any, ClassVar, Dict, List, Optional, Tuple
from manimlib import Scene, ThreeDCamera
from manimlib.utils.file_ops import get_sorted_integer_files
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,
)
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
@property
def _frame_width(self) -> float:
return self.camera.frame.get_width() # type: ignore
@property
def _background_color(self) -> str:
return self.camera_config["background_color"].hex # type: ignore
@property
def _resolution(self) -> Tuple[int, int]:
return self.camera_config["pixel_width"], self.camera_config["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
)
]
@property
def _show_progress_bar(self) -> bool:
return True
@property
def _leave_progress_bar(self) -> bool:
return self.leave_progress_bars # type: ignore
@property
def _start_at_animation_number(self) -> Optional[int]:
return self.start_at_animation_number # type: ignore
def run(self, *args: Any, **kwargs: Any) -> None:
"""MANIMGL renderer."""
super().run(*args, **kwargs)
self._save_slides(use_cache=False)
class ThreeDSlide(Slide):
CONFIG: ClassVar[Dict[str, Any]] = {
"camera_class": ThreeDCamera,
}
pass

View File

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

77
manim_slides/utils.py Normal file
View File

@ -0,0 +1,77 @@
import hashlib
import subprocess
import tempfile
from pathlib import Path
from typing import List
from .logger import logger
def concatenate_video_files(ffmpeg_bin: Path, files: List[Path], dest: Path) -> None:
"""Concatenate multiple video files into one."""
f = tempfile.NamedTemporaryFile(mode="w", delete=False)
f.writelines(f"file '{path.absolute()}'\n" for path in files)
f.close()
command: List[str] = [
str(ffmpeg_bin),
"-f",
"concat",
"-safe",
"0",
"-i",
f.name,
"-c",
"copy",
str(dest),
"-y",
]
logger.debug(" ".join(command))
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output, error = process.communicate()
if output:
logger.debug(output.decode())
if error:
logger.debug(error.decode())
if not dest.exists():
raise ValueError(
"could not properly concatenate files, use `-v DEBUG` for more details"
)
def merge_basenames(files: List[Path]) -> Path:
"""Merge multiple filenames by concatenating basenames."""
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 reverse_video_file(ffmpeg_bin: Path, src: Path, dst: Path) -> None:
"""Reverses a video file, writting the result to `dst`."""
command = [str(ffmpeg_bin), "-y", "-i", str(src), "-vf", "reverse", str(dst)]
logger.debug(" ".join(command))
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output, error = process.communicate()
if output:
logger.debug(output.decode())
if error:
logger.debug(error.decode())

View File

@ -22,7 +22,7 @@ from .commons import config_options, verbosity_option
from .config import Config, Key
from .defaults import CONFIG_PATH
from .logger import logger
from .resources import * # noqa: F401, F403
from .resources import * # noqa: F403
WINDOW_NAME: str = "Configuration Wizard"
@ -43,7 +43,7 @@ class KeyInput(QDialog): # type: ignore
self.layout.addWidget(self.label)
self.setLayout(self.layout)
def keyPressEvent(self, event: QKeyEvent) -> None:
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
self.key = event.key()
self.deleteLater()
event.accept()
@ -58,11 +58,11 @@ class Wizard(QWidget): # type: ignore
self.icon = QIcon(":/icon.png")
self.setWindowIcon(self.icon)
QBtn = QDialogButtonBox.Save | QDialogButtonBox.Cancel
button = QDialogButtonBox.Save | QDialogButtonBox.Cancel
self.buttonBox = QDialogButtonBox(QBtn)
self.buttonBox.accepted.connect(self.saveConfig)
self.buttonBox.rejected.connect(self.closeWithoutSaving)
self.buttonBox = QDialogButtonBox(button)
self.buttonBox.accepted.connect(self.save_config)
self.buttonBox.rejected.connect(self.close_without_saving)
self.buttons = []
@ -72,7 +72,7 @@ class Wizard(QWidget): # type: ignore
# Create label for key name information
label = QLabel()
key_info = value["name"] or key
label.setText(key_info)
label.setText(key_info.title())
self.layout.addWidget(label, i, 0)
# Create button that will pop-up a dialog and ask to input a new key
@ -83,7 +83,7 @@ class Wizard(QWidget): # type: ignore
)
self.buttons.append(button)
button.clicked.connect(
partial(self.openDialog, i, getattr(self.config.keys, key))
partial(self.open_dialog, i, getattr(self.config.keys, key))
)
self.layout.addWidget(button, i, 1)
@ -91,16 +91,16 @@ class Wizard(QWidget): # type: ignore
self.setLayout(self.layout)
def closeWithoutSaving(self) -> None:
def close_without_saving(self) -> None:
logger.debug("Closing configuration wizard without saving")
self.deleteLater()
sys.exit(0)
def closeEvent(self, event: Any) -> None:
def closeEvent(self, event: Any) -> None: # noqa: N802
self.closeWithoutSaving()
event.accept()
def saveConfig(self) -> None:
def save_config(self) -> None:
try:
Config.model_validate(self.config.dict())
except ValueError:
@ -116,7 +116,7 @@ class Wizard(QWidget): # type: ignore
self.deleteLater()
def openDialog(self, button_number: int, key: Key) -> None:
def open_dialog(self, button_number: int, key: Key) -> None:
button = self.buttons[button_number]
dialog = KeyInput()
dialog.exec_()
@ -149,8 +149,10 @@ def init(
def _init(
config_path: Path, force: bool, merge: bool, skip_interactive: bool = False
) -> None:
"""Actual initialization code for configuration file, with optional interactive mode."""
"""
Actual initialization code for configuration file, with optional interactive
mode.
"""
if config_path.exists():
click.secho(f"The `{CONFIG_PATH}` configuration file exists")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

After

Width:  |  Height:  |  Size: 158 KiB

View File

@ -137,7 +137,7 @@ page.
## Comparison with manim-presentation
Starting from [@manim-presentation]'s original work, Manim Slides now provides
Starting from @manim-presentation's original work, Manim Slides now provides
numerous additional features.
A non-exhaustive list of those new features is as follows:
@ -162,7 +162,7 @@ with the appropriate template.
# Acknowledgements
We acknowledge the work of [@manim-presentation] that paved the initial structure
We acknowledge the work of @manim-presentation that paved the initial structure
of Manim Slides with the manim-presentation Python package.
We also acknowledge Grant Sanderson for his tremendous work on Manim, as

5252
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -5,10 +5,6 @@ requires = ["setuptools", "poetry-core>=1.0.0"]
[tool.black]
target-version = ["py38"]
[tool.isort]
profile = "black"
py_version = 38
[tool.mypy]
disallow_untyped_decorators = false
install_types = true
@ -43,11 +39,14 @@ packages = [
]
readme = "README.md"
repository = "https://github.com/jeertmans/manim-slides"
version = "4.15.0"
version = "5.0.0-rc3"
[tool.poetry.dependencies]
click = "^8.1.3"
click-default-group = "^1.2.2"
docutils = {version = "^0.20.1", optional = true}
ipython = {version = ">=8.12.2", optional = true}
jinja2 = "^3.1.2"
lxml = "^4.9.2"
manim = {version = "^0.17.3", optional = true}
manimgl = {version = "^1.6.1", optional = true}
@ -56,7 +55,7 @@ opencv-python = "^4.6.0.66"
pillow = "^9.5.0"
pydantic = "^2.0.1"
pydantic-extra-types = "^2.0.0"
pyside6 = "^6.5.1.1"
pyside6 = "6.5.2"
python = ">=3.8.1,<3.12"
python-pptx = "^0.6.21"
requests = "^2.28.1"
@ -65,8 +64,13 @@ rtoml = "^0.9.0"
tqdm = "^4.64.1"
[tool.poetry.extras]
magic = ["manim", "ipython"]
manim = ["manim"]
manimgl = ["manimgl"]
sphinx-directive = ["docutils", "manim"]
[tool.poetry.group.dev]
optional = true
[tool.poetry.group.dev.dependencies]
black = "^22.10.0"
@ -76,20 +80,31 @@ mypy = "^0.991"
pre-commit = "^3.0.2"
ruff = "^0.0.219"
[tool.poetry.group.docs]
optional = true
[tool.poetry.group.docs.dependencies]
furo = "^2023.5.20"
ipykernel = "^6.25.1"
manim = "^0.17.3"
myst-parser = "^2.0.0"
nbsphinx = "^0.9.2"
pandoc = "^2.3"
sphinx = "^7.0.1"
sphinx-click = "^4.4.0"
sphinx-copybutton = "^0.5.1"
sphinxext-opengraph = "^0.7.5"
[tool.poetry.group.test]
optional = true
[tool.poetry.group.test.dependencies]
manim = "^0.17.3"
manimgl = "^1.6.1"
pytest = "^7.4.0"
pytest-cov = "^4.1.0"
pytest-env = "^0.8.2"
pytest-xdist = "^3.3.1"
[tool.poetry.plugins]
@ -97,13 +112,31 @@ pytest-cov = "^4.1.0"
manim-slides = "manim_slides.__main__:cli"
[tool.pytest.ini_options]
env = [
"QT_QPA_PLATFORM=offscreen"
]
filterwarnings = [
"error",
"ignore::DeprecationWarning"
]
[tool.ruff]
ignore = [
extend-exclude = ["manim_slides/resources.py"]
extend-ignore = [
"D100",
"D101",
"D102",
"D103",
"D104",
"D105",
"D106",
"D107",
"D203",
"D205",
"D212",
"E501"
]
extend-select = ["B", "C90", "D", "I", "N", "RUF", "UP", "T"]
isort = {known-first-party = ['manim_slides', 'tests']}
line-length = 88
target-version = "py38"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 670 KiB

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

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

40
tests/data/slides.py Normal file
View File

@ -0,0 +1,40 @@
# flake8: noqa: F403, F405
# type: ignore
from manim_slides import Slide
from manim_slides.slide import MANIM, MANIMGL
if MANIM:
from manim import *
elif MANIMGL:
from manimlib import *
class BasicSlide(Slide):
def construct(self):
text = Text("This is some text")
self.play(Write(text))
circle = Circle(radius=3, color=BLUE)
self.play(Transform(text, circle))
circle = text # this is to avoid name confusion
square = Square()
self.play(FadeIn(square))
self.next_slide(loop=True)
self.play(Rotate(square, +PI / 2))
self.play(Rotate(square, -PI / 2))
self.next_slide()
other_text = Text("Other text")
self.wipe([square, circle], [other_text])
self.next_slide()
self.zoom(other_text, [])

View File

@ -0,0 +1,29 @@
{
"slides": [
{
"file": "slides/files/BasicSlide/28bf32c4df2711b07b765a647667059683133b3c45291f34692be0c845f75511.mp4",
"rev_file": "slides/files/BasicSlide/28bf32c4df2711b07b765a647667059683133b3c45291f34692be0c845f75511_reversed.mp4",
"loop": false
},
{
"file": "slides/files/BasicSlide/c7d0d9ccbf764d32bf316451f2d00607b8f12893e64afe215041a8aedceeb368.mp4",
"rev_file": "slides/files/BasicSlide/c7d0d9ccbf764d32bf316451f2d00607b8f12893e64afe215041a8aedceeb368_reversed.mp4",
"loop": true
},
{
"file": "slides/files/BasicSlide/5060f74bee3cb2e40a399a023e0120b3f91d348a9867c7f401db54ea337de97c.mp4",
"rev_file": "slides/files/BasicSlide/5060f74bee3cb2e40a399a023e0120b3f91d348a9867c7f401db54ea337de97c_reversed.mp4",
"loop": false
},
{
"file": "slides/files/BasicSlide/7a5de547a0b5de2230ff3451dd680425cf0a7ea065b31e8f92b5e93527694077.mp4",
"rev_file": "slides/files/BasicSlide/7a5de547a0b5de2230ff3451dd680425cf0a7ea065b31e8f92b5e93527694077_reversed.mp4",
"loop": false
}
],
"resolution": [
854,
480
],
"background_color": "black"
}

View File

@ -1,32 +0,0 @@
{
"slides": [
{
"type": "slide",
"start_animation": 0,
"end_animation": 1,
"number": 1
},
{
"type": "loop",
"start_animation": 1,
"end_animation": 2,
"number": 2
},
{
"type": "last",
"start_animation": 2,
"end_animation": 3,
"number": 3
}
],
"files": [
"slides/files/BasicExample/1413466013_3346521118_223132457.mp4",
"slides/files/BasicExample/1672018281_3136302242_2191168284.mp4",
"slides/files/BasicExample/1672018281_1369283980_3942561600.mp4"
],
"resolution": [
1920,
1080
],
"background_color": "black"
}

View File

@ -95,7 +95,7 @@ def test_folder_path_option() -> None:
@pytest.mark.parametrize(
("verbosity",),
[("PeRF",), ("DEBUG",), ("info",), ("waRNING",), ("eRRor",), ("CrItIcAl",)],
[("DEBUG",), ("info",), ("waRNING",), ("eRRor",), ("CrItIcAl",)],
)
def test_valid_verbosity_option(verbosity: str) -> None:
@click.command()

View File

@ -1,80 +1,9 @@
import random
import string
import tempfile
from pathlib import Path
from typing import Any, Generator, List
from typing import Any
import pytest
from pydantic import ValidationError
from manim_slides.config import (
Key,
PresentationConfig,
SlideConfig,
SlideType,
merge_basenames,
)
def random_path(
length: int = 20,
dirname: Path = Path("./media/videos/example"),
suffix: str = ".mp4",
touch: bool = False,
) -> Path:
basename = "".join(random.choices(string.ascii_letters, k=length))
filepath = dirname.joinpath(basename + suffix)
if touch:
filepath.touch()
return filepath
@pytest.fixture
def paths() -> Generator[List[Path], None, None]:
random.seed(1234)
yield [random_path() for _ in range(20)]
@pytest.fixture
def presentation_config(paths: List[Path]) -> Generator[PresentationConfig, None, None]:
dirname = Path(tempfile.mkdtemp())
files = [random_path(dirname=dirname, touch=True) for _ in range(10)]
slides = [
SlideConfig(
type=SlideType.slide,
start_animation=0,
end_animation=5,
number=1,
),
SlideConfig(
type=SlideType.loop,
start_animation=5,
end_animation=6,
number=2,
),
SlideConfig(
type=SlideType.last,
start_animation=6,
end_animation=10,
number=3,
),
]
yield PresentationConfig(
slides=slides,
files=files,
)
def test_merge_basenames(paths: List[Path]) -> None:
path = merge_basenames(paths)
assert path.suffix == paths[0].suffix
assert path.parent == paths[0].parent
from manim_slides.config import Key, PresentationConfig
class TestKey:

View File

@ -1,6 +1,129 @@
import pytest
from enum import EnumMeta
from pathlib import Path
from manim_slides.convert import PDF, Converter, PowerPoint, RevealJS
import pytest
from pydantic import ValidationError
from manim_slides.config import PresentationConfig
from manim_slides.convert import (
PDF,
AutoAnimateEasing,
AutoAnimateMatcher,
AutoPlayMedia,
AutoSlideMethod,
BackgroundSize,
BackgroundTransition,
ControlsBackArrows,
ControlsLayout,
Converter,
Display,
JsBool,
JsFalse,
JsNull,
JsTrue,
KeyboardCondition,
NavigationMode,
PowerPoint,
PreloadIframes,
RevealJS,
RevealTheme,
ShowSlideNumber,
SlideNumber,
Transition,
TransitionSpeed,
)
@pytest.mark.parametrize(
("enum_type",),
[
(JsTrue,),
(JsFalse,),
(JsBool,),
(JsNull,),
(ControlsLayout,),
(ControlsBackArrows,),
(SlideNumber,),
(ShowSlideNumber,),
(KeyboardCondition,),
(NavigationMode,),
(AutoPlayMedia,),
(PreloadIframes,),
(AutoAnimateMatcher,),
(AutoAnimateEasing,),
(AutoSlideMethod,),
(Transition,),
(TransitionSpeed,),
(BackgroundSize,),
(BackgroundTransition,),
(Display,),
(RevealTheme,),
],
)
def test_format_enum(enum_type: EnumMeta) -> None:
for enum in enum_type: # type: ignore[var-annotated]
expected = str(enum)
got = f"{enum}"
assert expected == got
got = "{enum}".format(enum=enum) # noqa: UP032
assert expected == got
got = format(enum, "")
assert expected == got
@pytest.mark.parametrize(
("enum_type",),
[
(ControlsLayout,),
(ControlsBackArrows,),
(SlideNumber,),
(ShowSlideNumber,),
(KeyboardCondition,),
(NavigationMode,),
(AutoPlayMedia,),
(PreloadIframes,),
(AutoAnimateMatcher,),
(AutoAnimateEasing,),
(AutoSlideMethod,),
(Transition,),
(TransitionSpeed,),
(BackgroundSize,),
(BackgroundTransition,),
(Display,),
],
)
def test_quoted_enum(enum_type: EnumMeta) -> None:
for enum in enum_type: # type: ignore[var-annotated]
if enum in ["true", "false", "null"]:
continue
expected = "'" + enum.value + "'"
got = str(enum)
assert expected == got
@pytest.mark.parametrize(
("enum_type",),
[
(JsTrue,),
(JsFalse,),
(JsBool,),
(JsNull,),
(RevealTheme,),
],
)
def test_unquoted_enum(enum_type: EnumMeta) -> None:
for enum in enum_type: # type: ignore[var-annotated]
expected = enum.value
got = str(enum)
assert expected == got
class TestConverter:
@ -9,3 +132,31 @@ class TestConverter:
)
def test_from_string(self, name: str, converter: type) -> None:
assert Converter.from_string(name) == converter
def test_revealjs_converter(
self, tmp_path: Path, presentation_config: PresentationConfig
) -> None:
out_file = tmp_path / "slides.html"
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()
assert "manim" in file_contents.casefold()
def test_pdf_converter(
self, tmp_path: Path, presentation_config: PresentationConfig
) -> None:
out_file = tmp_path / "slides.pdf"
PDF(presentation_configs=[presentation_config]).convert_to(out_file)
assert out_file.exists()
def test_converter_no_presentation_config(self) -> None:
with pytest.raises(ValidationError):
Converter(presentation_configs=[])
def test_pptx_converter(
self, tmp_path: Path, presentation_config: PresentationConfig
) -> None:
out_file = tmp_path / "slides.pptx"
PowerPoint(presentation_configs=[presentation_config]).convert_to(out_file)
assert out_file.exists()

View File

@ -1,5 +1,6 @@
from pathlib import Path
import pytest
from click.testing import CliRunner
from manim_slides.__main__ import cli
@ -16,29 +17,30 @@ def test_help() -> None:
assert results.exit_code == 0
def test_defaults_to_present(folder_path: Path) -> None:
def test_defaults_to_present(slides_folder: Path) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
results = runner.invoke(
cli, ["BasicExample", "--folder", str(folder_path), "-s"]
cli, ["BasicSlide", "--folder", str(slides_folder), "-s"]
)
assert results.exit_code == 0
def test_present(folder_path: Path) -> None:
def test_present(slides_folder: Path) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
results = runner.invoke(
cli, ["present", "BasicExample", "--folder", str(folder_path), "-s"]
cli, ["present", "BasicSlide", "--folder", str(slides_folder), "-s"]
)
assert results.exit_code == 0
def test_convert(folder_path: Path) -> None:
@pytest.mark.parametrize(("extension",), [("html",), ("pdf",), ("pptx",)])
def test_convert(slides_folder: Path, extension: str) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
@ -46,10 +48,12 @@ def test_convert(folder_path: Path) -> None:
cli,
[
"convert",
"BasicExample",
"basic_example.html",
"BasicSlide",
f"basic_example.{extension}",
"--folder",
str(folder_path),
str(slides_folder),
"--to",
extension,
],
)
@ -71,7 +75,7 @@ def test_init() -> None:
assert results.exit_code == 0
def test_list_scenes(folder_path: Path) -> None:
def test_list_scenes(slides_folder: Path) -> None:
runner = CliRunner()
with runner.isolated_filesystem():
@ -80,12 +84,12 @@ def test_list_scenes(folder_path: Path) -> None:
[
"list-scenes",
"--folder",
str(folder_path),
str(slides_folder),
],
)
assert results.exit_code == 0
assert "BasicExample" in results.output
assert "BasicSlide" in results.output
def test_wizard() -> None:

View File

@ -1,62 +1,55 @@
import importlib
import os
import sys
from contextlib import contextmanager
from importlib.abc import MetaPathFinder
from importlib.machinery import ModuleSpec
from types import ModuleType
from typing import Iterator, Optional, Sequence
import pytest
import manim_slides.manim as msm
@contextmanager
def suppress_module_finder() -> Iterator[None]:
meta_path = sys.meta_path
try:
class PathFinder(MetaPathFinder):
@classmethod
def find_spec(
cls,
fullname: str,
path: Optional[Sequence[str]],
target: Optional[ModuleType] = None,
) -> Optional[ModuleSpec]:
if fullname in ["manim", "manimlib"]:
return None
for finder in meta_path:
spec = finder.find_spec(fullname, path, target=target)
if spec is not None:
return spec
return None
sys.meta_path = [PathFinder]
yield
finally:
sys.meta_path = meta_path
import manim_slides.slide as slide
def assert_import(
*,
api_name: str,
manim: bool,
manim_available: bool,
manim_imported: bool,
manimgl: bool,
manimgl_available: bool,
manimgl_imported: bool,
) -> None:
importlib.reload(msm)
importlib.reload(slide)
assert msm.MANIM == manim
assert msm.MANIM_AVAILABLE == manim_available
assert msm.MANIM_IMPORTED == manim_imported
assert msm.MANIMGL == manimgl
assert msm.MANIMGL_AVAILABLE == manim_available
assert msm.MANIMGL_IMPORTED == manimgl_imported
assert slide.API_NAME == api_name
assert slide.MANIM == manim
assert slide.MANIMGL == manimgl
def test_force_api() -> None:
import manim # noqa: F401
if "manimlib" in sys.modules:
del sys.modules["manimlib"]
os.environ[slide.MANIM_API] = "manimlib"
os.environ[slide.FORCE_MANIM_API] = "1"
assert_import(
api_name="manimlib",
manim=False,
manimgl=True,
)
del os.environ[slide.MANIM_API]
del os.environ[slide.FORCE_MANIM_API]
def test_invalid_api() -> None:
os.environ[slide.MANIM_API] = "manim_slides"
with pytest.raises(ImportError):
assert_import(
api_name="",
manim=False,
manimgl=False,
)
del os.environ[slide.MANIM_API]
@pytest.mark.filterwarnings("ignore:assert_import")
@ -65,12 +58,9 @@ def test_manim_and_manimgl_imported() -> None:
import manimlib # noqa: F401
assert_import(
api_name="manim",
manim=True,
manim_available=True,
manim_imported=True,
manimgl=False,
manimgl_available=True,
manimgl_imported=True,
)
@ -81,12 +71,9 @@ def test_manim_imported() -> None:
del sys.modules["manimlib"]
assert_import(
api_name="manim",
manim=True,
manim_available=True,
manim_imported=True,
manimgl=False,
manimgl_available=True,
manimgl_imported=False,
)
@ -97,12 +84,9 @@ def test_manimgl_imported() -> None:
del sys.modules["manim"]
assert_import(
api_name="manimlib",
manim=False,
manim_available=True,
manim_imported=False,
manimgl=True,
manimgl_available=True,
manimgl_imported=True,
)
@ -114,30 +98,7 @@ def test_nothing_imported() -> None:
del sys.modules["manimlib"]
assert_import(
api_name="manim",
manim=True,
manim_available=True,
manim_imported=False,
manimgl=False,
manimgl_available=True,
manimgl_imported=False,
)
def test_no_package_available() -> None:
with suppress_module_finder():
if "manim" in sys.modules:
del sys.modules["manim"]
if "manimlib" in sys.modules:
del sys.modules["manimlib"]
with pytest.raises(ModuleNotFoundError):
# Actual values are not important
assert_import(
manim=False,
manim_available=False,
manim_imported=False,
manimgl=False,
manimgl_available=False,
manimgl_imported=False,
)

View File

@ -1,42 +1,171 @@
import random
import shutil
import subprocess
import sys
from pathlib import Path
import click
import pytest
from manim import Text
from pydantic import ValidationError
from click.testing import CliRunner
from manim import (
BLUE,
DOWN,
LEFT,
ORIGIN,
RIGHT,
UP,
Circle,
Dot,
FadeIn,
GrowFromCenter,
Text,
)
from manim.__main__ import main as manim_cli
from manim_slides.slide import Slide
from manim_slides.config import PresentationConfig
from manim_slides.defaults import FOLDER_PATH
from manim_slides.slide.manim import Slide
def assert_construct(cls: type) -> type:
@click.command(
context_settings=dict(
ignore_unknown_options=True,
allow_extra_args=True,
)
)
@click.pass_context
def manimgl_cli(ctx: click.Context) -> None:
subprocess.run([sys.executable, "-m", "manimlib", *ctx.args])
cli = pytest.mark.parametrize(
["cli"],
[
[manim_cli],
[manimgl_cli],
],
)
@cli
def test_render_basic_slide(
cli: click.Command,
slides_file: Path,
presentation_config: PresentationConfig,
manimgl_config: Path,
) -> None:
runner = CliRunner()
with runner.isolated_filesystem() as tmp_dir:
shutil.copy(manimgl_config, tmp_dir)
results = runner.invoke(cli, [str(slides_file), "BasicSlide", "-ql"])
assert results.exit_code == 0
local_slides_folder = (Path(tmp_dir) / "slides").resolve(strict=True)
local_config_file = (local_slides_folder / "BasicSlide.json").resolve(
strict=True
)
local_presentation_config = PresentationConfig.from_file(local_config_file)
assert len(local_presentation_config.slides) == len(presentation_config.slides)
assert (
local_presentation_config.background_color
== presentation_config.background_color
)
assert (
local_presentation_config.background_color
== presentation_config.background_color
)
assert local_presentation_config.resolution == presentation_config.resolution
def assert_constructs(cls: type) -> type:
class Wrapper:
@classmethod
def test_construct(_) -> None:
def test_construct(_) -> None: # noqa: N804
cls().construct()
return Wrapper
def assert_renders(cls: type) -> type:
class Wrapper:
@classmethod
def test_render(_) -> None: # noqa: N804
cls().render()
return Wrapper
class TestSlide:
@assert_construct
@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
@assert_renders
class TestMultipleAnimationsInLastSlide(Slide):
"""Check against solution for issue #161."""
def construct(self) -> None:
circle = Circle(color=BLUE)
dot = Dot()
self.play(GrowFromCenter(circle))
self.play(FadeIn(dot))
self.next_slide()
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))
@assert_renders
class TestFileTooLong(Slide):
"""Check against solution for issue #123."""
def construct(self) -> None:
circle = Circle(radius=3, color=BLUE)
dot = Dot()
self.play(GrowFromCenter(circle), run_time=0.1)
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)
self.start_loop()
assert "loop" not in self._pre_slide_config_kwargs
self.next_slide(loop=True)
self.play(text.animate.scale(2))
self.end_loop()
with pytest.raises(AssertionError):
self.end_loop()
assert self._pre_slide_config_kwargs["loop"]
self.start_loop()
with pytest.raises(AssertionError):
self.start_loop()
self.next_slide(loop=False)
with pytest.raises(ValidationError):
self.end_loop()
assert not self._pre_slide_config_kwargs["loop"]
@assert_construct
@assert_constructs
class TestWipe(Slide):
def construct(self) -> None:
text = Text("Some text")
@ -47,12 +176,12 @@ class TestSlide:
assert text in self.mobjects
assert bye not in self.mobjects
self.play(self.wipe([text], [bye]))
self.wipe([text], [bye])
assert text not in self.mobjects
assert bye in self.mobjects
@assert_construct
@assert_constructs
class TestZoom(Slide):
def construct(self) -> None:
text = Text("Some text")
@ -63,12 +192,12 @@ class TestSlide:
assert text in self.mobjects
assert bye not in self.mobjects
self.play(self.zoom([text], [bye]))
self.zoom([text], [bye])
assert text not in self.mobjects
assert bye in self.mobjects
@assert_construct
@assert_constructs
class TestCanvas(Slide):
def construct(self) -> None:
text = Text("Some text")

23
tests/test_utils.py Normal file
View File

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