Skip to content

Commit 41f6f05

Browse files
authored
Add interface to JS renderer (#465)
* Add interface to JS renderer * Update manim_directive.py * Add simple flag to get_style() * Change add_frames to add_frame * Update changelog.rst
1 parent de16dad commit 41f6f05

32 files changed

+3273
-330
lines changed

docs/source/changelog.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Command line
3232
#. Re-implement GIF export with the :code:`-i` flag (using this flag outputs ONLY a .gif file, and no .mp4 file)
3333
#. Added a :code:`--verbose` flag
3434
#. You can save the logs to a file by using :code:`--log_to_file`
35+
#. Add experimental javascript rendering with :code:`--use_js_renderer`
3536

3637

3738
Config system
@@ -62,6 +63,7 @@ Mobjects, Scenes, and Animations
6263
#. Add a :code:`Variable` class for displaying text that continuously updates to reflect the value of a python variable.
6364
#. The ``Tex`` and ``MathTex`` objects allow you to specify a custom TexTemplate using the ``template`` keyword argument.
6465
#. :code:`VGroup` now supports printing the class names of contained mobjects and :code:`VDict` supports printing the internal dict of mobjects
66+
#. :code:`Scene` now renders when :code:`Scene.render()` is called rather than upon instantiation.
6567
#. :code:`ValueTracker` now supports increment using the `+=` operator (in addition to the already existing `increment_value` method)
6668

6769

docs/source/manim_directive.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ def run(self):
192192
"from manim import *",
193193
*file_writer_config_code,
194194
*user_code,
195-
f"{clsname}()",
195+
f"{clsname}().render()",
196196
]
197197
exec("\n".join(code), globals())
198198

manim/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
from .scene.graph_scene import *
5757
from .scene.moving_camera_scene import *
5858
from .scene.reconfigurable_scene import *
59+
from .scene.js_scene import *
5960
from .scene.scene import *
6061
from .scene.sample_space_scene import *
6162
from .scene.three_d_scene import *

manim/__main__.py

Lines changed: 16 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,17 @@
99
import types
1010

1111
from . import constants, logger, console, file_writer_config
12-
from .config.config import args
12+
from .config.config import camera_config, args
1313
from .config import cfg_subcmds
14+
from .utils.module_ops import (
15+
get_module,
16+
get_scene_classes_from_module,
17+
get_scenes_to_render,
18+
)
1419
from .scene.scene import Scene
1520
from .utils.sounds import play_error_sound, play_finish_sound
1621
from .utils.file_ops import open_file as open_media_file
17-
from . import constants
22+
from .grpc.impl import frame_server_impl
1823

1924

2025
def open_file_if_needed(file_writer):
@@ -50,104 +55,6 @@ def open_file_if_needed(file_writer):
5055
sys.stdout = curr_stdout
5156

5257

53-
def is_child_scene(obj, module):
54-
return (
55-
inspect.isclass(obj)
56-
and issubclass(obj, Scene)
57-
and obj != Scene
58-
and obj.__module__.startswith(module.__name__)
59-
)
60-
61-
62-
def prompt_user_for_choice(scene_classes):
63-
num_to_class = {}
64-
for count, scene_class in enumerate(scene_classes):
65-
count += 1 # start with 1 instead of 0
66-
name = scene_class.__name__
67-
console.print(f"{count}: {name}", style="logging.level.info")
68-
num_to_class[count] = scene_class
69-
try:
70-
user_input = console.input(
71-
f"[log.message] {constants.CHOOSE_NUMBER_MESSAGE} [/log.message]"
72-
)
73-
return [
74-
num_to_class[int(num_str)]
75-
for num_str in re.split(r"\s*,\s*", user_input.strip())
76-
]
77-
except KeyError:
78-
logger.error(constants.INVALID_NUMBER_MESSAGE)
79-
sys.exit(2)
80-
except EOFError:
81-
sys.exit(1)
82-
83-
84-
def get_scenes_to_render(scene_classes):
85-
if not scene_classes:
86-
logger.error(constants.NO_SCENE_MESSAGE)
87-
return []
88-
if file_writer_config["write_all"]:
89-
return scene_classes
90-
result = []
91-
for scene_name in file_writer_config["scene_names"]:
92-
found = False
93-
for scene_class in scene_classes:
94-
if scene_class.__name__ == scene_name:
95-
result.append(scene_class)
96-
found = True
97-
break
98-
if not found and (scene_name != ""):
99-
logger.error(constants.SCENE_NOT_FOUND_MESSAGE.format(scene_name))
100-
if result:
101-
return result
102-
return (
103-
[scene_classes[0]]
104-
if len(scene_classes) == 1
105-
else prompt_user_for_choice(scene_classes)
106-
)
107-
108-
109-
def get_scene_classes_from_module(module):
110-
return [
111-
member[1]
112-
for member in inspect.getmembers(module, lambda x: is_child_scene(x, module))
113-
]
114-
115-
116-
def get_module(file_name):
117-
if file_name == "-":
118-
# Since this feature is used for rapid testing, using Scene Caching would be a
119-
# hindrance in this case.
120-
file_writer_config["disable_caching"] = True
121-
module = types.ModuleType("input_scenes")
122-
logger.info(
123-
"Enter the animation's code & end with an EOF (CTRL+D on Linux/Unix, CTRL+Z on Windows):"
124-
)
125-
code = sys.stdin.read()
126-
if not code.startswith("from manim import"):
127-
logger.warning(
128-
"Didn't find an import statement for Manim. Importing automatically..."
129-
)
130-
code = "from manim import *\n" + code
131-
logger.info("Rendering animation from typed code...")
132-
try:
133-
exec(code, module.__dict__)
134-
return module
135-
except Exception as e:
136-
logger.error(f"Failed to render scene: {str(e)}")
137-
sys.exit(2)
138-
else:
139-
if os.path.exists(file_name):
140-
if file_name[-3:] != ".py":
141-
raise Exception(f"{file_name} is not a valid Manim python script.")
142-
module_name = file_name[:-3].replace(os.sep, ".").split(".")[-1]
143-
spec = importlib.util.spec_from_file_location(module_name, file_name)
144-
module = importlib.util.module_from_spec(spec)
145-
spec.loader.exec_module(module)
146-
return module
147-
else:
148-
raise FileNotFoundError(f"{file_name} not found")
149-
150-
15158
def main():
15259
if hasattr(args, "subcommands"):
15360
if "cfg" in args.subcommands:
@@ -169,15 +76,19 @@ def main():
16976
sound_on = file_writer_config["sound"]
17077
for SceneClass in scene_classes_to_render:
17178
try:
172-
# By invoking, this renders the full scene
173-
scene = SceneClass()
174-
open_file_if_needed(scene.file_writer)
175-
if sound_on:
176-
play_finish_sound()
79+
if camera_config["use_js_renderer"]:
80+
frame_server_impl.get(SceneClass).start()
81+
else:
82+
scene = SceneClass()
83+
scene.render()
84+
open_file_if_needed(scene.file_writer)
85+
if sound_on:
86+
play_finish_sound()
17787
except Exception:
17888
print("\n\n")
17989
traceback.print_exc()
18090
print("\n\n")
91+
if not camera_config["use_js_renderer"]:
18192
if sound_on:
18293
play_error_sound()
18394

manim/camera/camera.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,6 @@ def __init__(self, background=None, **kwargs):
104104
# corresponding class. If a Mobject is not an instance of a class in
105105
# this dict (or an instance of a class that inherits from a class in
106106
# this dict), then it cannot be rendered.
107-
self.display_funcs = {
108-
VMobject: self.display_multiple_vectorized_mobjects,
109-
PMobject: self.display_multiple_point_cloud_mobjects,
110-
AbstractImageMobject: self.display_multiple_image_mobjects,
111-
Mobject: lambda batch, pa: batch, # Do nothing
112-
}
113107

114108
self.init_background()
115109
self.resize_frame_shape()
@@ -149,6 +143,12 @@ def type_or_raise(self, mobject):
149143
:exc:`TypeError`
150144
When mobject is not an instance of a class that can be rendered.
151145
"""
146+
self.display_funcs = {
147+
VMobject: self.display_multiple_vectorized_mobjects,
148+
PMobject: self.display_multiple_point_cloud_mobjects,
149+
AbstractImageMobject: self.display_multiple_image_mobjects,
150+
Mobject: lambda batch, pa: batch, # Do nothing
151+
}
152152
# We have to check each type in turn because we are dealing with
153153
# super classes. For example, if square = Square(), then
154154
# type(square) != VMobject, but isinstance(square, VMobject) == True.
@@ -358,6 +358,9 @@ def reset(self):
358358
self.set_pixel_array(self.background)
359359
return self
360360

361+
def set_frame_to_background(self, background):
362+
self.set_pixel_array(background)
363+
361364
####
362365

363366
# TODO, it's weird that this is part of camera.

manim/camera/js_camera.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from .camera import Camera
2+
import copy
3+
4+
5+
class JsCamera(Camera):
6+
def __init__(self, **kwargs):
7+
super().__init__(self, **kwargs)
8+
self.serialized_frame = []
9+
self.pixel_array = None
10+
11+
def display_multiple_non_background_colored_vmobjects(self, vmobjects, _):
12+
for vmobject in vmobjects:
13+
# TODO: Store a proto instead of JSON.
14+
needs_redraw = False
15+
point_hash = hash(tuple(vmobject.points.flatten()))
16+
if vmobject.point_hash != point_hash:
17+
vmobject.point_hash = point_hash
18+
needs_redraw = True
19+
self.serialized_frame.append(
20+
{
21+
"points": vmobject.points.tolist(),
22+
"style": vmobject.get_style(simple=True),
23+
"id": id(vmobject),
24+
"needs_redraw": needs_redraw,
25+
}
26+
)
27+
28+
def reset(self):
29+
self.serialized_frame = []
30+
31+
def set_frame_to_background(self, background):
32+
self.serialized_frame = copy.deepcopy(background)

manim/config/config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,14 @@ def _parse_config(config_parser, args):
112112
background_color = colour.Color(default["background_color"])
113113
config["background_color"] = background_color
114114

115+
config["use_js_renderer"] = args.use_js_renderer or default.getboolean(
116+
"use_js_renderer"
117+
)
118+
119+
config["js_renderer_path"] = args.js_renderer_path or default.get(
120+
"js_renderer_path"
121+
)
122+
115123
# Set the rest of the frame properties
116124
config["frame_height"] = 8.0
117125
config["frame_width"] = (
@@ -151,6 +159,8 @@ def _parse_config(config_parser, args):
151159
if not (hasattr(args, "subcommands")):
152160
_init_dirs(file_writer_config)
153161
config = _parse_config(config_parser, args)
162+
if config["use_js_renderer"]:
163+
file_writer_config["disable_caching"] = True
154164
camera_config = config
155165

156166
# Set the different loggers

manim/config/config_utils.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,18 @@ def _parse_cli(arg_list, input=True):
391391
"the rendering at the second value",
392392
)
393393

394+
parser.add_argument(
395+
"--use_js_renderer",
396+
help="Render animations using the javascript frontend",
397+
action="store_const",
398+
const=True,
399+
)
400+
401+
parser.add_argument(
402+
"--js_renderer_path",
403+
help="Path to the javascript frontend",
404+
)
405+
394406
# Specify the manim.cfg file
395407
parser.add_argument(
396408
"--config_file",

manim/config/default.cfg

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,21 @@ media_dir = ./media
7878
# --log_dir (by default "/logs", that will be put inside the media dir)
7979
log_dir = logs
8080

81+
# # --video_dir
82+
# video_dir = %(MEDIA_DIR)s/videos
83+
84+
# # --tex_dir
85+
# tex_dir = %(MEDIA_DIR)s/Tex
86+
87+
# # --text_dir
88+
# text_dir = %(MEDIA_DIR)s/texts
89+
90+
# --use_js_renderer
91+
use_js_renderer = False
92+
93+
# --js_renderer_path
94+
js_renderer_path =
95+
8196
# If the -t (--transparent) flag is used, these will be replaced with the
8297
# values specified in the [TRANSPARENT] section later in this file.
8398
png_mode = RGB

manim/constants.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,9 @@ class MyText(Text):
120120
"CRITICAL": "fatal",
121121
}
122122
VERBOSITY_CHOICES = FFMPEG_VERBOSITY_MAP.keys()
123+
JS_RENDERER_INFO = (
124+
"The Electron frontend to Manim is hosted at "
125+
"https://github.com/ManimCommunity/manim-renderer. After cloning and building it, "
126+
"you can either start it prior to running Manim or specify the path to the "
127+
"executable with the --js_renderer_path flag."
128+
)

0 commit comments

Comments
 (0)