Merge pull request #2262 from 3b1b/video-work

Refactor scene creation
This commit is contained in:
Grant Sanderson 2024-12-10 09:53:18 -06:00 committed by GitHub
commit 1a14a6bd0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 378 additions and 446 deletions

View file

@ -3,7 +3,7 @@ from manimlib import __version__
import manimlib.config
import manimlib.logger
import manimlib.utils.init_config
from manimlib.reload_manager import reload_manager
from manimlib.reload_manager import ReloadManager
def main():
@ -22,7 +22,7 @@ def main():
manimlib.utils.init_config.init_customization()
return
reload_manager.args = args
reload_manager = ReloadManager(args)
reload_manager.run()

View file

@ -1,7 +1,6 @@
from __future__ import annotations
import argparse
from argparse import Namespace
import colour
import importlib
import inspect
@ -13,21 +12,15 @@ import yaml
from functools import lru_cache
from manimlib.logger import log
from manimlib.module_loader import ModuleLoader
from manimlib.utils.dict_ops import merge_dicts_recursively
from manimlib.utils.init_config import init_customization
from typing import TYPE_CHECKING
if TYPE_CHECKING:
Module = importlib.util.types.ModuleType
from argparse import Namespace
from typing import Optional
# This has to be here instead of in constants.py
# due to its use in creating the camera configuration
FRAME_HEIGHT: float = 8.0
def parse_cli():
try:
parser = argparse.ArgumentParser()
@ -144,12 +137,8 @@ def parse_cli():
)
parser.add_argument(
"-e", "--embed",
nargs="?",
const="",
help="Creates a new file where the line `self.embed` is inserted " + \
"into the Scenes construct method. " + \
"If a string is passed in, the line will be inserted below the " + \
"last line of code including that string."
"at the corresponding line number"
)
parser.add_argument(
"-r", "--resolution",
@ -210,93 +199,6 @@ def get_manim_dir():
return os.path.abspath(os.path.join(manimlib_dir, ".."))
def get_indent(line: str):
return len(line) - len(line.lstrip())
def get_module_with_inserted_embed_line(
file_name: str, scene_name: str, line_marker: str, is_during_reload
):
"""
This is hacky, but convenient. When user includes the argument "-e", it will try
to recreate a file that inserts the line `self.embed()` into the end of the scene's
construct method. If there is an argument passed in, it will insert the line after
the last line in the sourcefile which includes that string.
"""
with open(file_name, 'r') as fp:
lines = fp.readlines()
try:
scene_line_number = next(
i for i, line in enumerate(lines)
if line.startswith(f"class {scene_name}")
)
except StopIteration:
log.error(f"No scene {scene_name}")
return
prev_line_num = -1
n_spaces = None
if len(line_marker) == 0:
# Find the end of the construct method
in_construct = False
for index in range(scene_line_number, len(lines) - 1):
line = lines[index]
if line.lstrip().startswith("def construct"):
in_construct = True
n_spaces = get_indent(line) + 4
elif in_construct:
if len(line.strip()) > 0 and get_indent(line) < (n_spaces or 0):
prev_line_num = index - 1
break
if prev_line_num < 0:
prev_line_num = len(lines) - 1
elif line_marker.isdigit():
# Treat the argument as a line number
prev_line_num = int(line_marker) - 1
elif len(line_marker) > 0:
# Treat the argument as a string
try:
prev_line_num = next(
i
for i in range(scene_line_number, len(lines) - 1)
if line_marker in lines[i]
)
except StopIteration:
log.error(f"No lines matching {line_marker}")
sys.exit(2)
# Insert the embed line, rewrite file, then write it back when done
if n_spaces is None:
n_spaces = get_indent(lines[prev_line_num])
inserted_line = " " * n_spaces + "self.embed()\n"
new_lines = list(lines)
new_lines.insert(prev_line_num + 1, inserted_line)
new_file = file_name.replace(".py", "_insert_embed.py")
with open(new_file, 'w') as fp:
fp.writelines(new_lines)
module = ModuleLoader.get_module(new_file, is_during_reload)
# This is to pretend the module imported from the edited lines
# of code actually comes from the original file.
module.__file__ = file_name
os.remove(new_file)
return module
def get_scene_module(args: Namespace) -> Module:
if args.embed is None:
return ModuleLoader.get_module(args.file)
else:
is_reload = args.is_reload if hasattr(args, "is_reload") else False
return get_module_with_inserted_embed_line(
args.file, args.scene_names[0], args.embed, is_reload
)
def load_yaml(file_path: str):
try:
with open(file_path, "r") as file:
@ -343,8 +245,8 @@ def get_animations_numbers(args: Namespace) -> tuple[int | None, int | None]:
return int(stan), None
def get_output_directory(args: Namespace, custom_config: dict) -> str:
dir_config = custom_config["directories"]
def get_output_directory(args: Namespace, global_config: dict) -> str:
dir_config = global_config["directories"]
output_directory = args.video_dir or dir_config["output"]
if dir_config["mirror_module_path"] and args.file:
to_cut = dir_config["removed_mirror_prefix"]
@ -356,7 +258,7 @@ def get_output_directory(args: Namespace, custom_config: dict) -> str:
return output_directory
def get_file_writer_config(args: Namespace, custom_config: dict) -> dict:
def get_file_writer_config(args: Namespace, global_config: dict) -> dict:
result = {
"write_to_movie": not args.skip_animations and args.write_file,
"save_last_frame": args.skip_animations and args.write_file,
@ -364,13 +266,13 @@ def get_file_writer_config(args: Namespace, custom_config: dict) -> dict:
# If -t is passed in (for transparent), this will be RGBA
"png_mode": "RGBA" if args.transparent else "RGB",
"movie_file_extension": get_file_ext(args),
"output_directory": get_output_directory(args, custom_config),
"output_directory": get_output_directory(args, global_config),
"file_name": args.file_name,
"input_file_path": args.file or "",
"open_file_upon_completion": args.open,
"show_file_location_upon_completion": args.finder,
"quiet": args.quiet,
**custom_config["file_writer_config"],
**global_config["file_writer_config"],
}
if args.vcodec:
@ -387,32 +289,11 @@ def get_file_writer_config(args: Namespace, custom_config: dict) -> dict:
return result
def get_window_config(args: Namespace, custom_config: dict, camera_config: dict) -> dict:
# Default to making window half the screen size
# but make it full screen if -f is passed in
try:
monitors = screeninfo.get_monitors()
except screeninfo.ScreenInfoError:
# Default fallback
monitors = [screeninfo.Monitor(width=1920, height=1080)]
mon_index = custom_config["window_monitor"]
monitor = monitors[min(mon_index, len(monitors) - 1)]
aspect_ratio = camera_config["pixel_width"] / camera_config["pixel_height"]
window_width = monitor.width
if not (args.full_screen or custom_config["full_screen"]):
window_width //= 2
window_height = int(window_width / aspect_ratio)
return dict(size=(window_width, window_height))
def get_resolution(args: Optional[Namespace] = None, global_config: Optional[dict] = None):
args = args or parse_cli()
global_config = global_config or get_global_config()
def get_camera_config(args: Optional[Namespace] = None, custom_config: Optional[dict] = None) -> dict:
if args is None:
args = parse_cli()
if custom_config is None:
custom_config = get_global_config()
camera_config = dict()
camera_resolutions = custom_config["camera_resolutions"]
camera_resolutions = global_config["camera_resolutions"]
if args.resolution:
resolution = args.resolution
elif args.low_quality:
@ -426,33 +307,53 @@ def get_camera_config(args: Optional[Namespace] = None, custom_config: Optional[
else:
resolution = camera_resolutions[camera_resolutions["default_resolution"]]
if args.fps:
fps = int(args.fps)
else:
fps = custom_config["fps"]
width_str, height_str = resolution.split("x")
width = int(width_str)
height = int(height_str)
return int(width_str), int(height_str)
camera_config.update({
def get_window_config(args: Namespace, global_config: dict) -> dict:
# Default to making window half the screen size
# but make it full screen if -f is passed in
try:
monitors = screeninfo.get_monitors()
except screeninfo.ScreenInfoError:
# Default fallback
monitors = [screeninfo.Monitor(width=1920, height=1080)]
mon_index = global_config["window_monitor"]
monitor = monitors[min(mon_index, len(monitors) - 1)]
width, height = get_resolution(args, global_config)
aspect_ratio = width / height
window_width = monitor.width
if not (args.full_screen or global_config["full_screen"]):
window_width //= 2
window_height = int(window_width / aspect_ratio)
return dict(size=(window_width, window_height))
def get_camera_config(args: Optional[Namespace] = None, global_config: Optional[dict] = None) -> dict:
args = args or parse_cli()
global_config = global_config or get_global_config()
width, height = get_resolution(args, global_config)
fps = int(args.fps or global_config["fps"])
camera_config = {
"pixel_width": width,
"pixel_height": height,
"frame_config": {
"frame_shape": ((width / height) * FRAME_HEIGHT, FRAME_HEIGHT),
},
"fps": fps,
})
}
try:
bg_color = args.color or custom_config["style"]["background_color"]
bg_color = args.color or global_config["style"]["background_color"]
camera_config["background_color"] = colour.Color(bg_color)
except ValueError as err:
log.error("Please use a valid color")
log.error(err)
sys.exit(2)
# If rendering a transparent image/move, make sure the
# If rendering a transparent image/movie, make sure the
# scene has a background opacity of 0
if args.transparent:
camera_config["background_opacity"] = 0
@ -466,17 +367,15 @@ def get_scene_config(args: Namespace) -> dict:
"""
global_config = get_global_config()
camera_config = get_camera_config(args, global_config)
window_config = get_window_config(args, global_config, camera_config)
file_writer_config = get_file_writer_config(args, global_config)
start, end = get_animations_numbers(args)
return {
"file_writer_config": get_file_writer_config(args, global_config),
"file_writer_config": file_writer_config,
"camera_config": camera_config,
"window_config": window_config,
"skip_animations": args.skip_animations,
"start_at_animation_number": start,
"end_at_animation_number": end,
"preview": not args.write_file,
"presenter_mode": args.presenter_mode,
"leave_progress_bars": args.leave_progress_bars,
"show_animation_progress": args.show_animation_progress,
@ -486,10 +385,15 @@ def get_scene_config(args: Namespace) -> dict:
def get_run_config(args: Namespace):
window_config = get_window_config(args, get_global_config())
return {
"module": get_scene_module(args),
"file_name": args.file,
"embed_line": int(args.embed) if args.embed is not None else None,
"is_reload": False,
"prerun": args.prerun,
"scene_names": args.scene_names,
"quiet": args.quiet or args.write_all,
"write_all": args.write_all,
"window_config": window_config,
"show_in_window": not args.write_file
}

View file

@ -1,8 +1,7 @@
from __future__ import annotations
import numpy as np
from manimlib.config import get_camera_config
from manimlib.config import FRAME_HEIGHT
from manimlib.config import get_resolution
from typing import TYPE_CHECKING
if TYPE_CHECKING:
@ -13,18 +12,19 @@ if TYPE_CHECKING:
# TODO, it feels a bit unprincipled to have some global constants
# depend on the output of this function, and for all that configuration
# code to be run merely upon importing from this file.
CAMERA_CONFIG = get_camera_config()
DEFAULT_RESOLUTION: tuple[int, int] = get_resolution()
DEFAULT_PIXEL_WIDTH = DEFAULT_RESOLUTION[0]
DEFAULT_PIXEL_HEIGHT = DEFAULT_RESOLUTION[1]
DEFAULT_FPS: int = 30
# Sizes relevant to default camera frame
ASPECT_RATIO: float = CAMERA_CONFIG['pixel_width'] / CAMERA_CONFIG['pixel_height']
ASPECT_RATIO: float = DEFAULT_PIXEL_WIDTH / DEFAULT_PIXEL_HEIGHT
FRAME_HEIGHT: float = 8.0
FRAME_WIDTH: float = FRAME_HEIGHT * ASPECT_RATIO
FRAME_SHAPE: tuple[float, float] = (FRAME_WIDTH, FRAME_HEIGHT)
FRAME_Y_RADIUS: float = FRAME_HEIGHT / 2
FRAME_X_RADIUS: float = FRAME_WIDTH / 2
DEFAULT_PIXEL_HEIGHT: int = CAMERA_CONFIG['pixel_height']
DEFAULT_PIXEL_WIDTH: int = CAMERA_CONFIG['pixel_width']
DEFAULT_FPS: int = 30
SMALL_BUFF: float = 0.1
MED_SMALL_BUFF: float = 0.25

View file

@ -58,3 +58,4 @@ camera_resolutions:
fps: 30
embed_exception_mode: "Verbose"
embed_error_sound: False
ignore_manimlib_modules_on_reload: True

View file

@ -1,12 +1,21 @@
from __future__ import annotations
import copy
import inspect
import sys
from manimlib.module_loader import ModuleLoader
from manimlib.config import get_global_config
from manimlib.logger import log
from manimlib.scene.interactive_scene import InteractiveScene
from manimlib.scene.scene import Scene
from typing import TYPE_CHECKING
if TYPE_CHECKING:
Module = importlib.util.types.ModuleType
from typing import Optional
class BlankScene(InteractiveScene):
def construct(self):
@ -115,10 +124,60 @@ def get_scene_classes_from_module(module):
]
def get_indent(code_lines: list[str], line_number: int) -> str:
"""
Find the indent associated with a given line of python code,
as a string of spaces
"""
# Find most recent non-empty line
try:
line = next(filter(lambda line: line.strip(), code_lines[line_number - 1::-1]))
except StopIteration:
return ""
# Either return its leading spaces, or add for if it ends with colon
n_spaces = len(line) - len(line.lstrip())
if line.endswith(":"):
n_spaces += 4
return n_spaces * " "
def insert_embed_line_to_module(module: Module, line_number: int):
"""
This is hacky, but convenient. When user includes the argument "-e", it will try
to recreate a file that inserts the line `self.embed()` into the end of the scene's
construct method. If there is an argument passed in, it will insert the line after
the last line in the sourcefile which includes that string.
"""
lines = inspect.getsource(module).splitlines()
# Add the relevant embed line to the code
indent = get_indent(lines, line_number)
lines.insert(line_number, indent + "self.embed()")
new_code = "\n".join(lines)
# Execute the code, which presumably redefines the user's
# scene to include this embed line, within the relevant module.
code_object = compile(new_code, module.__name__, 'exec')
exec(code_object, module.__dict__)
def get_scene_module(file_name: Optional[str], embed_line: Optional[int], is_reload: bool = False) -> Module:
module = ModuleLoader.get_module(file_name, is_reload)
if embed_line:
insert_embed_line_to_module(module, embed_line)
return module
def main(scene_config, run_config):
if run_config["module"] is None:
module = get_scene_module(
run_config["file_name"],
run_config["embed_line"],
run_config["is_reload"]
)
if module is None:
# If no module was passed in, just play the blank scene
return [BlankScene(**scene_config)]
all_scene_classes = get_scene_classes_from_module(run_config["module"])
all_scene_classes = get_scene_classes_from_module(module)
return get_scenes_to_render(all_scene_classes, scene_config, run_config)

View file

@ -11,4 +11,4 @@ logging.basicConfig(
)
log = logging.getLogger("manimgl")
log.setLevel("DEBUG")
log.setLevel("WARNING")

View file

@ -715,21 +715,6 @@ class Mobject(object):
self.become(self.saved_state)
return self
def save_to_file(self, file_path: str) -> Self:
with open(file_path, "wb") as fp:
fp.write(self.serialize())
log.info(f"Saved mobject to {file_path}")
return self
@staticmethod
def load(file_path) -> Mobject:
if not os.path.exists(file_path):
log.error(f"No file found at {file_path}")
sys.exit(2)
with open(file_path, "rb") as fp:
mobject = pickle.load(fp)
return mobject
def become(self, mobject: Mobject, match_updaters=False) -> Self:
"""
Edit all data and submobjects to be idential

View file

@ -6,12 +6,11 @@ import os
import sys
import sysconfig
from manimlib.config import get_global_config
from manimlib.logger import log
Module = importlib.util.types.ModuleType
IGNORE_MANIMLIB_MODULES = True
class ModuleLoader:
"""
@ -76,10 +75,7 @@ class ModuleLoader:
builtins.__import__ = tracked_import
try:
# Remove the "_insert_embed" suffix from the module name
module_name = module.__name__
if module.__name__.endswith("_insert_embed"):
module_name = module_name[:-13]
log.debug('Reloading module "%s"', module_name)
spec.loader.exec_module(module)
@ -146,7 +142,8 @@ class ModuleLoader:
Only user-defined modules are reloaded, see `is_user_defined_module()`.
"""
if IGNORE_MANIMLIB_MODULES and module.__name__.startswith("manimlib"):
ignore_manimlib_modules = get_global_config()["ignore_manimlib_modules_on_reload"]
if ignore_manimlib_modules and module.__name__.startswith("manimlib"):
return
if not hasattr(module, "__dict__"):

View file

@ -1,7 +1,20 @@
from __future__ import annotations
from typing import Any
from IPython.terminal.embed import KillEmbedded
import manimlib.config
import manimlib.extract_scene
from manimlib.window import Window
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from argparse import Namespace
class ReloadManager:
"""
Manages the loading and running of scenes and is called directly from the
@ -12,20 +25,17 @@ class ReloadManager:
command in the IPython shell.
"""
args: Any = None
scenes: list[Any] = []
window = None
# The line number to load the scene from when reloading
start_at_line = None
is_reload = False
def __init__(self, cli_args: Namespace):
self.args = cli_args
def set_new_start_at_line(self, start_at_line):
"""
Sets/Updates the line number to load the scene from when reloading.
"""
self.start_at_line = start_at_line
self.args.embed = str(start_at_line)
def run(self):
"""
@ -34,61 +44,43 @@ class ReloadManager:
while True:
try:
# blocking call since a scene will init an IPython shell()
self.retrieve_scenes_and_run(self.start_at_line)
self.retrieve_scenes_and_run()
return
except KillEmbedded:
# Requested via the `exit_raise` IPython runline magic
# by means of our scene.reload() command
for scene in self.scenes:
scene.tear_down()
self.scenes = []
self.is_reload = True
self.note_reload()
except KeyboardInterrupt:
break
def retrieve_scenes_and_run(self, overwrite_start_at_line: int | None = None):
def note_reload(self):
self.is_reload = True
print(" ".join([
"Reloading interactive session for",
f"\033[96m{self.args.scene_names[0]}\033[0m",
f"at line \033[96m{self.args.embed}\033[0m"
]))
def retrieve_scenes_and_run(self):
"""
Creates a new configuration based on the CLI args and runs the scenes.
"""
import manimlib.config
import manimlib.extract_scene
# Args
if self.args is None:
raise RuntimeError("Fatal error: No args were passed to the ReloadManager")
if overwrite_start_at_line is not None:
self.args.embed = str(overwrite_start_at_line)
# Args to Config
self.args.is_reload = self.is_reload
scene_config = manimlib.config.get_scene_config(self.args)
if self.window:
scene_config["existing_window"] = self.window # see scene initialization
scene_config.update(reload_manager=self)
run_config = manimlib.config.get_run_config(self.args)
run_config.update(is_reload=self.is_reload)
# Create or reuse window
if run_config["show_in_window"] and not self.window:
self.window = Window(**run_config["window_config"])
scene_config.update(window=self.window)
# Scenes
self.scenes = manimlib.extract_scene.main(scene_config, run_config)
if len(self.scenes) == 0:
scenes = manimlib.extract_scene.main(scene_config, run_config)
if len(scenes) == 0:
print("No scenes found to run")
return
# Find first available window
for scene in self.scenes:
if scene.window is not None:
self.window = scene.window
break
for scene in self.scenes:
if self.args.embed:
print(" ".join([
"Loading interactive session for",
f"\033[96m{self.args.scene_names[0]}\033[0m",
f"in \033[96m{self.args.file}\033[0m",
f"at line \033[96m{self.args.embed}\033[0m"
]))
for scene in scenes:
scene.run()
reload_manager = ReloadManager()

View file

@ -460,12 +460,6 @@ class InteractiveScene(Scene):
nudge *= 10
self.selection.shift(nudge * vect)
def save_selection_to_file(self):
if len(self.selection) == 1:
self.save_mobject_to_file(self.selection[0])
else:
self.save_mobject_to_file(self.selection)
# Key actions
def on_key_press(self, symbol: int, modifiers: int) -> None:
super().on_key_press(symbol, modifiers)
@ -503,8 +497,6 @@ class InteractiveScene(Scene):
self.ungroup_selection()
elif char == "t" and (modifiers & (PygletWindowKeys.MOD_COMMAND | PygletWindowKeys.MOD_CTRL)):
self.toggle_selection_mode()
elif char == "s" and (modifiers & (PygletWindowKeys.MOD_COMMAND | PygletWindowKeys.MOD_CTRL)):
self.save_selection_to_file()
elif char == "d" and (modifiers & PygletWindowKeys.MOD_SHIFT):
self.copy_frame_positioning()
elif char == "c" and (modifiers & PygletWindowKeys.MOD_SHIFT):

View file

@ -1,41 +1,33 @@
from __future__ import annotations
from collections import OrderedDict
import inspect
import os
import platform
import pyperclip
import random
import time
import re
from functools import wraps
from IPython.terminal import pt_inputhooks
from IPython.terminal.embed import InteractiveShellEmbed
from pyglet.window import key as PygletWindowKeys
import numpy as np
from tqdm.auto import tqdm as ProgressDisplay
from manimlib.animation.animation import prepare_animation
from manimlib.animation.fading import VFadeInThenOut
from manimlib.camera.camera import Camera
from manimlib.camera.camera_frame import CameraFrame
from manimlib.module_loader import ModuleLoader
from manimlib.constants import ARROW_SYMBOLS
from manimlib.constants import DEFAULT_WAIT_TIME
from manimlib.constants import RED
from manimlib.event_handler import EVENT_DISPATCHER
from manimlib.event_handler.event_type import EventType
from manimlib.logger import log
from manimlib.reload_manager import reload_manager
from manimlib.mobject.frame import FullScreenRectangle
from manimlib.mobject.mobject import _AnimationBuilder
from manimlib.mobject.mobject import Group
from manimlib.mobject.mobject import Mobject
from manimlib.mobject.mobject import Point
from manimlib.mobject.types.vectorized_mobject import VGroup
from manimlib.mobject.types.vectorized_mobject import VMobject
from manimlib.scene.scene_embed import interactive_scene_embed
from manimlib.scene.scene_embed import CheckpointManager
from manimlib.scene.scene_file_writer import SceneFileWriter
from manimlib.utils.family_ops import extract_mobject_family_members
from manimlib.utils.family_ops import recursive_mobject_remove
@ -45,13 +37,14 @@ from manimlib.window import Window
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Callable, Iterable, TypeVar
from typing import Callable, Iterable, TypeVar, Optional
from manimlib.typing import Vect3
T = TypeVar('T')
from PIL.Image import Image
from manimlib.reload_manager import ReloadManager
from manimlib.animation.animation import Animation
@ -68,7 +61,6 @@ class Scene(object):
drag_to_pan: bool = True
max_num_saved_states: int = 50
default_camera_config: dict = dict()
default_window_config: dict = dict()
default_file_writer_config: dict = dict()
samples = 0
# Euler angles, in degrees
@ -76,7 +68,6 @@ class Scene(object):
def __init__(
self,
window_config: dict = dict(),
camera_config: dict = dict(),
file_writer_config: dict = dict(),
skip_animations: bool = False,
@ -84,45 +75,39 @@ class Scene(object):
start_at_animation_number: int | None = None,
end_at_animation_number: int | None = None,
leave_progress_bars: bool = False,
preview: bool = True,
window: Optional[Window] = None,
reload_manager: Optional[ReloadManager] = None,
presenter_mode: bool = False,
show_animation_progress: bool = False,
embed_exception_mode: str = "",
embed_error_sound: bool = False,
existing_window: Window | None = None,
):
self.skip_animations = skip_animations
self.always_update_mobjects = always_update_mobjects
self.start_at_animation_number = start_at_animation_number
self.end_at_animation_number = end_at_animation_number
self.leave_progress_bars = leave_progress_bars
self.preview = preview
self.presenter_mode = presenter_mode
self.show_animation_progress = show_animation_progress
self.embed_exception_mode = embed_exception_mode
self.embed_error_sound = embed_error_sound
self.reload_manager = reload_manager
self.camera_config = {**self.default_camera_config, **camera_config}
self.window_config = {**self.default_window_config, **window_config}
for config in self.camera_config, self.window_config:
config["samples"] = self.samples
self.file_writer_config = {**self.default_file_writer_config, **file_writer_config}
# Initialize window, if applicable (and reuse window if provided during
# reload by means of the ReloadManager)
if self.preview:
if existing_window:
self.window = existing_window
self.window.update_scene(self)
else:
self.window = Window(scene=self, **self.window_config)
self.camera_config["fps"] = 30 # Where's that 30 from?
self.camera_config["window"] = self.window
else:
self.window = None
self.window = window
if self.window:
self.window.init_for_scene(self)
# Make sure camera and Pyglet window sync
self.camera_config["fps"] = 30
# Core state of the scene
self.camera: Camera = Camera(**self.camera_config)
self.camera: Camera = Camera(
window=self.window,
samples=self.samples,
**self.camera_config
)
self.frame: CameraFrame = self.camera.frame
self.frame.reorient(*self.default_frame_orientation)
self.frame.make_orientation_default()
@ -136,6 +121,7 @@ class Scene(object):
self.skip_time: float = 0
self.original_skipping_status: bool = self.skip_animations
self.checkpoint_states: dict[str, list[tuple[Mobject, Mobject]]] = dict()
self.checkpoint_manager: CheckpointManager = CheckpointManager()
self.undo_stack = []
self.redo_stack = []
@ -220,80 +206,12 @@ class Scene(object):
close_scene_on_exit: bool = True,
show_animation_progress: bool = False,
) -> None:
if not self.preview:
# Embed is only relevant with a preview
if not self.window:
# Embed is only relevant for interactive development with a Window
return
self.stop_skipping()
self.update_frame(force_draw=True)
self.save_state()
self.show_animation_progress = show_animation_progress
# Create embedded IPython terminal configured to have access to
# the local namespace of the caller
caller_frame = inspect.currentframe().f_back
module = ModuleLoader.get_module(caller_frame.f_globals["__file__"])
shell = InteractiveShellEmbed(
user_module=module,
display_banner=False,
xmode=self.embed_exception_mode
)
self.shell = shell
# Add a few custom shortcuts to that local namespace
local_ns = dict(caller_frame.f_locals)
local_ns.update(
play=self.play,
wait=self.wait,
add=self.add,
remove=self.remove,
clear=self.clear,
focus=self.focus,
save_state=self.save_state,
reload=self.reload,
undo=self.undo,
redo=self.redo,
i2g=self.i2g,
i2m=self.i2m,
checkpoint_paste=self.checkpoint_paste,
touch=lambda: shell.enable_gui("manim"),
notouch=lambda: shell.enable_gui(None),
)
# Update the shell module with the caller's locals + shortcuts
module.__dict__.update(local_ns)
# Enables gui interactions during the embed
def inputhook(context):
while not context.input_is_ready():
if not self.is_window_closing():
self.update_frame(dt=0)
if self.is_window_closing():
shell.ask_exit()
pt_inputhooks.register("manim", inputhook)
shell.enable_gui("manim")
# Operation to run after each ipython command
def post_cell_func(*args, **kwargs):
if not self.is_window_closing():
self.update_frame(dt=0, force_draw=True)
shell.events.register("post_run_cell", post_cell_func)
# Flash border, and potentially play sound, on exceptions
def custom_exc(shell, etype, evalue, tb, tb_offset=None):
# Show the error don't just swallow it
shell.showtraceback((etype, evalue, tb), tb_offset=tb_offset)
if self.embed_error_sound:
os.system("printf '\a'")
rect = FullScreenRectangle().set_stroke(RED, 30).set_fill(opacity=0)
rect.fix_in_frame()
self.play(VFadeInThenOut(rect, run_time=0.5))
shell.set_custom_exc((Exception,), custom_exc)
# Launch shell
shell()
interactive_scene_embed(self)
# End scene when exiting an embed
if close_scene_on_exit:
@ -616,6 +534,7 @@ class Scene(object):
self.num_plays += 1
def begin_animations(self, animations: Iterable[Animation]) -> None:
all_mobjects = set(self.get_mobject_family_members())
for animation in animations:
animation.begin()
# Anything animated that's not already in the
@ -623,8 +542,9 @@ class Scene(object):
# animated mobjects that are in the family of
# those on screen, this can result in a restructuring
# of the scene.mobjects list, which is usually desired.
if animation.mobject not in self.get_mobject_family_members():
if animation.mobject not in all_mobjects:
self.add(animation.mobject)
all_mobjects = all_mobjects.union(animation.mobject.get_family())
def progress_through_animations(self, animations: Iterable[Animation]) -> None:
last_t = 0
@ -736,8 +656,6 @@ class Scene(object):
scene_state.restore_scene(self)
def save_state(self) -> None:
if not self.preview:
return
state = self.get_state()
if self.undo_stack and state.mobjects_match(self.undo_stack[-1]):
return
@ -770,37 +688,6 @@ class Scene(object):
revert to the state of the scene the first time this function
was called on a block of code starting with that comment.
"""
if self.shell is None or self.window is None:
raise Exception(
"Scene.checkpoint_paste cannot be called outside of " +
"an ipython shell"
)
pasted = pyperclip.paste()
lines = pasted.split("\n")
# Commented lines trigger saved checkpoints
if lines[0].lstrip().startswith("#"):
if lines[0] not in self.checkpoint_states:
self.checkpoint(lines[0])
else:
self.revert_to_checkpoint(lines[0])
# Copied methods of a scene are handled specially
# A bit hacky, yes, but convenient
method_pattern = r"^def\s+([a-zA-Z_]\w*)\s*\(self.*\):"
method_names = re.findall(method_pattern ,lines[0].strip())
if method_names:
method_name = method_names[0]
indent = " " * lines[0].index(lines[0].strip())
pasted = "\n".join([
# Remove self from function signature
re.sub(r"self(,\s*)?", "", lines[0]),
*lines[1:],
# Attach to scene via self.func_name = func_name
f"{indent}self.{method_name} = {method_name}"
])
# Keep track of skipping and progress bar status
self.skip_animations = skip
@ -811,7 +698,7 @@ class Scene(object):
self.camera.use_window_fbo(False)
self.file_writer.begin_insert()
self.shell.run_cell(pasted)
self.checkpoint_manager.checkpoint_paste(self)
if record:
self.file_writer.end_insert()
@ -820,37 +707,8 @@ class Scene(object):
self.stop_skipping()
self.show_animation_progress = prev_progress
def checkpoint(self, key: str):
self.checkpoint_states[key] = self.get_state()
def revert_to_checkpoint(self, key: str):
if key not in self.checkpoint_states:
log.error(f"No checkpoint at {key}")
return
all_keys = list(self.checkpoint_states.keys())
index = all_keys.index(key)
for later_key in all_keys[index + 1:]:
self.checkpoint_states.pop(later_key)
self.restore_state(self.checkpoint_states[key])
def clear_checkpoints(self):
self.checkpoint_states = dict()
def save_mobject_to_file(self, mobject: Mobject, file_path: str | None = None) -> None:
if file_path is None:
file_path = self.file_writer.get_saved_mobject_path(mobject)
if file_path is None:
return
mobject.save_to_file(file_path)
def load_mobject(self, file_name):
if os.path.exists(file_name):
path = file_name
else:
directory = self.file_writer.get_saved_mobject_directory()
path = os.path.join(directory, file_name)
return Mobject.load(path)
self.checkpoint_manager.clear_checkpoints()
def is_window_closing(self):
return self.window and (self.window.is_closing or self.quit_interaction)
@ -1006,20 +864,23 @@ class Scene(object):
If `start_at_line` is provided, the scene will be reloaded at that line
number. This corresponds to the `linemarker` param of the
`config.get_module_with_inserted_embed_line()` method.
`extract_scene.insert_embed_line_to_module()` method.
Before reload, the scene is cleared and the entire state is reset, such
that we can start from a clean slate. This is taken care of by the
ReloadManager, which will catch the error raised by the `exit_raise`
magic command that we invoke here.
Note that we cannot define a custom exception class for this error,
since the IPython kernel will swallow any exception. While we can catch
such an exception in our custom exception handler registered with the
`set_custom_exc` method, we cannot break out of the IPython shell by
this means.
"""
reload_manager.set_new_start_at_line(start_at_line)
self.shell.run_line_magic("exit_raise", "")
self.reload_manager.set_new_start_at_line(start_at_line)
shell = get_ipython()
if shell:
shell.run_line_magic("exit_raise", "")
def focus(self) -> None:
"""

View file

@ -0,0 +1,151 @@
import inspect
import pyperclip
import re
from IPython.terminal import pt_inputhooks
from IPython.terminal.embed import InteractiveShellEmbed
from manimlib.animation.fading import VFadeInThenOut
from manimlib.constants import RED
from manimlib.mobject.mobject import Mobject
from manimlib.mobject.frame import FullScreenRectangle
from manimlib.module_loader import ModuleLoader
def interactive_scene_embed(scene):
scene.stop_skipping()
scene.update_frame(force_draw=True)
shell = get_ipython_shell_for_embedded_scene(scene)
enable_gui(shell, scene)
ensure_frame_update_post_cell(shell, scene)
ensure_flash_on_error(shell, scene)
# Launch shell
shell()
def get_ipython_shell_for_embedded_scene(scene):
"""
Create embedded IPython terminal configured to have access to
the local namespace of the caller
"""
# Triple back should take us to the context in a user's scene definition
# which is calling "self.embed"
caller_frame = inspect.currentframe().f_back.f_back.f_back
# Update the module's namespace to include local variables
module = ModuleLoader.get_module(caller_frame.f_globals["__file__"])
module.__dict__.update(caller_frame.f_locals)
module.__dict__.update(get_shortcuts(scene))
return InteractiveShellEmbed(
user_module=module,
display_banner=False,
xmode=scene.embed_exception_mode
)
def get_shortcuts(scene):
"""
A few custom shortcuts useful to have in the interactive shell namespace
"""
return dict(
play=scene.play,
wait=scene.wait,
add=scene.add,
remove=scene.remove,
clear=scene.clear,
focus=scene.focus,
save_state=scene.save_state,
reload=scene.reload,
undo=scene.undo,
redo=scene.redo,
i2g=scene.i2g,
i2m=scene.i2m,
checkpoint_paste=scene.checkpoint_paste,
)
def enable_gui(shell, scene):
"""Enables gui interactions during the embed"""
def inputhook(context):
while not context.input_is_ready():
if not scene.is_window_closing():
scene.update_frame(dt=0)
if scene.is_window_closing():
shell.ask_exit()
pt_inputhooks.register("manim", inputhook)
shell.enable_gui("manim")
def ensure_frame_update_post_cell(shell, scene):
"""Ensure the scene updates its frame after each ipython cell"""
def post_cell_func(*args, **kwargs):
if not scene.is_window_closing():
scene.update_frame(dt=0, force_draw=True)
shell.events.register("post_run_cell", post_cell_func)
def ensure_flash_on_error(shell, scene):
"""Flash border, and potentially play sound, on exceptions"""
def custom_exc(shell, etype, evalue, tb, tb_offset=None):
# Show the error don't just swallow it
shell.showtraceback((etype, evalue, tb), tb_offset=tb_offset)
if scene.embed_error_sound:
os.system("printf '\a'")
rect = FullScreenRectangle().set_stroke(RED, 30).set_fill(opacity=0)
rect.fix_in_frame()
scene.play(VFadeInThenOut(rect, run_time=0.5))
shell.set_custom_exc((Exception,), custom_exc)
class CheckpointManager:
checkpoint_states: dict[str, list[tuple[Mobject, Mobject]]] = dict()
def checkpoint_paste(self, scene):
"""
Used during interactive development to run (or re-run)
a block of scene code.
If the copied selection starts with a comment, this will
revert to the state of the scene the first time this function
was called on a block of code starting with that comment.
"""
shell = get_ipython()
if shell is None:
return
code_string = pyperclip.paste()
checkpoint_key = self.get_leading_comment(code_string)
self.handle_checkpoint_key(scene, checkpoint_key)
shell.run_cell(code_string)
@staticmethod
def get_leading_comment(code_string: str):
leading_line = code_string.partition("\n")[0].lstrip()
if leading_line.startswith("#"):
return leading_line
return None
def handle_checkpoint_key(self, scene, key: str):
if key is None:
return
elif key in self.checkpoint_states:
# Revert to checkpoint
scene.restore_state(self.checkpoint_states[key])
# Clear out any saved states that show up later
all_keys = list(self.checkpoint_states.keys())
index = all_keys.index(key)
for later_key in all_keys[index + 1:]:
self.checkpoint_states.pop(later_key)
else:
self.checkpoint_states[key] = scene.get_state()
def clear_checkpoints(self):
self.checkpoint_states = dict()

View file

@ -146,39 +146,6 @@ class SceneFileWriter(object):
def get_movie_file_path(self) -> str:
return self.movie_file_path
def get_saved_mobject_directory(self) -> str:
return guarantee_existence(self.saved_mobject_directory)
def get_saved_mobject_path(self, mobject: Mobject) -> str | None:
directory = self.get_saved_mobject_directory()
files = os.listdir(directory)
default_name = str(mobject) + "_0.mob"
index = 0
while default_name in files:
default_name = default_name.replace(str(index), str(index + 1))
index += 1
if platform.system() == 'Darwin':
cmds = [
"osascript", "-e",
f"""
set chosenfile to (choose file name default name "{default_name}" default location "{directory}")
POSIX path of chosenfile
""",
]
process = sp.Popen(cmds, stdout=sp.PIPE)
file_path = process.stdout.read().decode("utf-8").split("\n")[0]
if not file_path:
return
else:
user_name = input(f"Enter mobject file name (default is {default_name}): ")
file_path = os.path.join(directory, user_name or default_name)
if os.path.exists(file_path) or os.path.exists(file_path + ".mob"):
if input(f"{file_path} already exists. Overwrite (y/n)? ") != "y":
return
if not file_path.endswith(".mob"):
file_path = file_path + ".mob"
return file_path
# Sound
def init_audio(self) -> None:
self.includes_sound: bool = False

View file

@ -14,7 +14,7 @@ from manimlib.constants import FRAME_SHAPE
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Callable, TypeVar
from typing import Callable, TypeVar, Optional
from manimlib.scene.scene import Scene
T = TypeVar("T")
@ -29,21 +29,22 @@ class Window(PygletWindow):
def __init__(
self,
scene: Scene,
scene: Optional[Scene] = None,
size: tuple[int, int] = (1280, 720),
samples: int = 0
):
scene.window = self
super().__init__(size=size, samples=samples)
self.scene = scene
self.default_size = size
self.default_position = self.find_initial_position(size)
self.pressed_keys = set()
self.size = size
self.update_scene(scene)
if self.scene:
self.init_for_scene(scene)
def update_scene(self, scene: Scene):
def init_for_scene(self, scene: Scene):
"""
Resets the state and updates the scene associated to this window.
@ -114,7 +115,7 @@ class Window(PygletWindow):
py: int,
relative: bool = False
) -> np.ndarray:
if not hasattr(self.scene, "frame"):
if self.scene is None or not hasattr(self.scene, "frame"):
return np.zeros(3)
pixel_shape = np.array(self.size)
@ -145,6 +146,8 @@ class Window(PygletWindow):
@note_undrawn_event
def on_mouse_motion(self, x: int, y: int, dx: int, dy: int) -> None:
super().on_mouse_motion(x, y, dx, dy)
if not self.scene:
return
point = self.pixel_coords_to_space_coords(x, y)
d_point = self.pixel_coords_to_space_coords(dx, dy, relative=True)
self.scene.on_mouse_motion(point, d_point)
@ -152,6 +155,8 @@ class Window(PygletWindow):
@note_undrawn_event
def on_mouse_drag(self, x: int, y: int, dx: int, dy: int, buttons: int, modifiers: int) -> None:
super().on_mouse_drag(x, y, dx, dy, buttons, modifiers)
if not self.scene:
return
point = self.pixel_coords_to_space_coords(x, y)
d_point = self.pixel_coords_to_space_coords(dx, dy, relative=True)
self.scene.on_mouse_drag(point, d_point, buttons, modifiers)
@ -159,18 +164,24 @@ class Window(PygletWindow):
@note_undrawn_event
def on_mouse_press(self, x: int, y: int, button: int, mods: int) -> None:
super().on_mouse_press(x, y, button, mods)
if not self.scene:
return
point = self.pixel_coords_to_space_coords(x, y)
self.scene.on_mouse_press(point, button, mods)
@note_undrawn_event
def on_mouse_release(self, x: int, y: int, button: int, mods: int) -> None:
super().on_mouse_release(x, y, button, mods)
if not self.scene:
return
point = self.pixel_coords_to_space_coords(x, y)
self.scene.on_mouse_release(point, button, mods)
@note_undrawn_event
def on_mouse_scroll(self, x: int, y: int, x_offset: float, y_offset: float) -> None:
super().on_mouse_scroll(x, y, x_offset, y_offset)
if not self.scene:
return
point = self.pixel_coords_to_space_coords(x, y)
offset = self.pixel_coords_to_space_coords(x_offset, y_offset, relative=True)
self.scene.on_mouse_scroll(point, offset, x_offset, y_offset)
@ -179,32 +190,44 @@ class Window(PygletWindow):
def on_key_press(self, symbol: int, modifiers: int) -> None:
self.pressed_keys.add(symbol) # Modifiers?
super().on_key_press(symbol, modifiers)
if not self.scene:
return
self.scene.on_key_press(symbol, modifiers)
@note_undrawn_event
def on_key_release(self, symbol: int, modifiers: int) -> None:
self.pressed_keys.difference_update({symbol}) # Modifiers?
super().on_key_release(symbol, modifiers)
if not self.scene:
return
self.scene.on_key_release(symbol, modifiers)
@note_undrawn_event
def on_resize(self, width: int, height: int) -> None:
super().on_resize(width, height)
if not self.scene:
return
self.scene.on_resize(width, height)
@note_undrawn_event
def on_show(self) -> None:
super().on_show()
if not self.scene:
return
self.scene.on_show()
@note_undrawn_event
def on_hide(self) -> None:
super().on_hide()
if not self.scene:
return
self.scene.on_hide()
@note_undrawn_event
def on_close(self) -> None:
super().on_close()
if not self.scene:
return
self.scene.on_close()
def is_key_pressed(self, symbol: int) -> bool: