diff --git a/README.md b/README.md index 05fae52..b2a8571 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,70 @@ # jupyter-manim -%%mainm cell magic for IPython/Jupyter to show the output video +[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](http://choosealicense.com/licenses/mit/) + + +Integrates [manim](https://github.com/3b1b/manim) (animation engine for explanatory math videos) +with Jupyter displaying the resulting video when using `%%manim` cell magic to wrap a scene definition. + +### Quick preview + + + +The code in the example above comes from the excellent [manim tutorial](https://github.com/malhotra5/Manim-Tutorial). + +### Installation + +```sh +pip3 install jupyter-manim +``` + +### Usage + +Your arguments will be passed to manim, exactly as if these were command line options. + +For example, to render scene defined with class `Shapes(Scene)` use + +```python +%%manim Shapes +from manimlib.scene.scene import Scene +from manimlib.mobject.geometry import Circle +from manimlib.animation.creation import ShowCreation + +class Shapes(Scene): + + def construct(self): + circle = Circle() + self.play(ShowCreation(circle)) +``` + +NOTE: currently the code has to be self-contained as it will be run in a separate namespace. +Thus, all the imports have to be contained in your cell. + +In future, an option to export the current namespace (or specific variables) will be added. +It could be implemented by pickling the Python locals and globals and then pre-pending the cell with an un-pickling script (PRs welcome!). + +In the latest version of manimlib (not yet released) you will be able to import everything at once using: + +```python +from manimlib.imports import * +``` + + +To display manim help and options use: + +``` +%%manim -h +pass +``` + + + +The `%%manim` magic (by default) hides the progress bars as well as other logging messages generated by manim. +You can disable this behaviour using `--verbose` flag + +#### Video player control options + + - `--no-control` - hides the controls + - `--no-autoplay` - disables the autoplay feature + - `-r` or `--resolution` - control the height and width of the video player; + this option is shared with manim and requires the resolution in following format: + `height,width`, e.g. `%%manim Shapes -r 200,1000` diff --git a/jupyter_manim/__init__.py b/jupyter_manim/__init__.py new file mode 100644 index 0000000..2426ce8 --- /dev/null +++ b/jupyter_manim/__init__.py @@ -0,0 +1,155 @@ +from IPython.core.magic import Magics, magics_class, cell_magic +from unittest.mock import patch +from tempfile import NamedTemporaryFile +import manimlib +from IPython.display import HTML +import sys +from io import StringIO +from contextlib import ExitStack, suppress, redirect_stdout, redirect_stderr +from warnings import warn +from IPython import get_ipython +from pathlib import Path + +std_out = sys.stdout + + +def video(path, width=854, height=480, controls=True, autoplay=True): + return HTML(f""" + + """) + + +class StringIOWithCallback(StringIO): + + def __init__(self, callback, **kwargs): + super().__init__(**kwargs) + self.callback = callback + + def write(self, s): + super().write(s) + self.callback(s) + + +@magics_class +class ManimMagics(Magics): + path_line_start = 'File ready at ' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.defaults = { + 'autoplay': True, + 'controls': True, + 'silent': True, + 'width': 854, + 'height': 480 + } + + video_settings = {'width', 'height', 'controls', 'autoplay'} + magic_off_switches = { + 'verbose': 'silent', + 'no-controls': 'controls', + 'no-autoplay': 'autoplay' + } + + @cell_magic + def manim(self, line, cell): + # execute the code - won't generate any video, however it will introduce + # the variables into the notebook's namespace (enabling autocompletion etc); + # this also allows users to get feedback on some code errors early on + get_ipython().ex(cell) + + user_args = line.split(' ') + + # path of the output video + path = None + + settings = self.defaults.copy() + + # disable the switches as indicated by the user + for key, arg in self.magic_off_switches.items(): + if '--' + key in user_args: + user_args.remove('--' + key) + settings[arg] = False + + resolution_index = ( + user_args.index('-r') if '-r' in user_args else + user_args.index('--resolution') if '--resolution' in user_args else + None + ) + if resolution_index is not None: + # the resolution is passed as "height,width" + try: + h, w = user_args[resolution_index + 1].split(',') + settings['height'] = h + settings['width'] = w + except (IndexError, KeyError): + warn('Unable to retrieve dimensions from your resolution setting, falling back to the defaults') + + silent = settings['silent'] + + def catch_path_and_forward(lines): + nonlocal path + for line in lines.split('\n'): + if not silent: + print(line, file=std_out) + + if line.startswith(self.path_line_start): + path = line[len(self.path_line_start):].strip() + + with NamedTemporaryFile('w', suffix='.py') as f: + f.write(cell) + f.flush() + + args = ['manim', f.name, *user_args] + + stdout = StringIOWithCallback(catch_path_and_forward) + + with ExitStack() as stack: + + enter = stack.enter_context + + enter(patch.object(sys, 'argv', args)) + enter(suppress(SystemExit)) + enter(redirect_stdout(stdout)) + + if silent: + stderr = StringIO() + enter(redirect_stderr(stderr)) + + manimlib.main() + + if path: + path = Path(path) + assert path.exists() + + # To display a video in Jupyter, we need to have access to it + # so it has to be within the working tree. The absolute paths + # are usually outside of the accessible range. + relative_path = path.relative_to(Path.cwd()) + + video_settings = { + k: v + for k, v in settings.items() + if k in self.video_settings + } + + return video(relative_path, **video_settings) + else: + warn('Could not find path in the manim output') + + # If we were silent, some errors could have been silenced too. + if silent: + # Let's break the silence: + print(stdout.getvalue()) + print(stderr.getvalue(), file=sys.stderr) + + +ip = get_ipython() +ip.register_magics(ManimMagics) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8d85f5c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +IPython +manimlib diff --git a/screenshots/cell_magic_demo.png b/screenshots/cell_magic_demo.png new file mode 100644 index 0000000..98f23ec Binary files /dev/null and b/screenshots/cell_magic_demo.png differ diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..b88034e --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[metadata] +description-file = README.md diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..f2ed8db --- /dev/null +++ b/setup.py @@ -0,0 +1,49 @@ +from setuptools import setup +from setuptools import find_packages + + +try: + from pypandoc import convert + + def get_long_description(file_name): + return convert(file_name, 'rst', 'md') + +except ImportError: + + def get_long_description(file_name): + with open(file_name) as f: + return f.read() + + +if __name__ == '__main__': + setup( + name='jupyter_manim', + packages=find_packages(), + version='0.1', + license='MIT', + description='Cell magic rendering displaying videos in Jupyter/IPython', + long_description=get_long_description('README.md'), + author='Michal Krassowski', + author_email='krassowski.michal+pypi@gmail.com', + url='https://github.com/krassowski/jupyter-manim', + download_url='https://github.com/krassowski/jupyter-manim/tarball/v0.1', + keywords=['jupyter', 'jupyterlab', 'notebook', 'manim', 'manimlib'], + classifiers=[ + 'Development Status :: 4 - Beta', + 'License :: OSI Approved :: MIT License', + 'Framework :: IPython', + 'Framework :: Jupyter', + 'Operating System :: Microsoft :: Windows', + 'Operating System :: POSIX :: Linux', + 'Topic :: Utilities', + 'Topic :: Software Development :: User Interfaces', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Intended Audience :: Developers', + 'Intended Audience :: Science/Research', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7' + ], + install_requires=[ + 'manimlib', 'IPython' + ], + )