Compare commits

..

109 Commits

Author SHA1 Message Date
e60edcc960 fix(ci): remove duplicate info 2024-01-18 23:34:07 +01:00
c1d2dc26b2 fix(ci): avoid dynamic if does not work 2024-01-18 23:33:00 +01:00
0db4a8c260 fix(ci): update 2024-01-18 23:24:49 +01:00
cb4f6f552c fix(docs): typos 2024-01-18 22:20:55 +01:00
9cd50e73b2 chore(docs): nicer placement 2024-01-18 22:15:04 +01:00
cea3d0b0c3 chore(version): bump 5.1.0-rc2 to 5.1.0-rc3 2024-01-18 22:07:43 +01:00
75126d8bab fix(docs): typo 2024-01-18 22:04:10 +01:00
be227bbdea chore(deps): use PDM instead of Poetry (#354)
* chore(deps): use PDM instead of Poetry

* chore(git): move to top

* chore(lint): fixes

* fix(pdm): dynamic version did not work

* chore(ci): fix syntax for Windows

* fix(ci): typo

* wip(docs): updates

* chore(docs): add changelog entry

* chore(docs): admonitions

* chore(docs): update to use manim-slides render

* chore(docs): make it better
2024-01-18 22:02:53 +01:00
62560ea41f chore(ci): create Docker image (#355)
* chore(ci): create Docker image

* fix(ci): run only if tag is pushed

* chore(docs): add changelog entry
2024-01-18 19:57:35 +01:00
4b8f90c1fa chore(LICENSE): update date 2024-01-18 19:04:45 +01:00
8b1c45b84e [pre-commit.ci] pre-commit autoupdate (#352)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.1.11 → v0.1.13](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.11...v0.1.13)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-01-18 12:02:58 +01:00
34cc66d8be chore(deps): bump actions/cache from 3 to 4 (#353)
Bumps [actions/cache](https://github.com/actions/cache) from 3 to 4.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-18 11:50:35 +01:00
57e11a0ea7 chore(deps): bump jinja2 from 3.1.2 to 3.1.3 (#349)
Bumps [jinja2](https://github.com/pallets/jinja) from 3.1.2 to 3.1.3.
- [Release notes](https://github.com/pallets/jinja/releases)
- [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/jinja/compare/3.1.2...3.1.3)

---
updated-dependencies:
- dependency-name: jinja2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-12 16:32:36 +01:00
de342b32bb [pre-commit.ci] pre-commit autoupdate (#347)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.1.9 → v0.1.11](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.9...v0.1.11)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-01-08 23:06:49 +01:00
6bd431d748 chore(lib): remove useless var 2024-01-04 13:06:56 +01:00
2e996c03a7 chore(deps): bump actions/deploy-pages from 3 to 4 (#342)
Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 3 to 4.
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/deploy-pages
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-04 13:06:13 +01:00
d9715ccb96 chore(deps): bump actions/upload-pages-artifact from 2 to 3 (#343)
Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 2 to 3.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/upload-pages-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-04 13:06:06 +01:00
e3b3dd677f chore(deps): bump github/codeql-action from 2 to 3 (#338)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 2 to 3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v2...v3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-04 13:05:59 +01:00
db91de1412 [pre-commit.ci] pre-commit autoupdate (#337)
* [pre-commit.ci] pre-commit autoupdate

updates:
- [github.com/macisamuele/language-formatters-pre-commit-hooks: v2.11.0 → v2.12.0](https://github.com/macisamuele/language-formatters-pre-commit-hooks/compare/v2.11.0...v2.12.0)
- [github.com/astral-sh/ruff-pre-commit: v0.1.6 → v0.1.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.6...v0.1.9)
- [github.com/pre-commit/mirrors-mypy: v1.7.1 → v1.8.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.7.1...v1.8.0)

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-01-04 13:05:43 +01:00
bf67a7b695 chore(deps): bump actions/upload-artifact from 3 to 4 (#339)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  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-12-15 11:48:24 +01:00
ea2d352fc1 chore(lib): do not include reversed files in HTML assets (#336)
* chore(lib): do not include reversed files in HTML assets

* [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-12-11 13:58:39 +01:00
74ddefe519 chore(deps): remove subprocess calls to FFMPEG with av (#335)
* chore(deps): remove subprocess calls to FFMPEG with `av`

Linked to the progess made in https://github.com/ManimCommunity/manim/pull/3501.

The following PR aims at reducing subprocess calls for security and speed reasons. Also, with https://github.com/ManimCommunity/manim/pull/3501 is merged, FFMPEG should not be needed anymore as it is part of `PyAv`.

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

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

* fix(lib): oops forgot to commit those changes

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

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

* chore(lib): clear assets and clean code

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

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

* chore(doc): document changes

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-12-11 13:53:58 +01:00
20b7ef4110 chore(deps): bump actions/deploy-pages from 2 to 3 (#329)
* chore(deps): bump actions/deploy-pages from 2 to 3

Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 2 to 3.
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/deploy-pages
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

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

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-12-11 13:37:07 +01:00
19a31a9136 chore(deps): bump actions/configure-pages from 3 to 4 (#330)
* chore(deps): bump actions/configure-pages from 3 to 4

Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 3 to 4.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/configure-pages
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

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

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-12-11 13:36:55 +01:00
d2925340aa chore(deps): bump actions/setup-python from 4 to 5 (#333)
* chore(deps): bump actions/setup-python from 4 to 5

Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

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

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-12-07 08:36:32 +01:00
afeaa0d793 chore(version): bump 5.1.0-rc1 to 5.1.0-rc2 2023-12-05 13:29:43 +01:00
af3c4971ae feat(lib): add return_animation option (#331)
* feat(lib): add `return_animation` option

Allow to return animation instead of playing it.

* fix(lib): docs issue

* fix(ci): build with Python 3.10
2023-12-05 13:29:16 +01:00
b3ed127e31 chore(repo): delete unwanted file 2023-11-28 15:56:52 +01:00
fc200f22f5 chore(version): bump 5.0.0 to 5.1.0-rc1
chore(version): bump 5.0.0-rc1 to 5.1.0

chore(version): bump 5.1.0 to 5.1.0-rc1
2023-11-28 15:21:03 +01:00
050ee0ae78 feat(lib): enhance notes support (#324)
* feat(lib): enhance notes support

TODO: fix keyboard inputs and window order with `present`.

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

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

* chore(lint): allow too complex

* wip: presenter mode

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

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

* feat(cli): add presenter view

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-11-28 15:15:56 +01:00
a9b8081167 [pre-commit.ci] pre-commit autoupdate (#326)
updates:
- [github.com/pre-commit/mirrors-mypy: v1.7.0 → v1.7.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.7.0...v1.7.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-11-28 09:48:44 +01:00
dc58d498a8 feat(cli): automatically detect conversion format from DEST (#325)
* feat(cli): automatically detect conversion format from DEST

As titled

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

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

* fix(cli): fix and add tests

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

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

* fix(ci): ?

* [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-11-27 16:13:40 +01:00
f898dd3054 feat(lib): add support for presenter notes (#322)
* feat(lib): add support for presenter notes

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

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

* fix(test): typo

* Update test_slide.py

* Update convert.py

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-11-23 18:20:10 +01:00
b09a000c17 chore(lib): simplify how to add config options (#321)
* chore(lib): simplify how to add config options

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

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

* chore(lint): some fixes

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-11-23 15:38:49 +01:00
eb8efa8e3d feat(lib): add playback rate config options (#320)
* feat(lib): add playback rate config options

Basic playback rate config options, closes #309

* [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-11-23 12:26:25 +01:00
1f0c94dc5c fix(cli): typo 2023-11-23 00:10:56 +01:00
2771aac4d0 chore(cli): reduce timeout for version check 2023-11-22 18:28:57 +01:00
ce799aeded feat(cli): add render command as alias to manim render (#317)
* feat(cli): add render command as alias to `manim render`

* feat(cli): add render command as alias to `manim render`

* chore(test): add test

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

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

* chore(lib): simplify how ManimGL rendering is called

* chore(lib): test

* chore(ci): try identify error

* wip

* chore(test): add result output

* try: checkout

* Revert "try: checkout"

This reverts commit 60985f0b612ca0c6bff9726ebcccf07b1b1c4832.

* fix(deps): don't update lock

* chore(lib): simplify code and improve usage

* chore(cli): better cli

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

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

* fix: does this fix?

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-11-22 18:26:44 +01:00
891273b2fc [pre-commit.ci] pre-commit autoupdate (#318)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.1.5 → v0.1.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.5...v0.1.6)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-11-21 17:42:41 +01:00
2b25b6a89d chore(docs): warn users about #314 (#319)
* chore(docs): warn users about #314

Closes #316

* chore(lint): apply suggested changes
2023-11-21 17:37:30 +01:00
fc594533e9 [pre-commit.ci] pre-commit autoupdate (#310)
updates:
- [github.com/psf/black: 23.10.1 → 23.11.0](https://github.com/psf/black/compare/23.10.1...23.11.0)
- [github.com/astral-sh/ruff-pre-commit: v0.1.4 → v0.1.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.4...v0.1.5)
- [github.com/pre-commit/mirrors-mypy: v1.6.1 → v1.7.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.6.1...v1.7.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-11-19 10:42:18 +01:00
422102524f fix(lib): remove dummy content 2023-11-16 13:07:29 +01:00
739cbcee0a feat(cli): added --hide-info-window option (#313)
* feat(cli): added `--hide-info-window` option

This was suggested by Discord use #dung_14424 on the Manim Discord.

* [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-11-16 12:51:59 +01:00
3b3e3109a3 [pre-commit.ci] pre-commit autoupdate (#306)
updates:
- [github.com/psf/black: 23.10.0 → 23.10.1](https://github.com/psf/black/compare/23.10.0...23.10.1)
- [github.com/astral-sh/ruff-pre-commit: v0.1.1 → v0.1.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.1...v0.1.4)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-11-12 11:01:36 +01:00
8921c3b8f9 chore(README): fix relative link for Windows image 2023-11-01 10:06:00 +01:00
5eb23dc5c1 chore(version): update changelog version 2023-10-30 10:54:14 +01:00
a890832a4d chore(version): bump 5.0.0-rc3 to 5.0.0 2023-10-30 10:52:53 +01:00
106c7d4c06 feat(cli): add next-terminates-loop CLI option (#299)
* feat(cli): add `next-terminates-loop` CLI option

Closes #254

* chore(lib): fix `--next-terminates-loop`

* chore(CHANGELOG): document changes
2023-10-30 10:50:26 +01:00
6c52906037 feat(lib): add auto_next option (#304)
* feat(lib): add `auto_next` option

As suggested in #302, you can now automatically skip a slide. It works both with `present` and `convert --to=html`!

Closes #302

* chore(ci): add trigger on push on main
2023-10-30 10:12:59 +01:00
2853ed08e1 chore(tests): adding tests for Qt widgets (#300)
* chore(tests): adding tests for Qt widgets

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

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

* fix: reset WINDOW name

* chore(tests): addign tests

* chore: adding more tests

* fix how bin existence is checked

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

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

* fix: which takes str

* more tests!

* todo: fix

* change verbosity in tests

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-10-27 16:40:05 +02:00
760ceb8ce1 [pre-commit.ci] pre-commit autoupdate (#298)
* [pre-commit.ci] pre-commit autoupdate

updates:
- [github.com/psf/black: 23.9.1 → 23.10.0](https://github.com/psf/black/compare/23.9.1...23.10.0)
- [github.com/astral-sh/ruff-pre-commit: v0.0.292 → v0.1.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.0.292...v0.1.1)
- [github.com/pre-commit/mirrors-mypy: v1.6.0 → v1.6.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.6.0...v1.6.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-10-23 22:59:52 +02:00
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
111 changed files with 8501 additions and 8152 deletions

View File

@ -1,5 +1,9 @@
[bumpversion]
current_version = 4.16.0
current_version = 5.1.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}
@ -7,10 +11,6 @@ message = chore(version): bump {current_version} to {new_version}
search = __version__ = "{current_version}"
replace = __version__ = "{new_version}"
[bumpversion:file:pyproject.toml]
search = version = "{current_version}"
replace = version = "{new_version}"
[bumpversion:file:CITATION.cff]
search = version: v{current_version}
replace = version: v{new_version}

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,11 +38,11 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@ -56,7 +56,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
uses: github/codeql-action/autobuild@v3
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@ -69,4 +69,4 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v3

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:
@ -24,7 +24,7 @@ jobs:
# This should be the path to the paper within your repo.
paper-path: paper/paper.md
- name: Upload
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: paper
# This is the output path where Pandoc will write the compiled

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

13
.github/workflows/latest_tag.yml vendored Normal file
View File

@ -0,0 +1,13 @@
name: Keep the versions up-to-date
on:
release:
types: [published, edited]
jobs:
actions-tagger:
runs-on: windows-latest
steps:
- uses: Actions-R-Us/actions-tagger@latest
with:
publish_latest_tag: true

View File

@ -32,65 +32,66 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install Poetry
run: pipx install poetry
- name: Install Python
uses: actions/setup-python@v4
uses: actions/checkout@v4
- name: Install PDM
uses: pdm-project/setup-pdm@v3
with:
python-version: '3.9'
cache: poetry
python-version: '3.10'
cache: true
- name: Setup Pages
uses: actions/configure-pages@v3
uses: actions/configure-pages@v4
- name: Install Linux Dependencies
run: |
sudo apt-get update
sudo apt-get install libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev
- name: Setup Pandoc
uses: nikeee/setup-pandoc@v1
- name: Install local Python package
run: poetry install --with docs
run: pdm install -dGdocs -dGgithub-action
- name: Install IPython kernel
run: poetry run ipython kernel install --name "manim-slides" --user
run: pdm run ipython kernel install --name "manim-slides" --user
- name: Restore cached media
id: cache-media-restore
uses: actions/cache/restore@v3
uses: actions/cache/restore@v4
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
gh actions-cache delete ${{ steps.cache-media-restore.outputs.cache-primary-key }} --confirm || true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Save media to cache
id: cache-media-save
uses: actions/cache/save@v3
uses: actions/cache/save@v4
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
run: cd docs && pdm run make html
- name: Upload artifact
if: github.event_name != 'pull_request'
uses: actions/upload-pages-artifact@v2
uses: actions/upload-pages-artifact@v3
with:
# Upload docs/build/html dir
path: docs/build/html/
- name: Show docs/build/html/_static/ dir content (video only)
run: tree -L 3 docs/build/html/_static/ -P '*.mp4'
- name: Deploy to GitHub Pages
id: deployment
if: github.event_name != 'pull_request'
uses: actions/deploy-pages@v2
uses: actions/deploy-pages@v4

71
.github/workflows/publish.yml vendored Normal file
View File

@ -0,0 +1,71 @@
name: Upload Packages
on:
push:
release:
types: [published]
jobs:
publish-python:
name: Build and release
runs-on: ubuntu-latest
environment: release
permissions:
# IMPORTANT: this permission is mandatory for trusted publishing
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install PDM
uses: pdm-project/setup-pdm@v3
with:
python-version: '3.10'
cache: true
- name: Publish to PyPI
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
run: pdm publish
publish-docker:
name: Publish Docker image
runs-on: ubuntu-latest
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}`
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get Version
id: create_release
shell: python
env:
tag_act: ${{ github.ref }}
run: |
import os
ref_tag = os.getenv('tag_act').split('/')[-1]
with open(os.getenv('GITHUB_OUTPUT'), 'w') as f:
print(f"tag_name={ref_tag}", file=f)
- name: Build and push
uses: docker/build-push-action@v5
with:
platforms: linux/arm64,linux/amd64
file: docker/Dockerfile
push: true
tags: |
jeertmans/manim-slides:latest
jeertmans/manim-slides:${{ steps.create_release.outputs.tag_name }}

View File

@ -1,34 +0,0 @@
name: Upload Python Package
on:
push:
release:
types: [published]
jobs:
build_and_release:
name: Build and release
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Install Poetry
run: pipx install poetry
- name: Install Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
cache: poetry
- name: Build wheels
run: poetry build
- name: Publish to PyPI
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
env:
POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }}
run: poetry publish

View File

@ -1,4 +1,7 @@
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
@ -9,91 +12,24 @@ 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: Run apt-get update on Ubuntu
run: sudo apt-get update
- name: Install manim dependencies on Ubuntu
run: |
sudo apt-get install libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev
- name: Install xvfb on Ubuntu
run: |
sudo apt-get install xvfb
nohup Xvfb $DISPLAY &
- name: Install Manim Slides
run: |
poetry install --with test
- name: Run pytest
run: poetry run pytest -x
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
MANIM_SLIDES_VERBOSITY: error
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
- name: Install Python
uses: actions/setup-python@v4
- name: Install PDM
uses: pdm-project/setup-pdm@v3
with:
python-version: ${{ matrix.pyversion }}
cache: poetry
cache: true
# Path related stuff
- name: Append to Path on MacOS
@ -111,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 &
@ -143,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 }}
pdm install -dGgithub-action -dGtest
# 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: pdm 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: pdm 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

6
.gitignore vendored
View File

@ -4,10 +4,12 @@ __pycache__/
/build
/dist
*.egg-info/
.pdm-python
# Manim files
images/
/media
tests/data/media/
docs/source/media/
# ManimGL files
@ -17,7 +19,7 @@ videos/
.manim-slides.toml
slides/
!tests/slides/
!tests/data/slides/
slides_assets/
@ -44,3 +46,5 @@ paper/media/
# Others
coverage.xml
rendering_times.csv

View File

@ -1,34 +1,31 @@
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.12.0
hooks:
- id: pretty-format-yaml
args: [--autofix]
- id: pretty-format-toml
exclude: poetry.lock
args: [--autofix]
- repo: https://github.com/psf/black
rev: 23.7.0
args: [--autofix, --trailing-commas]
- repo: https://github.com/keewis/blackdoc
rev: v0.3.9
hooks:
- id: black
- id: blackdoc
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.0.282
rev: v0.1.13
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.4.1
rev: v1.8.0
hooks:
- id: mypy
additional_dependencies: [types-requests, types-setuptools]

173
CHANGELOG.md Normal file
View File

@ -0,0 +1,173 @@
# 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.1)=
## [v5.1 (Unreleased)](https://github.com/jeertmans/manim-slides/compare/v5.0.0...HEAD)
(v5.1-added)=
### Added
- Added the `--hide-info-window` option to `manim-slides present`.
[#313](https://github.com/jeertmans/manim-slides/pull/313)
- Added the `manim-slides render` command
to render slides using correct Manim installation.
[#317](https://github.com/jeertmans/manim-slides/pull/317)
- Added the `playback-rate` and `reversed-playback-rate` options
to slide config.
[#320](https://github.com/jeertmans/manim-slides/pull/320)
- Added the speaker notes option.
[#322](https://github.com/jeertmans/manim-slides/pull/322)
- Added `auto` option for conversion format, which is the default.
This is somewhat a **breaking change**, but changes to the CLI
API are not considered to be very important.
[#325](https://github.com/jeertmans/manim-slides/pull/325)
- Added `return_animation` option to slide animations `self.wipe`
and `self.zoom`.
[#331](https://github.com/jeertmans/manim-slides/pull/331)
- Created a Docker image, published on GitHub.
[#355](https://github.com/jeertmans/manim-slides/pull/355)
(v5.1-modified)=
### Modified
- Modified the internal logic to simplify adding configuration options.
[#321](https://github.com/jeertmans/manim-slides/pull/321)
- Remove `reversed` file assets when exporting to HTML, as it was not used.
[#336](https://github.com/jeertmans/manim-slides/pull/336)
(v5.1-chore)=
### Chore
- Removed subrocess calls to FFMPEG with direct `libav` bindings using
the `av` Python module. This should enhance rendering speed and security.
[#335](https://github.com/jeertmans/manim-slides/pull/335)
- Changed build backend to PDM and reflected on docs.
[#354](https://github.com/jeertmans/manim-slides/pull/354)
## [v5](https://github.com/jeertmans/manim-slides/compare/v4.16.0...v5.0.0)
Prior to v5, there was no real CHANGELOG other than the GitHub releases,
with most of the content automatically generated by GitHub from merged
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)
- Added `--next-terminates-loop` option to `manim-slides present` for turn a
looping slide into a normal one, so that it ends nicely. This is useful to
have a smooth transition with the next slide.
[#299](https://github.com/jeertmans/manim-slides/pull/299)
- Added `--playback-rate` option to `manim-slides present` for testing purposes.
[#300](https://github.com/jeertmans/manim-slides/pull/300)
- Added `auto_next` option to `Slide`'s `next_slide` method to automatically
play the next slide upon terminating. Supported by `present` and
`convert --to=html` commands.
[#304](https://github.com/jeertmans/manim-slides/pull/304)
(v5-changed)=
### Changed
- 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 -->

View File

@ -29,4 +29,4 @@ keywords:
- PowerPoint
- Python
license: MIT
version: v4.16.0
version: v5.1.0-rc3

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2023 Jérome Eertmans
Copyright (c) 2022-2024 Jérome Eertmans
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

156
README.md
View File

@ -14,19 +14,18 @@
# Manim Slides
Tool for live presentations using either [Manim (community edition)](https://www.manim.community/) or [ManimGL](https://3b1b.github.io/manim/). Manim Slides will *automatically* detect the one you are using!
Tool for live presentations using either
[Manim (community edition)](https://www.manim.community/)
or [ManimGL](https://3b1b.github.io/manim/).
Manim Slides will *automatically* detect the one you are using!
> **NOTE:** this project extends the work of [`manim-presentation`](https://github.com/galatolofederico/manim-presentation), with a lot more features!
> [!NOTE]
> This project extends the work of
> [`manim-presentation`](https://github.com/galatolofederico/manim-presentation),
> with a lot more features!
- [Installation](#installation)
* [Dependencies](#dependencies)
* [Pip install](#pip-install)
* [Install From Repository](#install-from-repository)
- [Usage](#usage)
* [Basic Example](#basic-example)
* [Key Bindings](#key-bindings)
* [Interactive Tutorial](#interactive-tutorial)
* [Other Examples](#other-examples)
- [Comparison with Similar Tools](#comparison-with-similar-tools)
- [F.A.Q](#faq)
* [How to increase quality on Windows](#how-to-increase-quality-on-windows)
@ -37,67 +36,36 @@ Tool for live presentations using either [Manim (community edition)](https://www
## Installation
<!-- start install -->
While installing Manim Slides and its dependencies on your global Python is fine, I recommend using a virtual environment (e.g., [venv](https://docs.python.org/3/tutorial/venv.html)) for a local installation.
### Dependencies
<!-- start deps -->
Manim Slides requires either Manim or ManimGL to be installed. Having both packages installed is fine too.
If none of those packages are installed, please refer to their specific installation guidelines:
- [Manim](https://docs.manim.community/en/stable/installation.html)
- [ManimGL](https://3b1b.github.io/manim/getting_started/installation.html)
<!-- end deps -->
### Pip Install
The recommended way to install the latest release is to use pip:
```bash
pip install manim-slides
```
Optionally, you can also install Manim or ManimGL using extras[^1]:
```bash
pip install manim-slides[manim] # For Manim
# or
pip install manim-slides[manimgl] # For ManimGL
```
[^1]: NOTE: you still need to have Manim or ManimGL platform-specific dependencies installed on your computer.
### Install From Repository
An alternative way to install Manim Slides is to clone the git repository, and install from there: read the [contributing guide](https://eertmans.be/manim-slides/contributing/workflow.html) to know how.
<!-- end install -->
Manim Slides requires either Manim or ManimGL to be installed, along
with their dependencies. Please checkout the
[documentation](https://eertmans.be/manim-slides/installation.html)
for detailed install instructions.
## Usage
<!-- start usage -->
Using Manim Slides is a two-step process:
1. Render animations using `Slide` (resp. `ThreeDSlide`) as a base class instead of `Scene` (resp. `ThreeDScene`), and add calls to `self.next_slide()` everytime you want to create a new slide.
2. Run `manim-slides` on rendered animations and display them like a *Power Point* presentation.
1. Render animations using `Slide` (resp. `ThreeDSlide`) as a base class instead
of `Scene` (resp. `ThreeDScene`), and add calls to `self.next_slide()`
everytime you want to create a new slide.
2. Run `manim-slides` on rendered animations and display them like a
*PowerPoint* presentation.
The documentation is available [online](https://eertmans.be/manim-slides/).
### 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)
@ -106,29 +74,33 @@ 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:
```bash
manim example.py BasicExample
# or
manimgl example.py BasicExample
manim-slides render example.py BasicExample
# or use ManimGL
manim-slides render --GL example.py BasicExample
```
To start the presentation using `Scene1`, `Scene2` and so on simply run:
> [!NOTE]
> Using `manim-slides render` makes sure the use the `manim`
> (or `manimlib`) library that was installed in the Python same environment.
> Put simply, this is a wrapper of `manim render [ARGS]...` (or `manimgl [ARGS]...`).
To start the presentation using `Scene1`, `Scene2` and so on, run:
```bash
manim-slides [OPTIONS] Scene1 Scene2...
```
Or in this example:
In our example:
```bash
manim-slides BasicExample
@ -136,49 +108,30 @@ manim-slides BasicExample
<!-- end usage -->
## Key Bindings
<p align="center">
<img alt="Example GIF" src="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/example.gif">
</p>
The default key bindings to control the presentation are:
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/wizard_dark.png">
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/wizard_light.png">
<img alt="Manim Slides Wizard" src="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/wizard_light.png">
</picture>
You can run the **configuration wizard** to change those key bindings:
```bash
manim-slides wizard
```
A default file can be created with:
```bash
manim-slides init
```
> **_NOTE:_** `manim-slides` uses key codes, which are platform dependent. Using the configuration wizard is therefore highly recommended.
For detailed usage documentation, run `manim-slides --help`, or go to the
[documentation](https://eertmans.be/manim-slides/reference/cli.html).
## Interactive Tutorial
Click on the image to watch a slides presentation that explains you how to use Manim Slides.
Click on the image to watch a slides presentation that explains to you how
to use Manim Slides.
[![Manim Slides Docs](https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/docs.png)](https://eertmans.be/manim-slides/)
## Other Examples
Other examples are available in the [`example.py`](https://github.com/jeertmans/manim-slides/blob/main/example.py) file, if you downloaded the git repository.
Below is a small recording of me playing with the slides back and forth.
![](https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/example.gif)
## More Examples
More examples are available in the
[`example.py`](https://github.com/jeertmans/manim-slides/blob/main/example.py)
file, if you downloaded the git repository.
## Comparison with Similar Tools
There exists are variety of tools that allows to create slides presentations containing Manim animations.
There exists a variety of tools that allows to create slides presentations
containing Manim animations.
Below is a comparison of the most used ones with Manim Slides:
@ -196,16 +149,23 @@ Below is a comparison of the most used ones with Manim Slides:
### How to increase quality on Windows
On Windows platform, one may encounter a lower image resolution than expected. Usually, this is observed because Windows rescales every application to fit the screen.
As found by [@arashash](https://github.com/arashash), in [#20](https://github.com/jeertmans/manim-slides/issues/20), the problem can be addressed by changing the scaling factor to 100%:
On Windows platform, one may encounter a lower image resolution than expected.
Usually, this is observed because Windows rescales every application to
fit the screen.
As found by [@arashash](https://github.com/arashash),
in [#20](https://github.com/jeertmans/manim-slides/issues/20),
the problem can be addressed by changing the scaling factor to 100%:
![Windows Fix Scaling](static/windows_quality_fix.png)
<p align="center">
<img alt="Windows Fix Scaling" src="https://raw.githubusercontent.com/jeertmans/manim-slides/main/static/windows_quality_fix.png">
</p>
in *Settings*->*Display*.
## Contributing
Contributions are more than welcome! Please read through [our contributing section](https://eertmans.be/manim-slides/contributing/index.html).
Contributions are more than welcome! Please read through
[our contributing section](https://eertmans.be/manim-slides/contributing/index.html).
### Reporting an Issue

2
custom_config.yml Normal file
View File

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

55
docker/Dockerfile Normal file
View File

@ -0,0 +1,55 @@
# Mostly a copy from https://github.com/ManimCommunity/manim/blob/68bd79093e1ebc1ed9f8051942ffe6e72a9e66a7/docker/Dockerfile
FROM python:3.11-slim
RUN apt-get update -qq \
&& apt-get install --no-install-recommends -y \
ffmpeg \
build-essential \
gcc \
cmake \
libcairo2-dev \
libffi-dev \
libpango1.0-dev \
freeglut3-dev \
pkg-config \
make \
wget \
ghostscript
# setup a minimal texlive installation
COPY docker/texlive-profile.txt /tmp/
ENV PATH=/usr/local/texlive/bin/armhf-linux:/usr/local/texlive/bin/aarch64-linux:/usr/local/texlive/bin/x86_64-linux:$PATH
RUN wget -O /tmp/install-tl-unx.tar.gz http://mirror.ctan.org/systems/texlive/tlnet/install-tl-unx.tar.gz && \
mkdir /tmp/install-tl && \
tar -xzf /tmp/install-tl-unx.tar.gz -C /tmp/install-tl --strip-components=1 && \
/tmp/install-tl/install-tl --profile=/tmp/texlive-profile.txt \
&& tlmgr install \
amsmath babel-english cbfonts-fd cm-super ctex doublestroke dvisvgm everysel \
fontspec frcursive fundus-calligra gnu-freefont jknapltx latex-bin \
mathastext microtype ms physics preview ragged2e relsize rsfs \
setspace standalone tipa wasy wasysym xcolor xetex xkeyval
# clone and build manim-slides
COPY . /opt/manim-slides
WORKDIR /opt/manim-slides
RUN pip install --no-cache manim[jupyterlab] .[sphinx-directive]
ARG NB_USER=manimslidesuser
ARG NB_UID=1000
ENV USER ${NB_USER}
ENV NB_UID ${NB_UID}
ENV HOME /manim-slides
RUN adduser --disabled-password \
--gecos "Default user" \
--uid ${NB_UID} \
${NB_USER}
# create working directory for user to mount local directory into
WORKDIR ${HOME}
USER root
RUN chown -R ${NB_USER}:${NB_USER} ${HOME}
RUN chmod 777 ${HOME}
USER ${NB_USER}
CMD [ "/bin/bash" ]

15
docker/README.md Normal file
View File

@ -0,0 +1,15 @@
# Docker Image
Manim Slides Docker image, highly inspired from the Manim Community Docker image.
Building the image can be done with:
```bash
docker build -t manim-slide/manin-slide:TAG -f docker/Dockerfile .
```
from the root directory of the repository.
> [!WARNING]
> If you run the command above from another place,
> Docker will not be able to find expected files.

View File

@ -0,0 +1,10 @@
selected_scheme scheme-minimal
TEXDIR /usr/local/texlive
TEXMFCONFIG ~/.texlive/texmf-config
TEXMFHOME ~/texmf
TEXMFLOCAL /usr/local/texlive/texmf-local
TEXMFSYSCONFIG /usr/local/texlive/texmf-config
TEXMFSYSVAR /usr/local/texlive/texmf-var
TEXMFVAR ~/.texlive/texmf-var
option_doc 0
option_src 0

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

@ -4,12 +4,20 @@
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
import sys
from datetime import date
from manim_slides import __version__
assert sys.version_info >= (3, 10), "Building docs requires Python 3.10"
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = "Manim Slides"
copyright = "2023, Jérome Eertmans"
copyright = f"2024-{date.today().year}, Jérome Eertmans"
author = "Jérome Eertmans"
version = __version__
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
@ -29,9 +37,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",
@ -41,6 +47,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
@ -74,6 +82,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

@ -4,7 +4,7 @@ This document is there to help you recreate a working environment for Manim Slid
## Dependencies
```{include} ../../../README.md
```{include} ../quickstart.md
:start-after: <!-- start deps -->
:end-before: <!-- end deps -->
```
@ -20,64 +20,110 @@ Useful links:
Once you feel comfortable with git and GitHub, [fork](https://github.com/jeertmans/manim-slides/fork) the repository, and clone it locally.
As for every Python project, using virtual environment is recommended to avoid conflicts between modules. For Manim Slides, we use [Poetry](https://python-poetry.org/docs/#installing-with-the-official-installer). If not already, please install it.
As for every Python project, using virtual environment is recommended to avoid
conflicts between modules.
For this project, we use [PDM](https://pdm-project.org/) to easily manage project
and development dependencies. If not already, please install this tool.
## Installing Python modules
With Poetry, installation becomes straightforward:
With PDM, installation becomes straightforward:
```bash
poetry install
pdm install
```
This, however, only installs the minimal set of dependencies to run the package.
If you would like to install Manim or ManimGL, as documented in the [quickstart](../quickstart),
you can use the `--extras` option:
If you would like to install Manim or ManimGL,
as documented in the [quickstart](../quickstart),
you can use the `-G|--group` option:
```bash
poetry install --extras manim # For Manim
pdm install -Gmanim # For Manim
# or
poetry install --extras manimgl # For ManimGL
pdm install -Gmanimgl # For ManimGL
```
Additionnally, Manim Slides comes with group dependencies for development purposes:
Additionnally, Manim Slides comes with groups of dependencies for development purposes:
```bash
poetry install --with dev # For linters and formatters
pdm install -dGdev # For linters and formatters
# or
poetry install --with docs # To build the documentation locally
pdm install --dGdocs # To build the documentation locally
# or
pdm install --dGtests # To run tests
```
Another group is `test`, but it is only used for
[GitHub actions](https://github.com/jeertmans/manim-slides/blob/main/.github/workflows/test_examples.yml).
:::{note}
You can combine any number of groups or extras when installing the package locally.
You can also install everything with `pdm install -G:all`.
:::
## Running commands
As modules were installed in a new Python environment, you cannot use them directly in the shell.
Instead, you either need to prepend `poetry run` to any command, e.g.:
Because modules are installed in a new Python environment,
you cannot use them directly in the shell.
Instead, you either need to prepend `pdm run` to any command, e.g.:
```bash
poetry run manim-slides wizard
pdm run manim-slides wizard
```
or enter a new shell that uses this new Python environment:
or [enter a new shell](https://pdm-project.org/latest/usage/venv/#activate-a-virtualenv)
that uses this new Python environment:
```
poetry shell
```bash
eval $(pdm venv activate) # Click on the link above to see shell-specific command
manim-slides wizard
```
## Testing your code
Most of the tests are done with GitHub actions, thus not on your computer. The only command you should run locally is `pre-commit run --all-files`: this runs a few linter and formatter to make sure the code quality and style stay constant across time. If a warning or an error is displayed, please fix it before going to next step.
Most of the tests are done with GitHub actions, thus not on your computer.
The only command you should run locally is:
```bash
pdm run pre-commit run --all-files
```
This runs a few linter and formatter to make sure the code quality and style stay
constant across time.
If a warning or an error is displayed, please fix it before going to next step.
For testing your code, simply run:
```bash
pdm run pytest
```
## Building the documentation
The documentation is generated using Sphinx, based on the content
in `docs/source` and in the `manim_slides` Python package.
To generate the documentation, run the following:
```bash
cd docs
pdm run make html
```
Then, the output index file is located at `docs/build/html/index.html` and
can be opened with any modern browser.
:::{warning}
Building the documentation can take quite some time, especially
the first time as it needs to render all the animations.
Further builds should run faster.
:::
## Proposing changes
Once you feel ready and think your contribution is ready to be reviewed, create a [pull request](https://github.com/jeertmans/manim-slides/pulls) and wait for a reviewer to check your work!
Once you feel ready and think your contribution is ready to be reviewed,
create a [pull request](https://github.com/jeertmans/manim-slides/pulls)
and wait for a reviewer to check your work!
Many thanks to you!

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,23 +23,26 @@ 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.
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>
```{eval-rst}
.. manim-slides:: ../../example.py:ConvertExample
:hide_source:
:quality: high
```
```{toctree}
:hidden:
quickstart
installation
reference/index
features_table
manim_or_manimgl
```
```{toctree}

104
docs/source/installation.md Normal file
View File

@ -0,0 +1,104 @@
# Installation
While installing Manim Slides and its dependencies on your global Python is fine,
we recommend using a virtual environment
(e.g., [venv](https://docs.python.org/3/tutorial/venv.html)) for a local installation.
Therefore, the following documentation will install Manim Slides using
[pipx](https://pipx.pypa.io/). This tool is a drop-in replacement
for installing Python packages that ship with one or more executable.
The benefit of using pipx is that it will automatically create a new virtual
environment for every package you install.
:::{note}
Everytime you read `pipx install`, you can use `pip install` instead,
if you are working in a virtual environment or else.
:::
## Dependencies
Manim Slides requires either Manim or ManimGL to be installed, along
with their dependencies.
Having both packages installed is fine too.
If none of those packages are installed,
please refer to their specific installation guidelines:
- [Manim](https://docs.manim.community/en/stable/installation.html)
- [ManimGL](https://3b1b.github.io/manim/getting_started/installation.html)
:::{warning}
If you install Manim from its git repository, as suggested by ManimGL,
make sure to first check out a supported version (e.g., `git checkout tags/v1.6.1`
for ManimGL), otherwise it might install an unsupported version of Manim!
See [#314](https://github.com/jeertmans/manim-slides/issues/314).
:::
## Pip Install
The recommended way to install the latest release is to use pip:
```bash
pipx install -U manim-slides
```
:::{tip}
While not necessary, the `-U` indicates that we would
like to upgrade to the latest version available,
if Manim Slides is already installed.
:::
Optionally, you can also install Manim or ManimGL using extras[^1]:
```bash
pipx install -U "manim-slides[manim]" # For Manim
# or
pipx install -U "manim-slides[manimgl]" # For ManimGL
```
You can check that Manim Slides was correctly installed with:
```bash
manim-slides --version
```
:::{warning}
If you are installing with pipx, this is mandatory to at least include
either `manim` or `manimgl`.
:::
[^1]: You still need to have Manim or ManimGL platform-specific dependencies
installed on your computer.
## Optional Dependencies
Along with the optional dependencies for Manim and ManimGL,
Manim Slides offers additional *extras*, that can be activated
using optional dependencies:
- `magic`, to include a Jupyter magic to render
animations inside notebooks. This automatically installs `manim`,
and does not work with ManimGL;
- `manim` and `manimgl`, for installing the corresponding
dependencies;
- `sphinx-directive`, to generate presentation inside your Sphinx
documentation. This automatically installs `manim`,
and does not work with ManimGL;
Installing those extras can be done with the following syntax:
```bash
pipx install -U "manim-slides[extra1,extra2]"
```
:::{note}
The quotes `"` are added because not all shell support unquoted
brackets (e.g., zsh) or commas (e.g., Windows).
:::
## Install From Repository
An alternative way to install Manim Slides is to clone the git repository,
and install from there: read the
[contributing guide](./contributing/workflow)
to know how to process.

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

@ -1,11 +1,7 @@
# Quickstart
## Installation
```{include} ../../README.md
:start-after: <!-- start install -->
:end-before: <!-- end install -->
```
If not already, install Manim Slides, along with either Manim or ManimGL,
see [installation](./installation).
## Creating your first slides
@ -16,6 +12,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

@ -55,13 +55,12 @@
" Text(\"Press\"),\n",
" Text(\"and\"),\n",
" Text(\"loop\"),\n",
" ).arrange(DOWN, buff=1.)\n",
" \n",
" ).arrange(DOWN, buff=1.0)\n",
"\n",
" self.play(Write(text))\n",
" self.next_slide(loop=True)\n",
" self.play(Indicate(text[-1], scale_factor=2.0, run_time=0.5))\n",
" self.next_slide()\n",
" self.start_loop()\n",
" self.play(Indicate(text[-1], scale_factor=2., run_time=.5))\n",
" self.end_loop()\n",
" self.play(FadeOut(text))"
]
},

View File

@ -9,8 +9,10 @@ We will go from the *most restrictive* method, to the least restrictive one.
If you need to present on a computer without prior knowledge on what will be
installed on it, please directly refer to the last sections.
> **NOTES:** in the next sections, we will assume your animations are described
:::{note}
In the next sections, we will assume your animations are described
in `example.py`, and you have one presentation called `BasicExample`.
:::
## With Manim Slides installed on the target machine
@ -32,8 +34,8 @@ the key bindings might not be the same.
Example:
```bash
# If you use ManimGl, replace `manim` with `manimgl`
manim example.py BasicExample
# If you use ManimGl, add `--GL` after `render`
manim-slides render example.py BasicExample
# This or `manim-slides BasicExample` works since
# `present` is implied by default
@ -128,25 +130,34 @@ The additional code comes from
[this article](https://faq.dailymotion.com/hc/en-us/articles/360022841393-How-to-preserve-the-player-aspect-ratio-on-a-responsive-page)
and it there to preserve the original aspect ratio (16:9).
### Sharing ONE HTML file
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
to know how to share your slide with GitHub pages, see the
[workflow file](https://github.com/jeertmans/manim-slides/blob/main/.github/workflows/pages.yml).
> **WARNING:** keep in mind that playing large video files over the internet
:::{warning}
Keep in mind that playing large video files over the internet network
can take some time, and *glitches* may occur between slide transitions for this
reason.
:::
### Using the Github starter template
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*)

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,51 +16,12 @@ 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):
@ -181,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()
@ -209,7 +164,7 @@ class Example(Slide):
language="console",
).shift(DOWN)
self.play(self.wipe(title, code))
self.wipe(title, code)
self.next_slide()
self.play(FadeIn(step, shift=RIGHT))
@ -230,26 +185,25 @@ class Example(Slide):
self.play(Transform(step, step_5))
self.play(Transform(code, code_step_5))
self.next_slide()
self.next_slide(auto_next=True)
self.play(Transform(step, step_6))
self.play(Transform(code, code_step_6))
self.play(code.animate.shift(UP), FadeIn(code_step_7), FadeIn(or_text))
self.next_slide()
watch_text = Text("Watch result on next slides!").shift(2 * DOWN).scale(0.5)
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)
@ -289,9 +243,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)
@ -302,9 +256,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))
@ -313,11 +267,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)
@ -329,7 +279,6 @@ else:
frame.set_euler_angles(
theta=30 * DEGREES,
phi=75 * DEGREES,
gamma=0,
)
self.play(GrowFromCenter(circle))
@ -341,9 +290,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))
@ -353,9 +302,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,7 +5,7 @@ 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__(
@ -48,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(
{

View File

@ -6,8 +6,9 @@ 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 .render import render
from .wizard import init, wizard
@ -27,16 +28,13 @@ 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:
manim_info_url = "https://pypi.org/pypi/manim-slides/json"
warn_prompt = "Cannot check if latest release of Manim Slides is installed"
try:
req_info: requests.models.Response = requests.get(
manim_info_url, timeout=10
)
req_info: requests.models.Response = requests.get(manim_info_url, timeout=2)
req_info.raise_for_status()
stable = req_info.json()["info"]["version"]
if stable != __version__:
@ -68,6 +66,7 @@ cli.add_command(convert)
cli.add_command(init)
cli.add_command(list_scenes)
cli.add_command(present)
cli.add_command(render)
cli.add_command(wizard)
if __name__ == "__main__":

View File

@ -1 +1 @@
__version__ = "4.16.0"
__version__ = "5.1.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,7 +42,7 @@ 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: str) -> None:
if not value or ctx.resilient_parsing:
@ -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,10 @@
import hashlib
import json
import shutil
import subprocess
import tempfile
from enum import Enum
from functools import wraps
from inspect import Parameter, signature
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple, Union
from textwrap import dedent
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
import rtoml
from pydantic import (
@ -13,42 +12,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 +64,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 +104,121 @@ 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 BaseSlideConfig(BaseModel): # type: ignore
"""Base class for slide config."""
loop: bool = False
auto_next: bool = False
playback_rate: float = 1.0
reversed_playback_rate: float = 1.0
notes: str = ""
dedent_notes: bool = True
@classmethod
def wrapper(cls, arg_name: str) -> Callable[..., Any]:
"""
Wrap a function to transform keyword argument into an instance of this class.
The function signature is updated to reflect the new keyword-only arguments.
The wrapped function must follow two criteria:
- its last parameter must be ``**kwargs`` (or equivalent);
- and its second last parameter must be ``<arg_name>``.
"""
def _wrapper_(fun: Callable[..., Any]) -> Callable[..., Any]:
@wraps(fun)
def __wrapper__(*args: Any, **kwargs: Any) -> Any: # noqa: N807
fun_kwargs = {
key: value
for key, value in kwargs.items()
if key not in cls.__fields__
}
fun_kwargs[arg_name] = cls(**kwargs)
return fun(*args, **fun_kwargs)
sig = signature(fun)
parameters = list(sig.parameters.values())
parameters[-2:-1] = [
Parameter(
field_name,
Parameter.KEYWORD_ONLY,
default=field_info.default,
annotation=field_info.annotation,
)
for field_name, field_info in cls.__fields__.items()
]
sig = sig.replace(parameters=parameters)
__wrapper__.__signature__ = sig # type: ignore[attr-defined]
return __wrapper__
return _wrapper_
@model_validator(mode="after")
@classmethod
def apply_dedent_notes(
cls, base_slide_config: "BaseSlideConfig"
) -> "BaseSlideConfig":
if base_slide_config.dedent_notes:
base_slide_config.notes = dedent(base_slide_config.notes)
return base_slide_config
class SlideConfig(BaseModel): # type: ignore
type: SlideType
class PreSlideConfig(BaseSlideConfig):
"""Slide config to be used prior to rendering."""
start_animation: int
end_animation: int
number: int
terminated: bool = Field(False, exclude=True)
@classmethod
def from_base_slide_config_and_animation_indices(
cls,
base_slide_config: BaseSlideConfig,
start_animation: int,
end_animation: int,
) -> "PreSlideConfig":
return cls(
start_animation=start_animation,
end_animation=end_animation,
**base_slide_config.dict(),
)
@field_validator("start_animation", "end_animation")
@classmethod
@ -138,19 +227,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 +242,88 @@ class SlideConfig(BaseModel): # type: ignore
"Start animation index must be strictly lower than end animation index"
)
return values
return pre_slide_config
def is_slide(self) -> bool:
return self.type == SlideType.slide
@model_validator(mode="after")
@classmethod
def loop_and_auto_next_disallowed(
cls, pre_slide_config: "PreSlideConfig"
) -> "PreSlideConfig":
if pre_slide_config.loop and pre_slide_config.auto_next:
raise ValueError(
"You cannot have both `loop=True` and `auto_next=True`, "
"because a looping slide has no ending. "
"This may be supported in the future if "
"https://github.com/jeertmans/manim-slides/pull/299 gets merged."
)
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(BaseSlideConfig):
"""Slide config to be used after rendering."""
file: FilePath
rev_file: FilePath
@classmethod
def from_pre_slide_config_and_files(
cls, pre_slide_config: PreSlideConfig, file: Path, rev_file: Path
) -> "SlideConfig":
return cls(file=file, rev_file=rev_file, **pre_slide_config.dict())
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"
def copy_to(
self, folder: Path, use_cached: bool = True, include_reversed: bool = True
) -> "PresentationConfig":
files = config.files
slides = config.slides
"""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 include_reversed and (not use_cached or not rev_dest.exists()):
shutil.copy(rev_file, rev_dest)
return self

View File

@ -9,55 +9,36 @@ 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
from .present import get_scenes_presentation_config
DATA_URI_FIX = r"""
// Fix found by @t-fritsch on GitHub
// see: https://github.com/hakimel/reveal.js/discussions/3362#discussioncomment-6651475.
function fixBase64VideoBackground(event) {
// event.previousSlide, event.currentSlide, event.indexh, event.indexv
if (event.currentSlide.getAttribute('data-background-video')) {
const background = Reveal.getSlideBackground(event.indexh, event.indexv),
video = background.querySelector('video'),
sources = video.querySelectorAll('source');
sources.forEach((source, i) => {
const src = source.getAttribute('src');
if(src.match(/^data:video.*;base64$/)){
const nextSrc = sources[i+1]?.getAttribute('src');
video.setAttribute('src', `${src},${nextSrc}`);
}
});
}
}
Reveal.on( 'ready', fixBase64VideoBackground );
Reveal.on( 'slidechanged', fixBase64VideoBackground );
"""
def open_with_default(file: Path) -> None:
system = platform.system()
@ -80,44 +61,55 @@ def validate_config_option(
config[key] = value
except ValueError:
raise click.BadParameter(
f"Configuration options `{c_option}` could not be parsed into a proper (key, value) pair. Please use an `=` sign to separate key from value."
)
f"Configuration options `{c_option}` could not be parsed into "
"a proper (key, value) pair. "
"Please use an `=` sign to separate key from value."
) from None
return config
def data_uri(file: Path) -> str:
"""
Reads a video and returns the corresponding data-uri.
"""
def file_to_data_uri(file: Path) -> str:
"""Read a video and return the corresponding data-uri."""
b64 = b64encode(file.read_bytes()).decode("ascii")
mime_type = mimetypes.guess_type(file)[0] or "video/mp4"
return f"data:{mime_type};base64,{b64}"
def get_duration_ms(file: Path) -> float:
"""Read a video and return its duration in milliseconds."""
cap = cv2.VideoCapture(str(file))
fps: int = cap.get(cv2.CAP_PROP_FPS)
frame_count: int = cap.get(cv2.CAP_PROP_FRAME_COUNT)
return 1000 * frame_count / fps
class Converter(BaseModel): # type: ignore
presentation_configs: 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,
@ -138,45 +130,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"
@ -185,24 +182,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"
@ -211,25 +208,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"
@ -238,13 +235,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"
@ -254,11 +251,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"
@ -270,6 +267,9 @@ class RevealTheme(str, Enum):
soralized = "solarized"
blood = "blood"
moon = "moon"
black_contrast = "black-contrast"
white_contrast = "white-contrast"
dracula = "dracula"
class RevealJS(Converter):
@ -316,20 +316,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
@ -351,57 +353,34 @@ 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]
logger.debug(f"Writing video section with file {file}")
if self.data_uri:
file = data_uri(file)
else:
file = assets_dir / file.name
# TODO: document this
# Videos are muted because, otherwise, the first slide never plays correctly.
# This is due to a restriction in playing audio without the user doing anything.
# Later, this might be useful to only mute the first video, or to make it optional.
# Read more about this:
# https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide#autoplay_and_autoplay_blocking
if slide_config.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."""
"""
Convert this configuration into a RevealJS HTML presentation, saved to
DEST.
"""
if self.data_uri:
assets_dir = Path("") # Actually we won't care.
for presentation_config in self.presentation_configs:
presentation_config.concat_animations()
else:
dirname = dest.parent
basename = dest.stem
@ -417,20 +396,27 @@ class RevealJS(Converter):
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)
presentation_config.copy_to(full_assets_dir, include_reversed=False)
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()
options = self.dict()
options["assets_dir"] = assets_dir
if self.data_uri:
data_uri_fix = DATA_URI_FIX
else:
data_uri_fix = ""
has_notes = any(
slide_config.notes != ""
for presentation_config in self.presentation_configs
for slide_config in presentation_config.slides
)
content = revealjs_template.format(
sections=sections, data_uri_fix=data_uri_fix, **self.dict()
content = revealjs_template.render(
file_to_data_uri=file_to_data_uri,
get_duration_ms=get_duration_ms,
has_notes=has_notes,
**options,
)
f.write(content)
@ -450,7 +436,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))
@ -460,6 +446,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)
@ -470,15 +457,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,
@ -501,8 +489,8 @@ class PowerPoint(Converter):
def open(self, file: Path) -> None:
return open_with_default(file)
def convert_to(self, dest: Path) -> None:
"""Converts this configuration into a PowerPoint presentation, saved to DEST."""
def convert_to(self, dest: Path) -> None: # noqa: C901
"""Convert this configuration into a PowerPoint presentation, saved to DEST."""
prs = pptx.Presentation()
prs.slide_width = self.width * 9525
prs.slide_height = self.height * 9525
@ -531,25 +519,26 @@ 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]
@ -568,14 +557,18 @@ class PowerPoint(Converter):
poster_frame_image=poster_frame_image,
mime_type=mime_type,
)
if self.auto_play_media:
auto_play_media(movie, loop=slide_config.is_loop())
if slide_config.notes != "":
slide.notes_slide.notes_text_frame.text = slide_config.notes
if self.auto_play_media:
auto_play_media(movie, loop=slide_config.loop)
dest.parent.mkdir(parents=True, exist_ok=True)
prs.save(dest)
def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
"""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:
@ -583,9 +576,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()
@ -601,7 +596,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:
@ -611,7 +606,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())
@ -634,10 +629,11 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
@click.argument("dest", type=click.Path(dir_okay=False, path_type=Path))
@click.option(
"--to",
type=click.Choice(["html", "pdf", "pptx"], case_sensitive=False),
default="html",
type=click.Choice(["auto", "html", "pdf", "pptx"], case_sensitive=False),
metavar="FORMAT",
default="auto",
show_default=True,
help="Set the conversion format to use.",
help="Set the conversion format to use. Use 'auto' to detect format from DEST.",
)
@click.option(
"--open",
@ -674,14 +670,23 @@ 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:
converter = Converter.from_string(to)(
if to == "auto":
fmt = dest.suffix[1:].lower()
try:
cls = Converter.from_string(fmt)
except KeyError:
logger.warn(
f"Could not guess conversion format from {dest!s}, defaulting to HTML."
)
cls = RevealJS
else:
cls = Converter.from_string(to)
converter = cls(
presentation_configs=presentation_configs,
template=template,
**config_options,
@ -704,4 +709,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,290 +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}
}});
{data_uri_fix}
</script>
</body>
</html>

View File

@ -2,4 +2,3 @@ from pathlib import Path
FOLDER_PATH: Path = Path("./slides")
CONFIG_PATH: Path = Path(".manim-slides.toml")
FFMPEG_BIN: Path = Path("ffmpeg")

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::
@ -21,7 +21,9 @@ This directive requires three additional dependencies:
with Sphinx.
You can install them manually, or with the extra keyword:
pip install manim-slides[sphinx-directive]
.. code-block:: bash
pip install "manim-slides[sphinx-directive]"
Note that you will still need to install Manim's platform-specific dependencies,
see
@ -69,13 +71,27 @@ render scenes that are defined within doctests, for example::
>>> class DirectiveDoctestExample(Slide):
... def construct(self):
... self.play(Create(dot))
...
A third application is to render scenes from another specific file::
.. 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
@ -101,7 +117,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
@ -124,8 +140,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.
"""
@ -144,8 +161,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
-----
@ -161,15 +179,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,
@ -182,7 +201,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 = (
@ -211,7 +230,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:
@ -271,20 +300,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:
@ -306,9 +339,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,
@ -348,7 +378,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]):
@ -356,15 +386,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):
@ -400,6 +432,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

@ -16,7 +16,9 @@ Utilities for using Manim Slides with IPython (in particular: Jupyter notebooks)
This magic requires two additional dependencies: ``manim`` and ``IPython``.
You can install them manually, or with the extra keyword:
pip install manim-slides[magic]
.. code-block:: bash
pip install "manim-slides[magic]"
Note that you will still need to install Manim's platform-specific dependencies,
see
@ -30,7 +32,7 @@ import mimetypes
import shutil
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
from typing import Any
from IPython import get_ipython
from IPython.core.interactiveshell import InteractiveShell
@ -49,18 +51,18 @@ from ..present import get_scenes_presentation_config
class ManimSlidesMagic(Magics): # type: ignore
def __init__(self, shell: InteractiveShell) -> None:
super().__init__(shell)
self.rendered_files: Dict[Path, Path] = {}
self.rendered_files: dict[Path, Path] = {}
@needs_local_scope
@line_cell_magic
def manim_slides(
def manim_slides( # noqa: C901
self,
line: str,
cell: Optional[str] = None,
local_ns: Dict[str, Any] = {},
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.
r"""
Render Manim Slides contained in IPython cells. Works as a line or cell magic.
.. note::
@ -118,7 +120,6 @@ class ManimSlidesMagic(Magics): # type: ignore
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
@ -143,8 +144,9 @@ class ManimSlidesMagic(Magics): # type: ignore
In case you want to hide the red box containing the output progress bar, the ``progress_bar`` config
option should be set to ``None``. This can also be done by passing ``--progress_bar None`` as a
CLI flag.
"""
if local_ns is None:
local_ns = {}
if cell:
exec(cell, local_ns)
@ -174,8 +176,8 @@ class ManimSlidesMagic(Magics): # type: ignore
renderer = OpenGLRenderer()
try:
SceneClass = local_ns[config["scene_names"][0]]
scene = SceneClass(renderer=renderer)
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

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,20 +31,22 @@ 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)
if not (libav_logger := logging.getLogger("libav")).hasHandlers():
libav_logger.addHandler(rich_handler)
return logger
make_logger()
logger = logging.getLogger("manim-slides")

View File

@ -1,93 +0,0 @@
import sys
from importlib.util import find_spec
__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",
]
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:
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,321 @@
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 ..commons import config_path_option, folder_path_option, verbosity_option
from ..config import Config, PresentationConfig
from ..logger import logger
from ..qt_utils import qapp
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="Present content on the given screen (a.k.a. display).",
)
@click.option(
"--playback-rate",
metavar="RATE",
type=float,
default=1.0,
help="Playback rate of the video slides, see PySide6 docs for details. "
" The playback rate of each slide is defined as the product of its default "
" playback rate and the provided value.",
)
@click.option(
"--next-terminates-loop",
"next_terminates_loop",
is_flag=True,
help="If set, pressing next will turn any looping slide into a play slide.",
)
@click.option(
"--hide-info-window",
is_flag=True,
help="Hide info window.",
)
@click.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],
playback_rate: float,
next_terminates_loop: bool,
hide_info_window: bool,
) -> 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_slide_number = start_at[1]
app = qapp()
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,
playback_rate=playback_rate,
next_terminates_loop=next_terminates_loop,
hide_info_window=hide_info_window,
)
player.show()
signal.signal(signal.SIGINT, signal.SIG_DFL)
sys.exit(app.exec())

View File

@ -0,0 +1,551 @@
from datetime import datetime
from pathlib import Path
from typing import List, Optional
from PySide6.QtCore import Qt, QTimer, 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 (
QHBoxLayout,
QLabel,
QMainWindow,
QVBoxLayout,
QWidget,
)
from ..config import Config, PresentationConfig, SlideConfig
from ..logger import logger
from ..resources import * # noqa: F403
WINDOW_NAME = "Manim Slides"
class Info(QWidget): # type: ignore[misc]
key_press_event: Signal = Signal(QKeyEvent)
close_event: Signal = Signal(QCloseEvent)
def __init__(
self,
*,
full_screen: bool,
aspect_ratio_mode: Qt.AspectRatioMode,
screen: Optional[QScreen],
) -> None:
super().__init__()
if screen:
self.setScreen(screen)
self.move(screen.geometry().topLeft())
if full_screen:
self.setWindowState(Qt.WindowFullScreen)
layout = QHBoxLayout()
# Current slide view
left_layout = QVBoxLayout()
left_layout.addWidget(
QLabel("Current slide"),
alignment=Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter,
)
main_video_widget = QVideoWidget()
main_video_widget.setAspectRatioMode(aspect_ratio_mode)
main_video_widget.setFixedSize(720, 480)
self.video_sink = main_video_widget.videoSink()
left_layout.addWidget(main_video_widget)
# Current slide informations
self.scene_label = QLabel()
self.slide_label = QLabel()
self.start_time = datetime.now()
self.time_label = QLabel()
self.elapsed_label = QLabel("00h00m00s")
self.timer = QTimer()
self.timer.start(1000) # every second
self.timer.timeout.connect(self.update_time)
bottom_left_layout = QHBoxLayout()
bottom_left_layout.addWidget(
QLabel("Scene:"),
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight,
)
bottom_left_layout.addWidget(
self.scene_label,
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
)
bottom_left_layout.addWidget(
QLabel("Slide:"),
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight,
)
bottom_left_layout.addWidget(
self.slide_label,
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
)
bottom_left_layout.addWidget(
QLabel("Time:"),
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight,
)
bottom_left_layout.addWidget(
self.time_label,
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
)
bottom_left_layout.addWidget(
QLabel("Elapsed:"),
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight,
)
bottom_left_layout.addWidget(
self.elapsed_label,
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
)
left_layout.addLayout(bottom_left_layout)
layout.addLayout(left_layout)
layout.addSpacing(20)
# Next slide preview
right_layout = QVBoxLayout()
right_layout.addWidget(
QLabel("Next slide"),
alignment=Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter,
)
next_video_widget = QVideoWidget()
next_video_widget.setAspectRatioMode(aspect_ratio_mode)
next_video_widget.setFixedSize(360, 240)
self.next_media_player = QMediaPlayer()
self.next_media_player.setVideoOutput(next_video_widget)
self.next_media_player.setLoops(-1)
right_layout.addWidget(next_video_widget)
# Notes
self.slide_notes = QLabel()
self.slide_notes.setWordWrap(True)
self.slide_notes.setTextFormat(Qt.TextFormat.MarkdownText)
self.slide_notes.setFixedWidth(360)
right_layout.addWidget(
self.slide_notes,
alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
)
layout.addLayout(right_layout)
widget = QWidget()
widget.setLayout(layout)
main_layout = QVBoxLayout()
main_layout.addWidget(widget, alignment=Qt.AlignmentFlag.AlignCenter)
self.setLayout(main_layout)
@Slot()
def update_time(self) -> None:
now = datetime.now()
seconds = (now - self.start_time).total_seconds()
hours, seconds = divmod(seconds, 3600)
minutes, seconds = divmod(seconds, 60)
self.time_label.setText(now.strftime("%Y/%m/%d %H:%M:%S"))
self.elapsed_label.setText(
f"{int(hours):02d}h{int(minutes):02d}m{int(seconds):02d}s"
)
@Slot()
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
self.close_event.emit(event)
@Slot()
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
self.key_press_event.emit(event)
class Player(QMainWindow): # type: ignore[misc]
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,
playback_rate: float = 1.0,
next_terminates_loop: bool = False,
hide_info_window: bool = False,
):
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_presentation_index = presentation_index
self.current_slide_index = slide_index
self.__current_file: Path = self.current_slide_config.file
self.__playing_reversed_slide = False
# Widgets
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_sink = self.video_widget.videoSink()
self.video_widget.setAspectRatioMode(aspect_ratio_mode)
self.setCentralWidget(self.video_widget)
self.media_player = QMediaPlayer(self)
self.media_player.setVideoOutput(self.video_widget)
self.playback_rate = playback_rate
self.presentation_changed.connect(self.presentation_changed_callback)
self.slide_changed.connect(self.slide_changed_callback)
self.info = Info(
full_screen=full_screen, aspect_ratio_mode=aspect_ratio_mode, screen=screen
)
self.info.close_event.connect(self.closeEvent)
self.info.key_press_event.connect(self.keyPressEvent)
self.video_sink.videoFrameChanged.connect(
lambda frame: self.info.video_sink.setVideoFrame(frame)
)
self.hide_info_window = hide_info_window
# Connecting key callbacks
self.config.keys.QUIT.connect(self.close)
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
self.next_terminates_loop = next_terminates_loop
# 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)
else:
def media_status_changed(status: QMediaPlayer.MediaStatus) -> None:
if (
status == QMediaPlayer.EndOfMedia
and self.current_slide_config.auto_next
):
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 next_slide_config(self) -> Optional[SlideConfig]:
if self.playing_reversed_slide:
return self.current_slide_config
elif self.current_slide_index < self.current_slides_count - 1:
return self.presentation_configs[self.current_presentation_index].slides[
self.current_slide_index + 1
]
elif self.current_presentation_index < self.presentations_count - 1:
return self.presentation_configs[
self.current_presentation_index + 1
].slides[0]
else:
return None
@property
def next_file(self) -> Optional[Path]:
if slide_config := self.next_slide_config:
return slide_config.file # type: ignore[no-any-return]
return None
@property
def playing_reversed_slide(self) -> bool:
return self.__playing_reversed_slide
@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 self.playing_reversed_slide:
self.media_player.setPlaybackRate(
self.current_slide_config.reversed_playback_rate * self.playback_rate
)
else:
self.media_player.setPlaybackRate(
self.current_slide_config.playback_rate * self.playback_rate
)
if start_paused:
self.media_player.pause()
else:
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
self.preview_next_slide() # Slide number did not change, but next did
elif self.current_slide_index < self.current_slides_count - 1:
self.current_slide_index += 1
elif self.current_presentation_index < self.presentations_count - 1:
self.current_presentation_index += 1
self.current_slide_index = 0
elif self.exit_after_last_slide:
self.close()
return
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}")
self.info.slide_notes.setText(self.current_slide_config.notes)
self.preview_next_slide()
def preview_next_slide(self) -> None:
if slide_config := self.next_slide_config:
url = QUrl.fromLocalFile(slide_config.file)
self.info.next_media_player.setSource(url)
self.info.next_media_player.play()
def show(self) -> None:
super().show()
if not self.hide_info_window:
self.info.show()
@Slot()
def close(self) -> None:
logger.info("Closing gracefully...")
self.info.close()
super().close()
@Slot()
def next(self) -> None:
if self.media_player.playbackState() == QMediaPlayer.PausedState:
self.media_player.play()
elif self.next_terminates_loop and self.media_player.loops() != 1:
position = self.media_player.position()
self.media_player.setLoops(1)
self.media_player.stop()
self.media_player.setPosition(position)
self.media_player.play()
else:
self.load_next_slide()
@Slot()
def previous(self) -> None:
self.load_previous_slide()
@Slot()
def reverse(self) -> None:
self.load_reversed_slide()
self.preview_next_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)
@Slot()
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
self.close()
@Slot()
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
key = event.key()
self.dispatch(key)
event.accept()

14
manim_slides/qt_utils.py Normal file
View File

@ -0,0 +1,14 @@
"""Qt utils."""
from PySide6.QtWidgets import QApplication
def qapp() -> QApplication:
"""
Return a QApplication instance, creating one
if needed.
"""
if app := QApplication.instance():
return app
return QApplication([])

54
manim_slides/render.py Normal file
View File

@ -0,0 +1,54 @@
"""
Alias command to either
``manim render [OPTIONS] [ARGS]...`` or
``manimgl [OPTIONS] [ARGS]...``.
This is especially useful for two reasons:
1. You can are sure to execute the rendering command with the same Python environment
as for ``manim-slides``.
2. You can pass options to the config.
"""
import subprocess
import sys
from typing import Tuple
import click
@click.command(
context_settings=dict(
ignore_unknown_options=True, allow_extra_args=True, help_option_names=("-h",)
),
options_metavar="[-h] [--CE|--GL]",
)
@click.option(
"--CE",
is_flag=True,
envvar="MANIM_RENDERER",
show_envvar=True,
help="If set, use Manim Community Edition (CE) renderer. "
"If this or ``--GL`` is not set, defaults to CE renderer.",
)
@click.option(
"--GL",
is_flag=True,
envvar="MANIMGL_RENDERER",
show_envvar=True,
help="If set, use ManimGL renderer.",
)
@click.argument("args", metavar="[RENDERER_ARGS]...", nargs=-1, type=click.UNPROCESSED)
def render(ce: bool, gl: bool, args: Tuple[str, ...]) -> None:
"""
Render SCENE(s) from the input FILE, using the specified renderer.
Use ``manim-slides render --help`` to see help information for
a specific renderer.
"""
if ce and gl:
raise click.UsageError("You cannot specify both --CE and --GL renderers.")
if gl:
subprocess.run([sys.executable, "-m", "manimlib", *args])
else:
subprocess.run([sys.executable, "-m", "manim", "render", *args])

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)

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

@ -0,0 +1,640 @@
from __future__ import annotations
__all__ = ["BaseSlide"]
import platform
from abc import abstractmethod
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
MutableMapping,
Sequence,
ValuesView,
)
import numpy as np
from tqdm import tqdm
from ..config import BaseSlideConfig, PresentationConfig, PreSlideConfig, SlideConfig
from ..defaults import FOLDER_PATH
from ..logger import logger
from ..utils import concatenate_video_files, merge_basenames, reverse_video_file
from . import MANIM
if TYPE_CHECKING:
from .animation import Wipe, Zoom
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._base_slide_config: BaseSlideConfig = BaseSlideConfig()
self._current_slide = 1
self._current_animation = 0
self._start_animation = 0
self._canvas: MutableMapping[str, Mobject] = {}
self._wait_time_between_slides = 0.0
@property
@abstractmethod
def _frame_height(self) -> float:
"""Return the scene's frame height."""
raise NotImplementedError
@property
@abstractmethod
def _frame_width(self) -> float:
"""Return the scene's frame width."""
raise NotImplementedError
@property
@abstractmethod
def _background_color(self) -> str:
"""Return the scene's background color."""
raise NotImplementedError
@property
@abstractmethod
def _resolution(self) -> tuple[int, int]:
"""Return the scene's resolution used during rendering."""
raise NotImplementedError
@property
@abstractmethod
def _partial_movie_files(self) -> list[Path]:
"""Return a list of partial movie files, a.k.a animations."""
raise NotImplementedError
@property
@abstractmethod
def _show_progress_bar(self) -> bool:
"""Return True if progress bar should be displayed."""
raise NotImplementedError
@property
@abstractmethod
def _leave_progress_bar(self) -> bool:
"""Return True if progress bar should be left after completed."""
raise NotImplementedError
@property
@abstractmethod
def _start_at_animation_number(self) -> int | None:
"""If set, return the animation number at which rendering start."""
raise NotImplementedError
@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 # type: ignore[attr-defined]
if mobject not in self.canvas_mobjects
]
@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
@BaseSlideConfig.wrapper("base_slide_config")
def next_slide(
self,
*,
base_slide_config: BaseSlideConfig,
**kwargs: Any,
) -> None:
"""
Create a new slide with previous animations, and setup options
for the next slide.
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 auto_next:
If set, next slide will play immediately play the next slide
upon terminating.
Note that this is only supported by ``manim-slides present``
and ``manim-slides convert --to=html``.
:param playback_rate:
Playback rate at which the video is played.
Note that this is only supported by ``manim-slides present``.
:param reversed_playback_rate:
Playback rate at which the reversed video is played.
Note that this is only supported by ``manim-slides present``.
:param notes:
Presenter notes, in Markdown format.
Note that PowerPoint does not support Markdown.
Note that this is only supported by ``manim-slides present``
and ``manim-slides convert --to=html/pptx``.
:param dedent_notes:
If set, apply :func:`textwrap.dedent` to notes.
:param kwargs:
Keyword arguments to be passed to
:meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`,
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))
The following contains one slide that triggers the next slide
upon terminating.
.. manim-slides:: AutoNextExample
from manim import *
from manim_slides import Slide
class AutoNextExample(Slide):
def construct(self):
square = Square(color=RED, side_length=2)
self.play(GrowFromCenter(square))
self.next_slide(auto_next=True)
self.play(Wiggle(square))
self.next_slide()
self.wipe(square)
The following contains speaker notes. On the webbrowser,
the speaker view can be triggered by pressing :kbd:`S`.
.. manim-slides:: SpeakerNotesExample
from manim import *
from manim_slides import Slide
class SpeakerNotesExample(Slide):
def construct(self):
self.next_slide(notes="Some introduction")
square = Square(color=GREEN, side_length=2)
self.play(GrowFromCenter(square))
self.next_slide(notes="We now rotate the slide")
self.play(Rotate(square, PI / 2))
self.next_slide(notes="Bye bye")
self.zoom(square)
"""
if self._current_animation > self._start_animation:
if self.wait_time_between_slides > 0.0:
self.wait(self.wait_time_between_slides) # type: ignore[attr-defined]
self._slides.append(
PreSlideConfig.from_base_slide_config_and_animation_indices(
self._base_slide_config,
self._start_animation,
self._current_animation,
)
)
self._current_slide += 1
self._base_slide_config = base_slide_config
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.from_base_slide_config_and_animation_indices(
self._base_slide_config,
self._start_animation,
self._current_animation,
)
)
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(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(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,
return_animation: bool = False,
**kwargs: Any,
) -> Wipe | None:
"""
Play a wipe animation that will shift all the current objects outside of the
current scene's scope, and all the future objects inside.
:param args: Positional arguments passed to
:class:`Wipe<manim_slides.slide.animation.Wipe>`.
:param direction: The wipe direction, that will be scaled by the scene size.
:param return_animation: If set, return the animation instead of
playing it. This is useful to combine multiple animations with this one.
:param kwargs: Keyword arguments passed to
:class:`Wipe<manim_slides.slide.animation.Wipe>`.
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()
anim = self.wipe(
beautiful,
circle,
direction=DOWN + RIGHT,
return_animation=True
)
self.play(anim)
"""
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,
)
if return_animation:
return animation
self.play(animation)
return None
def zoom(
self,
*args: Any,
return_animation: bool = False,
**kwargs: Any,
) -> Zoom | None:
"""
Play a zoom animation that will fade out all the current objects, and fade in
all the future objects. Objects are faded in a direction that goes towards the
camera.
:param args: Positional arguments passed to
:class:`Zoom<manim_slides.slide.animation.Zoom>`.
:param return_animation: If set, return the animation instead of
playing it. This is useful to combine multiple animations with this one.
:param kwargs: Keyword arguments passed to
:class:`Zoom<manim_slides.slide.animation.Zoom>`.
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()
anim = self.zoom(
square,
circle,
out=True,
scale=10.0,
return_animation=True
)
self.play(anim)
"""
from .animation import Zoom
animation = Zoom(*args, **kwargs)
if return_animation:
return animation
self.play(animation)
return None

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

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

98
manim_slides/utils.py Normal file
View File

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

View File

@ -7,7 +7,6 @@ import click
from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon, QKeyEvent
from PySide6.QtWidgets import (
QApplication,
QDialog,
QDialogButtonBox,
QGridLayout,
@ -22,7 +21,8 @@ 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 .qt_utils import qapp
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()
@ -57,12 +57,13 @@ class Wizard(QWidget): # type: ignore
self.config = config
self.icon = QIcon(":/icon.png")
self.setWindowIcon(self.icon)
self.closed_without_saving = False
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.button_box = QDialogButtonBox(button)
self.button_box.accepted.connect(self.save_config)
self.button_box.rejected.connect(self.close_without_saving)
self.buttons = []
@ -72,7 +73,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,24 +84,24 @@ 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)
self.layout.addWidget(self.buttonBox, len(self.buttons), 1)
self.layout.addWidget(self.button_box, len(self.buttons), 1)
self.setLayout(self.layout)
def closeWithoutSaving(self) -> None:
def close_without_saving(self) -> None:
logger.debug("Closing configuration wizard without saving")
self.closed_without_saving = True
self.deleteLater()
sys.exit(0)
def closeEvent(self, event: Any) -> None:
self.closeWithoutSaving()
def closeEvent(self, event: Any) -> None: # noqa: N802
self.close_without_saving()
event.accept()
def saveConfig(self) -> None:
def save_config(self) -> None:
try:
Config.model_validate(self.config.dict())
except ValueError:
@ -111,15 +112,15 @@ class Wizard(QWidget): # type: ignore
"Two or more actions share a common key: make sure actions have distinct key codes."
)
msg.setWindowTitle("Error: duplicated keys")
msg.exec_()
msg.exec()
return
self.deleteLater()
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_()
dialog.exec()
if dialog.key is not None:
key_name = keymap[dialog.key]
key.set_ids(dialog.key)
@ -149,8 +150,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")
@ -178,12 +181,15 @@ def _init(
if config_path.exists():
config = Config.from_file(config_path)
app = QApplication(sys.argv)
app = qapp()
app.setApplicationName("Manim Slides Wizard")
window = Wizard(config)
window.show()
app.exec()
if window.closed_without_saving:
sys.exit(0)
config = window.config
if merge:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

After

Width:  |  Height:  |  Size: 158 KiB

3458
pdm.lock generated Normal file

File diff suppressed because it is too large Load Diff

4616
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +1,9 @@
[build-system]
build-backend = "poetry.core.masonry.api"
requires = ["setuptools", "poetry-core>=1.0.0"]
build-backend = "pdm.backend"
requires = ["pdm-backend", "setuptools"]
[tool.black]
target-version = ["py38"]
[tool.isort]
profile = "black"
py_version = 38
[tool.mypy]
disallow_untyped_decorators = false
install_types = true
python_version = "3.8"
strict = true
[tool.poetry]
authors = [
"Jérome Eertmans <jeertmans@icloud.com>"
]
[project]
authors = [{name = "Jérome Eertmans", email = "jeertmans@icloud.com"}]
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
@ -29,104 +14,126 @@ classifiers = [
"Operating System :: OS Independent",
"Topic :: Multimedia :: Video",
"Topic :: Multimedia :: Graphics",
"Topic :: Scientific/Engineering"
"Topic :: Scientific/Engineering",
]
dependencies = [
"av>=9.0.0",
"click>=8.1.3",
"click-default-group>=1.2.2",
"jinja2>=3.1.2",
"lxml>=4.9.2",
"numpy>=1.19",
"opencv-python>=4.6.0.66",
"pillow>=9.5.0",
"pydantic>=2.0.1",
"pydantic-extra-types>=2.0.0",
"pyside6==6.5.2",
"python-pptx>=0.6.21",
"requests>=2.28.1",
"rich>=13.3.2",
"rtoml>=0.9.0",
"tqdm>=4.64.1",
]
description = "Tool for live presentations using manim"
documentation = "https://eertmans.be/manim-slides"
exclude = ["docs/", "static/"]
homepage = "https://github.com/jeertmans/manim-slides"
dynamic = ["version"]
keywords = ["manim", "slides", "plugin", "manimgl"]
license = "MIT"
license = {text = "MIT"}
name = "manim-slides"
packages = [
{include = "manim_slides"}
]
readme = "README.md"
repository = "https://github.com/jeertmans/manim-slides"
version = "4.16.0"
requires-python = ">=3.8,<3.12"
[tool.poetry.dependencies]
click = "^8.1.3"
click-default-group = "^1.2.2"
docutils = {version = "^0.20.1", optional = true}
ipython = {version = ">=8.12.2", optional = true}
jinja2 = {version = "^3.1.2", optional = true}
lxml = "^4.9.2"
manim = {version = "^0.17.3", optional = true}
manimgl = {version = "^1.6.1", optional = true}
notebook = {version = "^7.0.2", optional = true}
numpy = "^1.19"
opencv-python = "^4.6.0.66"
pillow = "^9.5.0"
pydantic = "^2.0.1"
pydantic-extra-types = "^2.0.0"
pyside6 = "^6.5.1.1"
python = ">=3.8.1,<3.12"
python-pptx = "^0.6.21"
requests = "^2.28.1"
rich = "^13.3.2"
rtoml = "^0.9.0"
tqdm = "^4.64.1"
[project.optional-dependencies]
all = [
"manim-slides[magic,manim,manimgl,sphinx-directive]",
]
magic = ["manim-slides[manim]", "ipython>=8.12.2"]
manim = ["manim>=0.17.3"]
manimgl = ["manimgl>=1.6.1"]
sphinx-directive = ["docutils>=0.20.1", "manim-slides[manim]"]
[tool.poetry.extras]
magic = ["manim", "ipython"]
manim = ["manim"]
manimgl = ["manimgl"]
sphinx-directive = ["docutils", "jinja2", "manim"]
[tool.poetry.group.dev]
optional = true
[tool.poetry.group.dev.dependencies]
black = "^22.10.0"
bump2version = "^1.0.1"
isort = "^5.12.0"
mypy = "^0.991"
pre-commit = "^3.0.2"
ruff = "^0.0.219"
[tool.poetry.group.docs]
optional = true
[tool.poetry.group.docs.dependencies]
furo = "^2023.5.20"
ipykernel = "^6.25.1"
manim = "^0.17.3"
myst-parser = "^2.0.0"
nbsphinx = "^0.9.2"
pandoc = "^2.3"
sphinx = "^7.0.1"
sphinx-click = "^4.4.0"
sphinx-copybutton = "^0.5.1"
sphinxext-opengraph = "^0.7.5"
[tool.poetry.group.test]
optional = true
[tool.poetry.group.test.dependencies]
manim = "^0.17.3"
manimgl = "^1.6.1"
pytest = "^7.4.0"
pytest-cov = "^4.1.0"
pytest-env = "^0.8.2"
pytest-xdist = "^3.3.1"
[tool.poetry.plugins]
[tool.poetry.plugins."console_scripts"]
[project.scripts]
manim-slides = "manim_slides.__main__:cli"
[project.urls]
Changelog = "https://github.com/jeertmans/manim-slides/releases"
Documentation = "https://eertmans.be/manim-slides"
Founding = "https://github.com/sponsors/jeertmans"
Homepage = "https://github.com/jeertmans/manim-slides"
Repository = "https://github.com/jeertmans/manim-slides"
[tool.coverage.report]
exclude_lines = [
'pragma: no cover',
'raise NotImplementedError',
'if TYPE_CHECKING:',
'if typing.TYPE_CHECKING:',
]
precision = 2
[tool.mypy]
disallow_untyped_decorators = false
install_types = true
python_version = "3.8"
strict = true
[tool.pdm.dev-dependencies]
dev = [
"bump2version>=1.0.1",
"pre-commit>=3.5.0",
]
docs = [
"manim-slides[magic,sphinx-directive]",
"furo>=2023.5.20",
"ipykernel>=6.25.1",
"myst-parser>=2.0.0",
"nbsphinx>=0.9.2",
"pandoc>=2.3",
"sphinx>=7.0.1",
"sphinx-click>=4.4.0",
"sphinx-copybutton>=0.5.1",
"sphinxext-opengraph>=0.7.5",
]
github-action = ["setuptools"]
test = [
"manim-slides[manim,manimgl]",
"pytest>=7.4.0",
"pytest-cov>=4.1.0",
"pytest-env>=0.8.2",
"pytest-qt>=4.2.0",
"pytest-xdist>=3.3.1",
]
[tool.pdm.version]
path = "manim_slides/__version__.py"
source = "file"
[tool.pytest.ini_options]
env = [
"QT_QPA_PLATFORM=offscreen"
"QT_QPA_PLATFORM=offscreen",
]
filterwarnings = [
"error",
"ignore::DeprecationWarning"
"ignore::DeprecationWarning",
]
[tool.ruff]
ignore = [
"E501"
extend-exclude = ["manim_slides/resources.py"]
extend-ignore = [
"D100",
"D101",
"D102",
"D103",
"D104",
"D105",
"D106",
"D107",
"D203",
"D205",
"D212",
"E501",
]
extend-include = ["*.ipynb"]
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

View File

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

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,78 @@
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
from manim_slides.config import PresentationConfig
_ = make_logger() # This is run so that "PERF" level is created
@pytest.fixture(scope="session")
def tests_folder() -> Iterator[Path]:
yield Path(__file__).parent.resolve(strict=True)
@pytest.fixture(scope="session")
def project_folder(tests_folder: Path) -> Iterator[Path]:
yield tests_folder.parent.resolve(strict=True)
@pytest.fixture(scope="session")
def data_folder(tests_folder: Path) -> Iterator[Path]:
yield (tests_folder / "data").resolve(strict=True)
@pytest.fixture(scope="session")
def slides_folder(data_folder: Path) -> Iterator[Path]:
yield (data_folder / "slides").resolve(strict=True)
@pytest.fixture(scope="session")
def slides_file(data_folder: Path) -> Iterator[Path]:
yield (data_folder / "slides.py").resolve(strict=True)
@pytest.fixture(scope="session")
def manimgl_config(project_folder: Path) -> Iterator[Path]:
yield (project_folder / "custom_config.yml").resolve(strict=True)
@pytest.fixture(scope="session")
def video_file(data_folder: Path) -> Iterator[Path]:
yield (data_folder / "video.mp4").resolve(strict=True)
@pytest.fixture(scope="session")
def video_data_uri_file(data_folder: Path) -> Iterator[Path]:
yield (data_folder / "video_data_uri.txt").resolve(strict=True)
def random_path(
length: int = 20,
dirname: Path = Path("./media/videos/example"),
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 folder_path() -> Iterator[Path]:
yield (Path(__file__).parent / "slides").resolve()
def paths() -> Generator[List[Path], None, None]:
random.seed(1234)
yield [random_path() for _ in range(20)]
@pytest.fixture(scope="session")
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"
}

BIN
tests/data/video.mp4 Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

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"
}

105
tests/test_base_slide.py Normal file
View File

@ -0,0 +1,105 @@
from typing import MutableMapping
import pytest
from manim_slides.slide.base import BaseSlide
@pytest.fixture
def base_slide() -> BaseSlide:
return BaseSlide() # type: ignore[abstract]
class TestBaseSlide:
def test_frame_height(self, base_slide: BaseSlide) -> None:
with pytest.raises(NotImplementedError):
_ = base_slide._frame_height
def test_frame_width(self, base_slide: BaseSlide) -> None:
with pytest.raises(NotImplementedError):
_ = base_slide._frame_width
def test_background_color(self, base_slide: BaseSlide) -> None:
with pytest.raises(NotImplementedError):
_ = base_slide._background_color
def test_resolution(self, base_slide: BaseSlide) -> None:
with pytest.raises(NotImplementedError):
_ = base_slide._resolution
def test_partial_movie_files(self, base_slide: BaseSlide) -> None:
with pytest.raises(NotImplementedError):
_ = base_slide._partial_movie_files
def test_show_progress_bar(self, base_slide: BaseSlide) -> None:
with pytest.raises(NotImplementedError):
_ = base_slide._show_progress_bar
def test_leave_progress_bar(self, base_slide: BaseSlide) -> None:
with pytest.raises(NotImplementedError):
_ = base_slide._leave_progress_bar
def test_start_at_animation_number(self, base_slide: BaseSlide) -> None:
with pytest.raises(NotImplementedError):
_ = base_slide._start_at_animation_number
def test_canvas(self, base_slide: BaseSlide) -> None:
assert len(base_slide.canvas) == 0
assert isinstance(base_slide.canvas, MutableMapping)
def test_add_to__and_remove_from_canvas(self, base_slide: BaseSlide) -> None:
assert len(base_slide.canvas) == 0
base_slide.add_to_canvas(a=1, b=2)
assert len(base_slide.canvas) == 2
assert base_slide.canvas["a"] == 1
assert base_slide.canvas["b"] == 2
with pytest.raises(KeyError):
_ = base_slide.canvas["c"]
base_slide.add_to_canvas(b=3, c=4)
assert len(base_slide.canvas) == 3
assert sorted(base_slide.canvas_mobjects) == [1, 3, 4]
base_slide.remove_from_canvas("a", "b", "c")
assert len(base_slide.canvas) == 0
with pytest.raises(KeyError):
base_slide.remove_from_canvas("a")
def test_mobjects_without_canvas(self) -> None:
pass # This property should be tested in test_slide.py
def test_wait_time_between_slides(self, base_slide: BaseSlide) -> None:
assert base_slide.wait_time_between_slides == 0.0
base_slide.wait_time_between_slides = 1.0
assert base_slide.wait_time_between_slides == 1.0
base_slide.wait_time_between_slides = -1.0
assert base_slide.wait_time_between_slides == 0.0
def test_play(self) -> None:
pass # This method should be tested in test_slide.py
def test_next_slide(self) -> None:
pass # This method should be tested in test_slide.py
def test_add_last_slide(self) -> None:
pass # This method should be tested in test_slide.py
def test_save_slides(self) -> None:
pass # This method should be tested in test_slide.py
def test_zoom(self) -> None:
pass # This method should be tested in test_slide.py
def test_wipe(self) -> None:
pass # This method should be tested in test_slide.py

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()

Some files were not shown because too many files have changed in this diff Show More