Compare commits

..

10 Commits

Author SHA1 Message Date
4e483525e4 chore(deps): pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.12.1 → v0.12.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.12.1...v0.12.3)
2025-07-14 18:41:17 +00:00
ba733eeb97 chore(docs): add example to gallery (#557)
* Add Antonio School presentation

* chore: document changes

* fix: heading

---------

Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
2025-07-02 19:11:34 +02:00
14813f7af1 chore(docs): add example to gallery (#556)
* Update gallery.md

* Update gallery.md

* Improved text

* capitalisation

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

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

* chore: rephrase

* chore: update changelog

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
2025-07-02 18:32:39 +02:00
2f62915ad6 chore(docs): remove redundant URL 2025-07-02 17:45:56 +02:00
c81e117bb8 chore(deps): pre-commit autoupdate (#555)
updates:
- [github.com/macisamuele/language-formatters-pre-commit-hooks: v2.14.0 → v2.15.0](https://github.com/macisamuele/language-formatters-pre-commit-hooks/compare/v2.14.0...v2.15.0)
- [github.com/astral-sh/ruff-pre-commit: v0.12.0 → v0.12.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.12.0...v0.12.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-07-01 16:29:37 +02:00
6255f644ab chore(deps): pre-commit autoupdate (#546)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.7 → v0.12.0](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.7...v0.12.0)
- [github.com/pre-commit/mirrors-mypy: v1.15.0 → v1.16.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.15.0...v1.16.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-06-24 12:23:08 +02:00
9e12feb275 chore(docs): add example to gallery (#552)
* Add example to gallery.

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

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

* chore(docs): gallery formatting.

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
2025-06-16 13:07:17 +02:00
d2d5cc10b7 chore(docs): enhance citation guidelines in the README 2025-06-13 10:26:40 +02:00
3c6e2db7db chore(deps): pre-commit autoupdate (#543)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.5 → v0.11.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.5...v0.11.7)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-04-29 09:38:26 +02:00
04b0eb5685 feat(lib): propagate manim render exit code (#545)
* feat: propagate `manim render` exit code

* changelog

* test

* fix typo
2025-04-29 09:37:55 +02:00
43 changed files with 210 additions and 385 deletions

View File

@ -13,7 +13,7 @@ repos:
- id: end-of-file-fixer - id: end-of-file-fixer
- id: trailing-whitespace - id: trailing-whitespace
- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
rev: v2.14.0 rev: v2.15.0
hooks: hooks:
- id: pretty-format-yaml - id: pretty-format-yaml
args: [--autofix] args: [--autofix]
@ -21,13 +21,13 @@ repos:
exclude: poetry.lock exclude: poetry.lock
args: [--autofix, --trailing-commas] args: [--autofix, --trailing-commas]
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.5 rev: v0.12.3
hooks: hooks:
- id: ruff - id: ruff
args: [--fix] args: [--fix]
- id: ruff-format - id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy - repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.15.0 rev: v1.16.1
hooks: hooks:
- id: mypy - id: mypy
additional_dependencies: [types-requests, types-setuptools] additional_dependencies: [types-requests, types-setuptools]

View File

@ -10,6 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(unreleased)= (unreleased)=
## [Unreleased](https://github.com/jeertmans/manim-slides/compare/v5.5.1...HEAD) ## [Unreleased](https://github.com/jeertmans/manim-slides/compare/v5.5.1...HEAD)
(unreleased-added)=
### Added
- `manim-slides render` now exits with the same return code as the one returned by `manim render` or `manimgl`.
[@chrjabs](https://github.com/chrjabs) [#545](https://github.com/jeertmans/manim-slides/pull/545)
(unreleased-chore)= (unreleased-chore)=
### Chore ### Chore
@ -18,10 +24,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
when using one of those extras, but as they were not part of the public API, when using one of those extras, but as they were not part of the public API,
we do not consider this to be a **breaking change**. we do not consider this to be a **breaking change**.
[#542](https://github.com/jeertmans/manim-slides/pull/542) [#542](https://github.com/jeertmans/manim-slides/pull/542)
- Moved `manim_slides.docs.manim_slides_directive` to `manim_slides.sphinxext.manim_slides_directive`. - Added example in the research section of the gallery.
This is a **breaking change** because documentation configs have [@mmcilree](https://github.com/mmcilree) [#552](https://github.com/jeertmans/manim-slides/pull/552)
to be updated. - Added example in the school work section of the gallery.
[#242](https://github.com/jeertmans/manim-slides/pull/242) [@casperalgera](https://github.com/casperalgera) [#556](https://github.com/jeertmans/manim-slides/pull/556)
- Added example in the school work section of the gallery.
[@amstrdm](https://github.com/amstrdm) [#557](https://github.com/jeertmans/manim-slides/pull/557)
(v5.5.1)= (v5.5.1)=
## [v5.5.1](https://github.com/jeertmans/manim-slides/compare/v5.5.0...v5.5.1) ## [v5.5.1](https://github.com/jeertmans/manim-slides/compare/v5.5.0...v5.5.1)

View File

@ -154,21 +154,23 @@ Below is a comparison of the most used ones with Manim Slides:
## Citing ## Citing
If you use this project, please cite it using the following reference: If you use this software, please cite it using as:
```bibtex ```bibtex
@article{Jerome_Eertmans_Manim_Slides_A_2023, @article{Jerome_Eertmans_Manim_Slides_A_2023,
title = {{Manim Slides: A Python package for presenting Manim content anywhere}}, title = {{Manim Slides: A Python package for presenting Manim content anywhere}},
author = {{Jérome Eertmans}}, author = {{Jérome Eertmans}},
year = 2023, year = 2023,
month = aug, month = aug,
journal = {Journal of Open Source Education}, journal = {Journal of Open Source Education},
volume = 6, volume = 6,
doi = {10.21105/jose.00206} doi = {10.21105/jose.00206}
} }
``` ```
or by linking this GitHub repository at the end of the presentation. or by linking this GitHub repository at the end of your presentation.
Other citation formats can be obtained by clicking on the *Cite this repository* button on this page.
## Contributing ## Contributing

View File

@ -37,7 +37,7 @@ extensions = [
"sphinx_copybutton", "sphinx_copybutton",
"sphinx_design", "sphinx_design",
# Custom # Custom
"manim_slides.sphinxext.manim_slides_directive", "manim_slides.docs.manim_slides_directive",
] ]
autodoc_typehints = "both" autodoc_typehints = "both"

View File

@ -92,7 +92,7 @@ This will also show the default value for each option.
If you want to create your own template, the best is to start from the default one. If you want to create your own template, the best is to start from the default one.
You can either download it from the You can either download it from the
[template folder](https://github.com/jeertmans/manim-slides/tree/main/manim_slides/cli/convert/templates) [template folder](https://github.com/jeertmans/manim-slides/tree/main/manim_slides/templates)
or use the `manim-slides convert --to=FORMAT --show-template` command, or use the `manim-slides convert --to=FORMAT --show-template` command,
where `FORMAT` is one of the supported formats. where `FORMAT` is one of the supported formats.

View File

@ -14,7 +14,7 @@ If you too have created content with Manim Slides that is available online
(e.g., a YouTube video or website), (e.g., a YouTube video or website),
don't hesitate to contact us so that we can share your content on this page! don't hesitate to contact us so that we can share your content on this page!
## Scientif Research ## Scientific Research
Below are people that dissimenate their research results Below are people that dissimenate their research results
using Manim Slides presentations. using Manim Slides presentations.
@ -23,7 +23,7 @@ using Manim Slides presentations.
Daniel publishes his presentations on *Cosmology, String Theory and related* Daniel publishes his presentations on *Cosmology, String Theory and related*
topics on his topics on his
[personal website](https://panopepino.github.io/web_page/main_page/slides.html). https://panopepino.github.io/web_page/main_page/slides.html [personal website](https://panopepino.github.io/web_page/main_page/slides.html).
For example, below are the slides of a seminar he gave titled For example, below are the slides of a seminar he gave titled
[Our Universe on a (Dark) Bubble](https://panopepino.github.io/web_page/main_page/presentations/2023_11_long/LS.html). [Our Universe on a (Dark) Bubble](https://panopepino.github.io/web_page/main_page/presentations/2023_11_long/LS.html).
@ -67,9 +67,66 @@ For example, below are the slides of his
</iframe> </iframe>
</div> </div>
### Matthew McIlree
Matthew is a Computer Science researcher from Scotland and has used Manim Slides
to present his work on *Proof Logging for Constraint Programming*. He also publishes
his presentation slides on his [personal website](https://matthewmcilree.com).
Here are the slides from a 25-minute talk he presented at the 39th Annual AAAI Conference on Artificial Intelligence titled
[Certifying Bounds Propagation for Integer Multiplication Constraints](https://matthewmcilree.com/files/slides/mcilree_aaai2025.html).
<div style="position:relative;padding-bottom:56.25%;">
<iframe
loading="lazy"
style="width:100%;height:100%;position:absolute;left:0px;top:0px;"
frameborder="1"
width="100%"
height="100%"
allowfullscreen
allow="autoplay"
src="https://matthewmcilree.com/files/slides/mcilree_aaai2025.html">
</iframe>
</div>
## School Work ## School Work
Below are people that used Manim Slides for school presentations. Below are people that used Manim Slides for school presentations.
*This list is currently empty. Please reach out to us if you have examples ### Antonio Caserta
to share!*
Antonio is a 17-year-old high school student from Germany who used Manim Slides to present his final project, *Episteme*, an AI financial terminal that uses crowdsourced data from social networks to gather stock insights.
The slides from his 30-minute presentation to the school board can be found below and on his [Github repository](https://github.com/amstrdm/episteme-manim-slides)
<div style="position:relative;padding-bottom:56.25%;">
<iframe
loading="lazy"
style="width:100%;height:100%;position:absolute;left:0px;top:0px;"
frameborder="1"
width="100%"
height="100%"
allowfullscreen
allow="autoplay"
src="https://amstrdm.github.io/episteme-manim-slides/">
</iframe>
</div>
### Casper Algera
Casper, a mathematics student from the Netherlands, used Manim Slides to present his bachelor's thesis.
In his presentation, he illustrates a probabilistic coupling argument related to the [contact process](https://en.wikipedia.org/wiki/Contact_process_(mathematics)).
His slides are available below, and his full presentation can be viewed on [YouTube](https://www.youtube.com/watch?v=ZJhvfCL5MWE).
<div style="position:relative;padding-bottom:56.25%;">
<iframe
loading="lazy"
style="width:100%;height:100%;position:absolute;left:0px;top:0px;"
frameborder="1"
width="100%"
height="100%"
allowfullscreen
allow="autoplay"
src="https://casperalgera.github.io/criticalvalueCP/">
</iframe>
</div>

View File

@ -4,7 +4,7 @@ This page contains an exhaustive list of all the commands available with `manim-
```{eval-rst} ```{eval-rst}
.. click:: manim_slides.cli.commands:main .. click:: manim_slides.__main__:cli
:prog: manim-slides :prog: manim-slides
:nested: full :nested: full
``` ```

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,11 @@
"""Manim Slides' main entrypoint.""" import json
import click
import requests
from click_default_group import DefaultGroup
from .__version__ import __version__ from .__version__ import __version__
from .checkhealth import checkhealth from .checkhealth import checkhealth
from .cli.commands import main
from .convert import convert from .convert import convert
from .logger import logger from .logger import logger
from .present import list_scenes, present from .present import list_scenes, present
@ -69,4 +72,4 @@ cli.add_command(render)
cli.add_command(wizard) cli.add_command(wizard)
if __name__ == "__main__": if __name__ == "__main__":
main() cli()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,9 +4,8 @@ from typing import Any, Callable
import click import click
from click import Context, Parameter from click import Context, Parameter
from ..core.config import list_presentation_configs from .defaults import CONFIG_PATH, FOLDER_PATH
from ..core.defaults import CONFIG_PATH, FOLDER_PATH from .logger import logger
from ..core.logger import logger
F = Callable[..., Any] F = Callable[..., Any]
Wrapper = Callable[[F], F] Wrapper = Callable[[F], F]
@ -89,68 +88,6 @@ def folder_path_option(function: F) -> F:
callback=callback, callback=callback,
help="Set slides folder.", help="Set slides folder.",
show_default=True, show_default=True,
is_eager=True, # Needed to expose its value to other callbacks
) )
return wrapper(function) return wrapper(function)
def scenes_argument(function: F) -> F:
"""
Wrap a function to add a scenes arguments.
This function assumes that :func:`folder_path_option` is also used
on the same decorated function.
"""
def callback(ctx: Context, param: Parameter, value: tuple[str]) -> list[Path]:
folder: Path = ctx.params.get("folder")
presentation_config_paths = list_presentation_configs(folder)
scene_names = [path.stem for path in presentation_config_paths]
num_scenes = len(scene_names)
num_digits = len(str(num_scenes))
if num_scenes == 0:
raise click.UsageError(
f"Folder {folder} does not contain "
"any valid config file, did you render the animations first?"
)
paths = []
if value:
for scene_name in value:
try:
i = scene_names.index(scene_name)
paths.append(presentation_config_paths[i])
except ValueError:
raise click.UsageError(
f"Could not find scene `{scene_name}` in: "
+ ", ".join(scene_names)
+ ". Did you make a typo or forgot to render the animations first?"
) from None
else:
click.echo(
"Choose at least one or more scenes from "
"(enter the corresponding number):\n"
+ "\n".join(
f"- {i:{num_digits}d}: {name}"
for i, name in enumerate(scene_names, start=1)
)
)
continue_prompt = True
while continue_prompt:
index = click.prompt(
"Please enter a value", type=click.IntRange(1, num_scenes)
)
paths.append(presentation_config_paths[index - 1])
continue_prompt = click.confirm(
"Do you want to enter an additional scene?"
)
return paths
wrapper: Wrapper = click.argument("scenes", nargs=-1, callback=callback)
return wrapper(function)

View File

@ -1,5 +1,3 @@
"""Manim Slides' configuration tools."""
import json import json
import shutil import shutil
from functools import wraps from functools import wraps
@ -15,7 +13,6 @@ from pydantic import (
FilePath, FilePath,
PositiveInt, PositiveInt,
PrivateAttr, PrivateAttr,
ValidationError,
conset, conset,
field_serializer, field_serializer,
field_validator, field_validator,
@ -29,54 +26,28 @@ Receiver = Callable[..., Any]
class Signal(BaseModel): # type: ignore[misc] class Signal(BaseModel): # type: ignore[misc]
"""Signal that notifies a list of receivers when it is emitted.""" __receivers: list[Receiver] = PrivateAttr(default_factory=list)
__receivers: set[Receiver] = PrivateAttr(default_factory=set)
def connect(self, receiver: Receiver) -> None: def connect(self, receiver: Receiver) -> None:
""" self.__receivers.append(receiver)
Connect a receiver to this signal.
This is a no-op if the receiver was already connected to this signal.
:param receiver: The receiver to connect.
"""
self.__receivers.add(receiver)
def disconnect(self, receiver: Receiver) -> None: def disconnect(self, receiver: Receiver) -> None:
""" self.__receivers.remove(receiver)
Disconnect a receiver from this signal.
This is a no-op if the receiver was not connected to this signal.
:param receiver: The receiver to disconnect.
"""
self.__receivers.discard(receiver)
def emit(self, *args: Any) -> None: def emit(self, *args: Any) -> None:
"""
Emit this signal and call each of the attached receivers.
:param args: Positional arguments passed to each receiver.
"""
for receiver in self.__receivers: for receiver in self.__receivers:
receiver(*args) receiver(*args)
def key_id(name: str) -> PositiveInt: def key_id(name: str) -> PositiveInt:
""" """Avoid importing Qt too early."""
Return the id corresponding to the given key name. from qtpy.QtCore import Qt
:param str: The name of the key, e.g., 'Q'.
:return: The corresponding id.
"""
from qtpy.QtCore import Qt # Avoid importing Qt too early."""
return getattr(Qt, f"Key_{name}") return getattr(Qt, f"Key_{name}")
class Key(BaseModel): # type: ignore[misc] class Key(BaseModel): # type: ignore[misc]
"""Represent a list of key codes, with optionally a name.""" """Represents a list of key codes, with optionally a name."""
ids: conset(PositiveInt, min_length=1) # type: ignore[valid-type] ids: conset(PositiveInt, min_length=1) # type: ignore[valid-type]
name: Optional[str] = None name: Optional[str] = None
@ -87,7 +58,6 @@ class Key(BaseModel): # type: ignore[misc]
self.ids = set(ids) self.ids = set(ids)
def match(self, key_id: int) -> bool: def match(self, key_id: int) -> bool:
"""Return whether a given key id matches this key."""
m = key_id in self.ids m = key_id in self.ids
if m: if m:
@ -165,7 +135,6 @@ class Config(BaseModel): # type: ignore[misc]
"""General Manim Slides config.""" """General Manim Slides config."""
keys: Keys = Field(default_factory=Keys) keys: Keys = Field(default_factory=Keys)
"""The key mapping."""
@classmethod @classmethod
def from_file(cls, path: Path) -> "Config": def from_file(cls, path: Path) -> "Config":
@ -173,16 +142,11 @@ class Config(BaseModel): # type: ignore[misc]
return cls.model_validate(rtoml.load(path)) # type: ignore return cls.model_validate(rtoml.load(path)) # type: ignore
def to_file(self, path: Path) -> None: def to_file(self, path: Path) -> None:
"""Dump this configuration to a file.""" """Dump the configuration to a file."""
rtoml.dump(self.model_dump(), path, pretty=True) rtoml.dump(self.model_dump(), path, pretty=True)
def merge_with(self, other: "Config") -> "Config": def merge_with(self, other: "Config") -> "Config":
""" """Merge with another config."""
Merge with another config.
:param other: The other config to be merged with.
:return: This config, updated.
"""
self.keys = self.keys.merge_with(other.keys) self.keys = self.keys.merge_with(other.keys)
return self return self
@ -191,17 +155,11 @@ class BaseSlideConfig(BaseModel): # type: ignore
"""Base class for slide config.""" """Base class for slide config."""
loop: bool = False loop: bool = False
"""Whether this slide should loop."""
auto_next: bool = False auto_next: bool = False
"""Whether this slide is skipped upon completion."""
playback_rate: float = 1.0 playback_rate: float = 1.0
"""The speed at which the animation is played (1.0 is normal)."""
reversed_playback_rate: float = 1.0 reversed_playback_rate: float = 1.0
"""The speed at which the reversed animation is played."""
notes: str = "" notes: str = ""
"""The notes attached to this slide."""
dedent_notes: bool = True dedent_notes: bool = True
"""Whether to automatically remove any leading indentation in the notes."""
skip_animations: bool = False skip_animations: bool = False
src: Optional[FilePath] = None src: Optional[FilePath] = None
@ -215,11 +173,7 @@ class BaseSlideConfig(BaseModel): # type: ignore
The wrapped function must follow two criteria: The wrapped function must follow two criteria:
- its last parameter must be ``**kwargs`` (or equivalent); - its last parameter must be ``**kwargs`` (or equivalent);
- and its second last parameter must be ``<arg_name>``. - and its second last parameter must be ``<arg_name>``.
:param arg_name: The name of the argument.
:return: The wrapped function.
""" """
# TODO: improve docs and (maybe) type-hints too
def _wrapper_(fun: Callable[..., Any]) -> Callable[..., Any]: def _wrapper_(fun: Callable[..., Any]) -> Callable[..., Any]:
@wraps(fun) @wraps(fun)
@ -255,11 +209,6 @@ class BaseSlideConfig(BaseModel): # type: ignore
def apply_dedent_notes( def apply_dedent_notes(
self, self,
) -> "BaseSlideConfig": ) -> "BaseSlideConfig":
"""
Remove indentation from notes, if specified.
:return: The config, optionally modified.
"""
if self.dedent_notes: if self.dedent_notes:
self.notes = dedent(self.notes) self.notes = dedent(self.notes)
@ -270,9 +219,7 @@ class PreSlideConfig(BaseSlideConfig):
"""Slide config to be used prior to rendering.""" """Slide config to be used prior to rendering."""
start_animation: int start_animation: int
"""The index of the first animation."""
end_animation: int end_animation: int
"""The index after the last animation."""
@classmethod @classmethod
def from_base_slide_config_and_animation_indices( def from_base_slide_config_and_animation_indices(
@ -281,13 +228,6 @@ class PreSlideConfig(BaseSlideConfig):
start_animation: int, start_animation: int,
end_animation: int, end_animation: int,
) -> "PreSlideConfig": ) -> "PreSlideConfig":
"""
Create a config from a base config and animation indices.
:param base_slide_config: The base config.
:param start_animation: The index of the first animation.
:param end_animation: The index after the last animation.
"""
return cls( return cls(
start_animation=start_animation, start_animation=start_animation,
end_animation=end_animation, end_animation=end_animation,
@ -297,12 +237,6 @@ class PreSlideConfig(BaseSlideConfig):
@field_validator("start_animation", "end_animation") @field_validator("start_animation", "end_animation")
@classmethod @classmethod
def index_is_posint(cls, v: int) -> int: def index_is_posint(cls, v: int) -> int:
"""
Validate that animation indices are positive integers.
:param v: An animation index.
:return: The animation index, if valid.
"""
if v < 0: if v < 0:
raise ValueError("Animation index (start or end) cannot be negative") raise ValueError("Animation index (start or end) cannot be negative")
return v return v
@ -311,11 +245,7 @@ class PreSlideConfig(BaseSlideConfig):
def start_animation_is_before_end( def start_animation_is_before_end(
self, self,
) -> "PreSlideConfig": ) -> "PreSlideConfig":
""" if self.start_animation > self.end_animation:
Validate that start and end animation indices satisfy 'start < end'.
:return: The config, if indices are valid.
"""
raise ValueError( raise ValueError(
"Start animation index must be strictly lower than end animation index" "Start animation index must be strictly lower than end animation index"
) )
@ -349,9 +279,7 @@ class SlideConfig(BaseSlideConfig):
"""Slide config to be used after rendering.""" """Slide config to be used after rendering."""
file: FilePath file: FilePath
"""The file containing the animation."""
rev_file: FilePath rev_file: FilePath
"""The file containing the reversed animation."""
@classmethod @classmethod
def from_pre_slide_config_and_files( def from_pre_slide_config_and_files(
@ -361,22 +289,13 @@ class SlideConfig(BaseSlideConfig):
class PresentationConfig(BaseModel): # type: ignore[misc] class PresentationConfig(BaseModel): # type: ignore[misc]
"""Presentation config that contains all necessary information for a presentation."""
slides: list[SlideConfig] = Field(min_length=1) slides: list[SlideConfig] = Field(min_length=1)
"""The non-empty list of slide configs."""
resolution: tuple[PositiveInt, PositiveInt] = (1920, 1080) resolution: tuple[PositiveInt, PositiveInt] = (1920, 1080)
"""The resolution of the animation files."""
background_color: Color = "black" background_color: Color = "black"
"""The background color of the animation files."""
@classmethod @classmethod
def from_file(cls, path: Path) -> "PresentationConfig": def from_file(cls, path: Path) -> "PresentationConfig":
""" """Read a presentation configuration from a file."""
Read a presentation configuration from a file.
:param path: The path where the config is read from.
"""
with open(path) as f: with open(path) as f:
obj = json.load(f) obj = json.load(f)
@ -393,11 +312,7 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
return cls.model_validate(obj) # type: ignore return cls.model_validate(obj) # type: ignore
def to_file(self, path: Path) -> None: def to_file(self, path: Path) -> None:
""" """Dump the presentation configuration to a file."""
Dump the presentation configuration to a file.
:param path: The path to save this config.
"""
with open(path, "w") as f: with open(path, "w") as f:
f.write(self.model_dump_json(indent=2)) f.write(self.model_dump_json(indent=2))
@ -408,14 +323,7 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
include_reversed: bool = True, include_reversed: bool = True,
prefix: str = "", prefix: str = "",
) -> None: ) -> None:
""" """Copy the files to a given directory."""
Copy the files to a given directory and return the corresponding configuration.
:param folder: The folder that will contain the animation files.
:param use_cached: Whether caching should be used to avoid copies when possible.
:param include_reversed: Whether to also copy reversed animation to the folder.
:param prefix: Optional prefix added to each file name.
"""
for slide_config in self.slides: for slide_config in self.slides:
file = slide_config.file file = slide_config.file
rev_file = slide_config.rev_file rev_file = slide_config.rev_file
@ -427,6 +335,4 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
shutil.copy(file, dest) shutil.copy(file, dest)
if include_reversed and (not use_cached or not rev_dest.exists()): if include_reversed and (not use_cached or not rev_dest.exists()):
# TODO: if include_reversed is False, then rev_dev will likely not exist
# and this will cause an issue when decoding.
shutil.copy(rev_file, rev_dest) shutil.copy(rev_file, rev_dest)

View File

@ -37,18 +37,14 @@ from pydantic_core import CoreSchema, core_schema
from pydantic_extra_types.color import Color from pydantic_extra_types.color import Color
from tqdm import tqdm from tqdm import tqdm
from ...core.config import PresentationConfig
from ...core.logger import logger
from ..commons import folder_path_option, scenes_argument, verbosity_option
from . import templates from . import templates
from .commons import folder_path_option, verbosity_option
from .config import PresentationConfig
from .logger import logger
from .present import get_scenes_presentation_config
def open_with_default(file: Path) -> None: def open_with_default(file: Path) -> None:
"""
Open a file with the default application.
:param file: The file to open.
"""
system = platform.system() system = platform.system()
if system == "Darwin": if system == "Darwin":
subprocess.call(("open", str(file))) subprocess.call(("open", str(file)))
@ -146,7 +142,6 @@ class Str(str):
# This fixes pickling issue on Python 3.8 # This fixes pickling issue on Python 3.8
__reduce_ex__ = str.__reduce_ex__ __reduce_ex__ = str.__reduce_ex__
# TODO: do we still need this?
@classmethod @classmethod
def __get_pydantic_core_schema__( def __get_pydantic_core_schema__(
@ -552,11 +547,6 @@ class RevealJS(Converter):
return resources.files(templates).joinpath("revealjs.html").read_text() return resources.files(templates).joinpath("revealjs.html").read_text()
def open(self, file: Path) -> None: def open(self, file: Path) -> None:
"""
Open the HTML file inside a web browser.
:param path: The path to the HTML file.
"""
webbrowser.open(file.absolute().as_uri()) webbrowser.open(file.absolute().as_uri())
def convert_to(self, dest: Path) -> None: # noqa: C901 def convert_to(self, dest: Path) -> None: # noqa: C901
@ -920,7 +910,7 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
@click.command() @click.command()
@scenes_argument @click.argument("scenes", nargs=-1)
@folder_path_option @folder_path_option
@click.argument("dest", type=click.Path(dir_okay=False, path_type=Path)) @click.argument("dest", type=click.Path(dir_okay=False, path_type=Path))
@click.option( @click.option(
@ -970,7 +960,7 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
@show_config_options @show_config_options
@verbosity_option @verbosity_option
def convert( def convert(
scenes: list[Path], scenes: list[str],
folder: Path, folder: Path,
dest: Path, dest: Path,
to: str, to: str,
@ -981,7 +971,7 @@ def convert(
one_file: bool, one_file: bool,
) -> None: ) -> 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 = [PresentationConfig.from_file(scene) for scene in scenes] presentation_configs = get_scenes_presentation_config(scenes, folder)
try: try:
if to == "auto": if to == "auto":

View File

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

View File

@ -40,7 +40,7 @@ First, you must include the directive in the Sphinx configuration file:
extensions = [ extensions = [
# ... # ...
"manim_slides.sphinxext.manim_slides_directive", "manim_slides.docs.manim_slides_directive",
] ]
Its basic usage that allows processing **inline content** Its basic usage that allows processing **inline content**

View File

@ -116,7 +116,7 @@ class ManimSlidesMagic(Magics): # type: ignore
file) will be moved relative to the video locations. Use-cases include building file) will be moved relative to the video locations. Use-cases include building
documentation with Sphinx and JupyterBook. See also the documentation with Sphinx and JupyterBook. See also the
:mod:`Manim Slides directive for Sphinx :mod:`Manim Slides directive for Sphinx
<manim_slides.sphinxext.manim_slides_directive>`. <manim_slides.docs.manim_slides_directive>`.
Examples Examples
-------- --------

View File

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

View File

@ -7,14 +7,9 @@ import click
from click import Context, Parameter from click import Context, Parameter
from pydantic import ValidationError from pydantic import ValidationError
from ...core.config import Config, PresentationConfig, list_presentation_configs from ..commons import config_path_option, folder_path_option, verbosity_option
from ...core.logger import logger from ..config import Config, PresentationConfig
from ..commons import ( from ..logger import logger
config_path_option,
folder_path_option,
scenes_argument,
verbosity_option,
)
@click.command() @click.command()
@ -23,10 +18,8 @@ from ..commons import (
@verbosity_option @verbosity_option
def list_scenes(folder: Path) -> None: def list_scenes(folder: Path) -> None:
"""List available scenes.""" """List available scenes."""
scene_names = [path.stem for path in list_presentation_configs(folder)] for i, scene in enumerate(_list_scenes(folder), start=1):
num_digits = len(str(len(scene_names))) click.secho(f"{i}: {scene}", fg="green")
for i, scene_name in enumerate(scene_names, start=1):
click.secho(f"{i:{num_digits}d}: {scene_name}", fg="green")
def _list_scenes(folder: Path) -> list[str]: def _list_scenes(folder: Path) -> list[str]:
@ -137,7 +130,7 @@ def start_at_callback(
@click.command() @click.command()
@scenes_argument @click.argument("scenes", nargs=-1)
@config_path_option @config_path_option
@folder_path_option @folder_path_option
@click.option("--start-paused", is_flag=True, help="Start paused.") @click.option("--start-paused", is_flag=True, help="Start paused.")
@ -283,7 +276,7 @@ def present( # noqa: C901
if skip_all: if skip_all:
exit_after_last_slide = True exit_after_last_slide = True
presentation_configs = [PresentationConfig.from_file(path) for path in scenes] presentation_configs = get_scenes_presentation_config(scenes, folder)
if config_path.exists(): if config_path.exists():
try: try:

View File

@ -1,4 +1,14 @@
"""Manim Slides' rendering commands.""" """
Alias command to either
``manim render [OPTIONS] [ARGS]...`` or
``manimgl -w [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 subprocess
import sys import sys
@ -34,22 +44,11 @@ def render(ce: bool, gl: bool, args: tuple[str, ...]) -> None:
Use ``manim-slides render --help`` to see help information for Use ``manim-slides render --help`` to see help information for
a specific renderer. a specific renderer.
Alias command to either
``manim render [OPTIONS] [ARGS]...`` or
``manimgl [OPTIONS] [ARGS]...``.
This is especially useful for two reasons:
1. You can are sure to execute the rendering command with the same Python environment
as for ``manim-slides``.
2. You can pass options to the config.
""" """
if ce and gl: if ce and gl:
raise click.UsageError("You cannot specify both --CE and --GL renderers.") raise click.UsageError("You cannot specify both --CE and --GL renderers.")
if gl: if gl:
subprocess.run([sys.executable, "-m", "manimlib", "-w", *args]) completed = subprocess.run([sys.executable, "-m", "manimlib", "-w", *args])
else: else:
from manim.cli.render.commands import render as render_ce completed = subprocess.run([sys.executable, "-m", "manim", "render", *args])
sys.exit(completed.returncode)
render_ce(args, standalone_mode=False)

View File

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

View File

@ -1,8 +1,6 @@
"""Base class for the Slide class."""
from __future__ import annotations from __future__ import annotations
__all__ = ("BaseSlide",) __all__ = ["BaseSlide"]
import platform import platform
import shutil import shutil
@ -17,15 +15,10 @@ from typing import (
import numpy as np import numpy as np
from tqdm import tqdm from tqdm import tqdm
from ..core.config import ( from ..config import BaseSlideConfig, PresentationConfig, PreSlideConfig, SlideConfig
BaseSlideConfig, from ..defaults import FOLDER_PATH
PresentationConfig, from ..logger import logger
PreSlideConfig, from ..utils import concatenate_video_files, merge_basenames, reverse_video_file
SlideConfig,
)
from ..core.defaults import FOLDER_PATH
from ..core.logger import logger
from ..core.utils import concatenate_video_files, merge_basenames, reverse_video_file
from . import MANIM from . import MANIM
if TYPE_CHECKING: if TYPE_CHECKING:

View File

@ -1,5 +1,3 @@
"""Manim's implementation of the Slide class."""
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@ -7,7 +5,7 @@ from manim import Scene, ThreeDScene, config
from manim.renderer.opengl_renderer import OpenGLRenderer from manim.renderer.opengl_renderer import OpenGLRenderer
from manim.utils.color import rgba_to_color from manim.utils.color import rgba_to_color
from ..core.config import BaseSlideConfig from ..config import BaseSlideConfig
from .base import BaseSlide from .base import BaseSlide

View File

@ -1,5 +1,3 @@
"""ManimGL's implementation of the Slide class."""
from pathlib import Path from pathlib import Path
from typing import Any, ClassVar, Optional from typing import Any, ClassVar, Optional

View File

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

View File

@ -88,7 +88,7 @@ pyside6-full = ["manim-slides[full,pyside6]"]
sphinx-directive = ["docutils>=0.20.1", "manim-slides[manim]"] sphinx-directive = ["docutils>=0.20.1", "manim-slides[manim]"]
[project.scripts] [project.scripts]
manim-slides = "manim_slides.cli.commands:main" manim-slides = "manim_slides.__main__:cli"
[project.urls] [project.urls]
Changelog = "https://github.com/jeertmans/manim-slides/releases" Changelog = "https://github.com/jeertmans/manim-slides/releases"
@ -208,9 +208,10 @@ filterwarnings = [
] ]
[tool.ruff] [tool.ruff]
extend-exclude = ["manim_slides/cli/resources.py"] extend-exclude = ["manim_slides/resources.py"]
extend-include = ["*.ipynb"] extend-include = ["*.ipynb"]
line-length = 88 line-length = 88
target-version = "py39"
[tool.ruff.lint] [tool.ruff.lint]
extend-ignore = [ extend-ignore = [

View File

@ -42,3 +42,8 @@ class BasicSlide(Slide):
class BasicSlideSkipReversing(BasicSlide): class BasicSlideSkipReversing(BasicSlide):
skip_reversing = True skip_reversing = True
class FailingSlide(Slide):
def construct(self):
self.play("this fails to render")

View File

@ -113,6 +113,37 @@ def test_render_basic_slide(
assert local_presentation_config.resolution == presentation_config.resolution assert local_presentation_config.resolution == presentation_config.resolution
@pytest.mark.parametrize(
"renderer",
[
"--CE",
pytest.param(
"--GL",
marks=pytest.mark.skipif(
sys.version_info < (3, 10),
reason="See https://github.com/3b1b/manim/issues/2263.",
),
),
"--CE --renderer=opengl",
],
ids=("CE", "GL", "CE(GL)"),
)
def test_render_failing_slide(
renderer: str,
slides_file: Path,
manimgl_config: Path,
) -> None:
runner = CliRunner()
with runner.isolated_filesystem() as tmp_dir:
shutil.copy(manimgl_config, tmp_dir)
results = runner.invoke(
render, [*renderer.split(" "), str(slides_file), "FailingSlide", "-ql"]
)
assert results.exit_code != 0, results
def test_clear_cache( def test_clear_cache(
slides_file: Path, slides_file: Path,
) -> None: ) -> None: