feat(lib): add Jupyter magic (#237)

* feat(lib): add Jupyter magic

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

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

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

* fix(lib): remove deleted module

* chore(lib): fix typing issues

* chore(docs): document magic

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

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

* fix(ci): install kernel

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

* chore(ci): add ipykernel

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Jérome Eertmans
2023-08-07 17:47:03 +02:00
committed by GitHub
parent f6f2e4090f
commit fb02764bb7
15 changed files with 3479 additions and 1901 deletions

View File

@ -46,8 +46,12 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install libcairo2-dev libpango1.0-dev ffmpeg freeglut3-dev
- name: Setup Pandoc
uses: nikeee/setup-pandoc@v1
- name: Install local Python package
run: poetry install --with docs
- name: Install IPython kernel
run: poetry run ipython kernel install --name "manim-slides" --user
- name: Restore cached media
id: cache-media-restore
uses: actions/cache/restore@v3

View File

@ -20,6 +20,7 @@ extensions = [
"sphinx.ext.intersphinx",
"sphinx.ext.viewcode",
# Additional
"nbsphinx",
"myst_parser",
"sphinxext.opengraph",
"sphinx_click",

View File

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

View File

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

View File

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

View File

@ -13,6 +13,20 @@ class module(ModuleType):
"manim_slides.slide", None, None, ["Slide", "ThreeDSlide"]
)
return getattr(module, name)
elif name == "ManimSlidesMagic":
module = __import__(
"manim_slides.ipython.ipython_magic", None, None, ["ManimSlidesMagic"]
)
magic = getattr(module, name)
from IPython import get_ipython
ipy = get_ipython()
if ipy is not None:
ipy.register_magics(magic)
return magic
return ModuleType.__getattribute__(self, name)
@ -43,6 +57,6 @@ new_module.__dict__.update(
"__path__": __path__,
"__doc__": __doc__,
"__version__": __version__,
"__all__": ("__version__", "Slide", "ThreeDSlide"),
"__all__": ("__version__", "ManimSlidesMagic", "Slide", "ThreeDSlide"),
}
)

View File

@ -44,7 +44,7 @@ def config_options(function: F) -> F:
def verbosity_option(function: F) -> F:
"""Wraps a function to add verbosity option."""
def callback(ctx: Context, param: Parameter, value: bool) -> None:
def callback(ctx: Context, param: Parameter, value: str) -> None:
if not value or ctx.resilient_parsing:
return

View File

@ -1,3 +1,4 @@
import mimetypes
import os
import platform
import subprocess
@ -85,21 +86,12 @@ def validate_config_option(
return config
def get_file_mime_type(file: Path) -> str:
ext = file.suffix.lower()
if ext == ".mp4":
return "video/mp4"
else:
return "video/webm"
def data_uri(file: Path) -> str:
"""
Reads a video and returns the corresponding data-uri.
"""
b64 = b64encode(file.read_bytes()).decode("ascii")
mime_type = get_file_mime_type(file)
mime_type = mimetypes.guess_type(file)[0] or "video/mp4"
return f"data:{mime_type};base64,{b64}"
@ -407,6 +399,9 @@ class RevealJS(Converter):
"""Converts 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
@ -419,7 +414,7 @@ class RevealJS(Converter):
logger.debug(f"Assets will be saved to: {full_assets_dir}")
os.makedirs(full_assets_dir, exist_ok=True)
full_assets_dir.mkdir(parents=True, exist_ok=True)
for presentation_config in self.presentation_configs:
presentation_config.concat_animations().copy_to(full_assets_dir)
@ -556,7 +551,7 @@ class PowerPoint(Converter):
):
file = presentation_config.files[slide_config.start_animation]
mime_type = get_file_mime_type(file)
mime_type = mimetypes.guess_type(file)[0]
if self.poster_frame_image is None:
poster_frame_image = save_first_image_from_video_file(file)

View File

@ -16,6 +16,17 @@ A directive for including Manim slides in a Sphinx document
When rendering the HTML documentation, the ``.. manim-slides::``
directive implemented here allows to include rendered videos.
This directive requires three additional dependencies:
``manim``, ``docutils`` and ``jinja2``. The last two are usually bundled
with Sphinx.
You can install them manually, or with the extra keyword:
pip install manim-slides[sphinx-directive]
Note that you will still need to install Manim's platform-specific dependencies,
see
`their installation page <https://docs.manim.community/en/stable/installation.html>`_.
Usage
-----

View File

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

View File

@ -40,6 +40,7 @@ def make_logger() -> logging.Logger:
)
logging.addLevelName(5, "PERF")
logger = logging.getLogger("manim-slides")
logger.setLevel(logging.getLogger("manim").level)
logger.addHandler(rich_handler)
return logger

View File

@ -1,8 +1,6 @@
import sys
from importlib.util import find_spec
from .utils import suppress_stdout
__all__ = [
# Constants
"FFMPEG_BIN",
@ -77,20 +75,19 @@ if MANIMGL:
from manimlib.logger import log as logger
else:
with suppress_stdout(): # Avoids printing "Manim Community v..."
from manim import (
LEFT,
AnimationGroup,
FadeIn,
FadeOut,
Mobject,
Scene,
ThreeDScene,
config,
logger,
)
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
try: # For manim<v0.16.0.post0
from manim.constants import FFMPEG_BIN
except ImportError:
FFMPEG_BIN = config.ffmpeg_executable

View File

@ -1,15 +0,0 @@
import os
import sys
from contextlib import contextmanager
from typing import Iterator
@contextmanager
def suppress_stdout() -> Iterator[None]:
with open(os.devnull, "w") as devnull:
old_stdout = sys.stdout
sys.stdout = devnull
try:
yield
finally:
sys.stdout = old_stdout

4893
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -48,9 +48,13 @@ version = "4.15.0"
[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"
@ -65,8 +69,10 @@ rtoml = "^0.9.0"
tqdm = "^4.64.1"
[tool.poetry.extras]
magic = ["manim", "ipython"]
manim = ["manim"]
manimgl = ["manimgl"]
sphinx-directive = ["docutils", "jinja2", "manim"]
[tool.poetry.group.dev.dependencies]
black = "^22.10.0"
@ -78,8 +84,11 @@ ruff = "^0.0.219"
[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"