diff --git a/manimlib/__main__.py b/manimlib/__main__.py index aa283b80..b5877f8e 100644 --- a/manimlib/__main__.py +++ b/manimlib/__main__.py @@ -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() diff --git a/manimlib/config.py b/manimlib/config.py index 4d0a7a99..19f731e6 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -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 } diff --git a/manimlib/constants.py b/manimlib/constants.py index 7850796c..4dcdb508 100644 --- a/manimlib/constants.py +++ b/manimlib/constants.py @@ -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 diff --git a/manimlib/default_config.yml b/manimlib/default_config.yml index 4c9f0c3a..6f4224c5 100644 --- a/manimlib/default_config.yml +++ b/manimlib/default_config.yml @@ -58,3 +58,4 @@ camera_resolutions: fps: 30 embed_exception_mode: "Verbose" embed_error_sound: False +ignore_manimlib_modules_on_reload: True diff --git a/manimlib/extract_scene.py b/manimlib/extract_scene.py index 15dd3e13..2b78c974 100644 --- a/manimlib/extract_scene.py +++ b/manimlib/extract_scene.py @@ -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) diff --git a/manimlib/logger.py b/manimlib/logger.py index 71567a1d..5ef12121 100644 --- a/manimlib/logger.py +++ b/manimlib/logger.py @@ -11,4 +11,4 @@ logging.basicConfig( ) log = logging.getLogger("manimgl") -log.setLevel("DEBUG") +log.setLevel("WARNING") diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index b95acdbb..63089e43 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -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 diff --git a/manimlib/module_loader.py b/manimlib/module_loader.py index 894f12a0..50fcf999 100644 --- a/manimlib/module_loader.py +++ b/manimlib/module_loader.py @@ -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__"): diff --git a/manimlib/reload_manager.py b/manimlib/reload_manager.py index e7887930..c0cff3e7 100644 --- a/manimlib/reload_manager.py +++ b/manimlib/reload_manager.py @@ -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() diff --git a/manimlib/scene/interactive_scene.py b/manimlib/scene/interactive_scene.py index eb54569a..e1a53ee0 100644 --- a/manimlib/scene/interactive_scene.py +++ b/manimlib/scene/interactive_scene.py @@ -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): diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index 34ee13d7..bd1b7be7 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -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: """ diff --git a/manimlib/scene/scene_embed.py b/manimlib/scene/scene_embed.py new file mode 100644 index 00000000..ff507dfd --- /dev/null +++ b/manimlib/scene/scene_embed.py @@ -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() diff --git a/manimlib/scene/scene_file_writer.py b/manimlib/scene/scene_file_writer.py index 5d7b0d2d..f98d68c8 100644 --- a/manimlib/scene/scene_file_writer.py +++ b/manimlib/scene/scene_file_writer.py @@ -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 diff --git a/manimlib/window.py b/manimlib/window.py index 4c7da0f5..e342b83d 100644 --- a/manimlib/window.py +++ b/manimlib/window.py @@ -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: