diff --git a/docs/source/documentation/constants.rst b/docs/source/documentation/constants.rst index 7dabd50e..81ec4937 100644 --- a/docs/source/documentation/constants.rst +++ b/docs/source/documentation/constants.rst @@ -18,7 +18,7 @@ Frame and pixel shape DEFAULT_PIXEL_HEIGHT = 1080 DEFAULT_PIXEL_WIDTH = 1920 - DEFAULT_FRAME_RATE = 30 + DEFAULT_FPS = 30 Buffs ----- diff --git a/docs/source/getting_started/configuration.rst b/docs/source/getting_started/configuration.rst index 9b1a147f..c34948b5 100644 --- a/docs/source/getting_started/configuration.rst +++ b/docs/source/getting_started/configuration.rst @@ -56,7 +56,7 @@ flag abbr function ``--start_at_animation_number START_AT_ANIMATION_NUMBER`` ``-n`` Start rendering not from the first animation, but from another, specified by its index. If you passing two comma separated values, e.g. "3,6", it will end the rendering at the second value. ``--embed LINENO`` ``-e`` Takes a line number as an argument, and results in the scene being called as if the line ``self.embed()`` was inserted into the scene code at that line number ``--resolution RESOLUTION`` ``-r`` Resolution, passed as "WxH", e.g. "1920x1080" -``--frame_rate FRAME_RATE`` Frame rate, as an integer +``--fps FPS`` Frame rate, as an integer ``--color COLOR`` ``-c`` Background color ``--leave_progress_bars`` Leave progress bars displayed in terminal ``--video_dir VIDEO_DIR`` Directory to write video diff --git a/example_scenes.py b/example_scenes.py index 6806147f..a871915f 100644 --- a/example_scenes.py +++ b/example_scenes.py @@ -77,10 +77,6 @@ class AnimatingMethods(Scene): # ".animate" syntax: self.play(grid.animate.shift(LEFT)) - # Alternatively, you can use the older syntax by passing the - # method and then the arguments to the scene's "play" function: - self.play(grid.shift, LEFT) - # Both of those will interpolate between the mobject's initial # state and whatever happens when you apply that method. # For this example, calling grid.shift(LEFT) would shift the diff --git a/manimlib/animation/animation.py b/manimlib/animation/animation.py index 03959329..c066f6d6 100644 --- a/manimlib/animation/animation.py +++ b/manimlib/animation/animation.py @@ -6,6 +6,7 @@ from manimlib.mobject.mobject import _AnimationBuilder from manimlib.mobject.mobject import Mobject from manimlib.utils.config_ops import digest_config from manimlib.utils.rate_functions import smooth +from manimlib.utils.rate_functions import squish_rate_func from manimlib.utils.simple_functions import clip from typing import TYPE_CHECKING @@ -23,6 +24,7 @@ DEFAULT_ANIMATION_LAG_RATIO = 0 class Animation(object): CONFIG = { "run_time": DEFAULT_ANIMATION_RUN_TIME, + "time_span": None, # Tuple of times, between which the animation will run "rate_func": smooth, "name": None, # Does this animation add or remove a mobject form the screen @@ -53,6 +55,12 @@ class Animation(object): # played. As much initialization as possible, # especially any mobject copying, should live in # this method + if self.time_span is not None: + start, end = self.time_span + self.run_time = max(end, self.run_time) + self.rate_func = squish_rate_func( + self.rate_func, start / self.run_time, end / self.run_time, + ) self.mobject.set_animating_status(True) self.starting_mobject = self.create_starting_mobject() if self.suspend_mobject_updating: @@ -166,6 +174,8 @@ class Animation(object): return self def get_run_time(self) -> float: + if self.time_span: + return max(self.run_time, self.time_span[1]) return self.run_time def set_rate_func(self, rate_func: Callable[[float], float]): diff --git a/manimlib/animation/transform.py b/manimlib/animation/transform.py index 37307a0c..3d69528d 100644 --- a/manimlib/animation/transform.py +++ b/manimlib/animation/transform.py @@ -68,10 +68,11 @@ class Transform(Animation): self.target_copy = self.target_mobject.copy() self.mobject.align_data_and_family(self.target_copy) super().begin() - self.mobject.lock_matching_data( - self.starting_mobject, - self.target_copy, - ) + if not self.mobject.has_updaters: + self.mobject.lock_matching_data( + self.starting_mobject, + self.target_copy, + ) def finish(self) -> None: super().finish() diff --git a/manimlib/camera/camera.py b/manimlib/camera/camera.py index ab1e2d33..2b9ab8ff 100644 --- a/manimlib/camera/camera.py +++ b/manimlib/camera/camera.py @@ -11,7 +11,7 @@ from scipy.spatial.transform import Rotation from manimlib.constants import BLACK from manimlib.constants import DEGREES, RADIANS -from manimlib.constants import DEFAULT_FRAME_RATE +from manimlib.constants import DEFAULT_FPS from manimlib.constants import DEFAULT_PIXEL_HEIGHT, DEFAULT_PIXEL_WIDTH from manimlib.constants import FRAME_HEIGHT, FRAME_WIDTH from manimlib.constants import DOWN, LEFT, ORIGIN, OUT, RIGHT, UP @@ -170,7 +170,7 @@ class Camera(object): "frame_config": {}, "pixel_width": DEFAULT_PIXEL_WIDTH, "pixel_height": DEFAULT_PIXEL_HEIGHT, - "frame_rate": DEFAULT_FRAME_RATE, + "fps": DEFAULT_FPS, # Note: frame height and width will be resized to match # the pixel aspect ratio "background_color": BLACK, diff --git a/manimlib/config.py b/manimlib/config.py index 5bd6173f..cf8fbed0 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -136,7 +136,7 @@ def parse_cli(): help="Resolution, passed as \"WxH\", e.g. \"1920x1080\"", ) parser.add_argument( - "--frame_rate", + "--fps", help="Frame rate, as an integer", ) parser.add_argument( @@ -148,6 +148,11 @@ def parse_cli(): action="store_true", help="Leave progress bars displayed in terminal", ) + parser.add_argument( + "--show_animation_progress", + action="store_true", + help="Show progress bar for each animation", + ) parser.add_argument( "--video_dir", help="Directory to write video", @@ -225,6 +230,8 @@ def insert_embed_line(file_name: str, scene_name: str, line_marker: str): if len(line.strip()) > 0 and get_indent(line) < n_spaces: prev_line_num = index - 2 break + if prev_line_num is None: + prev_line_num = len(lines) - 2 elif line_marker.isdigit(): # Treat the argument as a line number prev_line_num = int(line_marker) - 1 @@ -233,7 +240,7 @@ def insert_embed_line(file_name: str, scene_name: str, line_marker: str): try: prev_line_num = next( i - for i in range(len(lines) - 1, scene_line_number, -1) + for i in range(scene_line_number, len(lines) - 1) if line_marker in lines[i] ) except StopIteration: @@ -330,6 +337,16 @@ def get_configuration(args): else: file_ext = ".mp4" + dir_config = custom_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"] + ext = os.path.abspath(args.file) + ext = ext.replace(to_cut, "").replace(".py", "") + if ext.startswith("_"): + ext = ext[1:] + output_directory = os.path.join(output_directory, ext) + file_writer_config = { "write_to_movie": not args.skip_animations and write_file, "break_into_partial_movies": custom_config["break_into_partial_movies"], @@ -338,8 +355,7 @@ def get_configuration(args): # If -t is passed in (for transparent), this will be RGBA "png_mode": "RGBA" if args.transparent else "RGB", "movie_file_extension": file_ext, - "mirror_module_path": custom_config["directories"]["mirror_module_path"], - "output_directory": args.video_dir or custom_config["directories"]["output"], + "output_directory": output_directory, "file_name": args.file_name, "input_file_path": args.file or "", "open_file_upon_completion": args.open, @@ -365,6 +381,7 @@ def get_configuration(args): "preview": not write_file, "presenter_mode": args.presenter_mode, "leave_progress_bars": args.leave_progress_bars, + "show_animation_progress": args.show_animation_progress, } # Camera configuration @@ -398,31 +415,31 @@ def get_configuration(args): def get_camera_configuration(args, custom_config): camera_config = {} - camera_qualities = get_custom_config()["camera_qualities"] + camera_resolutions = get_custom_config()["camera_resolutions"] if args.low_quality: - quality = camera_qualities["low"] + resolution = camera_resolutions["low"] elif args.medium_quality: - quality = camera_qualities["medium"] + resolution = camera_resolutions["med"] elif args.hd: - quality = camera_qualities["high"] + resolution = camera_resolutions["high"] elif args.uhd: - quality = camera_qualities["ultra_high"] + resolution = camera_resolutions["4k"] else: - quality = camera_qualities[camera_qualities["default_quality"]] + resolution = camera_resolutions[camera_resolutions["default_resolution"]] - if args.resolution: - quality["resolution"] = args.resolution - if args.frame_rate: - quality["frame_rate"] = int(args.frame_rate) + if args.fps: + fps = int(args.fps) + else: + fps = get_custom_config()["fps"] - width_str, height_str = quality["resolution"].split("x") + width_str, height_str = resolution.split("x") width = int(width_str) height = int(height_str) camera_config.update({ "pixel_width": width, "pixel_height": height, - "frame_rate": quality["frame_rate"], + "fps": fps, }) try: diff --git a/manimlib/constants.py b/manimlib/constants.py index f0bc3269..f1303d01 100644 --- a/manimlib/constants.py +++ b/manimlib/constants.py @@ -10,7 +10,7 @@ FRAME_X_RADIUS = FRAME_WIDTH / 2 DEFAULT_PIXEL_HEIGHT = 1080 DEFAULT_PIXEL_WIDTH = 1920 -DEFAULT_FRAME_RATE = 30 +DEFAULT_FPS = 30 SMALL_BUFF = 0.1 MED_SMALL_BUFF = 0.25 diff --git a/manimlib/default_config.yml b/manimlib/default_config.yml index 6ddd042e..573ba2b2 100644 --- a/manimlib/default_config.yml +++ b/manimlib/default_config.yml @@ -20,6 +20,7 @@ universal_import_line: "from manimlib import *" style: tex_font: "default" font: "Consolas" + text_alignment: "LEFT" background_color: "#333333" # Set the position of preview window, you can use directions, e.g. UL/DR/OL/OO/... # also, you can also specify the position(pixel) of the upper left corner of @@ -34,17 +35,10 @@ full_screen: False # easier when working with the broken up scene, which # effectively has cuts at all the places you might want. break_into_partial_movies: False -camera_qualities: - low: - resolution: "854x480" - frame_rate: 15 - medium: - resolution: "1280x720" - frame_rate: 30 - high: - resolution: "1920x1080" - frame_rate: 30 - ultra_high: - resolution: "3840x2160" - frame_rate: 60 - default_quality: "high" +camera_resolutions: + low: "854x480" + med: "1280x720" + high: "1920x1080" + 4k: "3840x2160" + default_resolution: "high" +fps: 30 \ No newline at end of file diff --git a/manimlib/extract_scene.py b/manimlib/extract_scene.py index a3686ebc..bc1cde7e 100644 --- a/manimlib/extract_scene.py +++ b/manimlib/extract_scene.py @@ -64,6 +64,7 @@ def get_scene_config(config): "start_at_animation_number", "end_at_animation_number", "leave_progress_bars", + "show_animation_progress", "preview", "presenter_mode", ] @@ -87,7 +88,7 @@ def compute_total_frames(scene_class, scene_config): pre_scene = scene_class(**pre_config) pre_scene.run() total_time = pre_scene.time - pre_scene.skip_time - return int(total_time * scene_config["camera_config"]["frame_rate"]) + return int(total_time * scene_config["camera_config"]["fps"]) def get_scenes_to_render(scene_classes, scene_config, config): diff --git a/manimlib/mobject/functions.py b/manimlib/mobject/functions.py index 9ec232a7..6d55b395 100644 --- a/manimlib/mobject/functions.py +++ b/manimlib/mobject/functions.py @@ -75,6 +75,7 @@ class ParametricCurve(VMobject): if hasattr(self, "x_range"): return self.x_range + class FunctionGraph(ParametricCurve): CONFIG = { "color": YELLOW, diff --git a/manimlib/mobject/geometry.py b/manimlib/mobject/geometry.py index 1410e74b..768be338 100644 --- a/manimlib/mobject/geometry.py +++ b/manimlib/mobject/geometry.py @@ -821,7 +821,7 @@ class FillArrow(Line): def reset_points_around_ends(self): self.set_points_by_ends( - self.get_start(), self.get_end(), path_arc=self.path_arc + self.get_start().copy(), self.get_end().copy(), path_arc=self.path_arc ) return self diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index 1e04a960..d55ae77a 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -286,7 +286,7 @@ class Mobject(object): def are_points_touching( self, points: np.ndarray, - buff: float = MED_SMALL_BUFF + buff: float = 0 ) -> bool: bb = self.get_bounding_box() mins = (bb[0] - buff) @@ -296,7 +296,7 @@ class Mobject(object): def is_point_touching( self, point: np.ndarray, - buff: float = MED_SMALL_BUFF + buff: float = 0 ) -> bool: return self.are_points_touching(np.array(point, ndmin=2), buff)[0] @@ -359,8 +359,9 @@ class Mobject(object): if p not in excluded: ancestors.append(p) to_process.append(p) - # Remove redundancies while preserving order + # Ensure mobjects highest in the hierarchy show up first ancestors.reverse() + # Remove list redundancies while preserving order return list(dict.fromkeys(ancestors)) def add(self, *mobjects: Mobject): @@ -475,6 +476,31 @@ class Mobject(object): self.center() return self + def arrange_to_fit_dim(self, length: float, dim: int, about_edge=ORIGIN): + ref_point = self.get_bounding_box_point(about_edge) + n_submobs = len(self.submobjects) + if n_submobs <= 1: + return + total_length = sum(sm.length_over_dim(dim) for sm in self.submobjects) + buff = (length - total_length) / (n_submobs - 1) + vect = np.zeros(self.dim) + vect[dim] = 1 + x = 0 + for submob in self.submobjects: + submob.set_coord(x, dim, -vect) + x += submob.length_over_dim(dim) + buff + self.move_to(ref_point, about_edge) + return self + + def arrange_to_fit_width(self, width: float, about_edge=ORIGIN): + return self.arrange_to_fit_dim(width, 0, about_edge) + + def arrange_to_fit_height(self, height: float, about_edge=ORIGIN): + return self.arrange_to_fit_dim(height, 1, about_edge) + + def arrange_to_fit_depth(self, depth: float, about_edge=ORIGIN): + return self.arrange_to_fit_dim(depth, 2, about_edge) + def sort( self, point_to_num_func: Callable[[np.ndarray], float] = lambda p: p[0], @@ -616,11 +642,12 @@ class Mobject(object): sm1.depth_test = sm2.depth_test sm1.render_primitive = sm2.render_primitive self.refresh_bounding_box(recurse_down=True) + self.match_updaters(mobject) return self def looks_identical(self, mobject: Mobject): - fam1 = self.get_family() - fam2 = mobject.get_family() + fam1 = self.family_members_with_points() + fam2 = mobject.family_members_with_points() if len(fam1) != len(fam2): return False for m1, m2 in zip(fam1, fam2): @@ -628,11 +655,13 @@ class Mobject(object): if set(d1).difference(d2): return False for key in d1: - if isinstance(d1[key], np.ndarray): - if not np.all(d1[key] == d2[key]): + eq = (d1[key] == d2[key]) + if isinstance(eq, bool): + if not eq: + return False + else: + if not eq.all(): return False - elif d1[key] != d2[key]: - return False return True # Creating new Mobjects from this one diff --git a/manimlib/mobject/numbers.py b/manimlib/mobject/numbers.py index 6d88c647..1181d191 100644 --- a/manimlib/mobject/numbers.py +++ b/manimlib/mobject/numbers.py @@ -28,27 +28,31 @@ class DecimalNumber(VMobject): "include_background_rectangle": False, "edge_to_fix": LEFT, "font_size": 48, - "text_config": {} # Do not pass in font_size here + "text_config": {} # Do not pass in font_size here } def __init__(self, number: float | complex = 0, **kwargs): super().__init__(**kwargs) self.set_submobjects_from_number(number) + self.init_colors() def set_submobjects_from_number(self, number: float | complex) -> None: self.number = number self.set_submobjects([]) - string_to_mob_ = lambda s: self.string_to_mob(s, **self.text_config) - num_string = self.get_num_string(number) - self.add(*map(string_to_mob_, num_string)) + self.text_config["font_size"] = self.get_font_size() + num_string = self.num_string = self.get_num_string(number) + self.add(*( + Text(ns, **self.text_config) + for ns in num_string + )) # Add non-numerical bits if self.show_ellipsis: - dots = string_to_mob_("...") + dots = Text("...", **self.text_config) dots.arrange(RIGHT, buff=2 * dots[0].get_width()) self.add(dots) if self.unit is not None: - self.unit_sign = self.string_to_mob(self.unit, SingleStringTex) + self.unit_sign = SingleStringTex(self.unit, font_size=self.get_font_size()) self.add(self.unit_sign) self.arrange( @@ -91,12 +95,7 @@ class DecimalNumber(VMobject): self.data["font_size"] = np.array([self.font_size], dtype=float) def get_font_size(self) -> float: - return self.data["font_size"][0] - - def string_to_mob(self, string: str, mob_class: Type[T] = Text, **kwargs) -> T: - mob = mob_class(string, font_size=1, **kwargs) - mob.scale(self.get_font_size()) - return mob + return int(self.data["font_size"][0]) def get_formatter(self, **kwargs) -> str: """ @@ -117,13 +116,14 @@ class DecimalNumber(VMobject): ] ]) config.update(kwargs) + ndp = config["num_decimal_places"] return "".join([ "{", config.get("field_name", ""), ":", "+" if config["include_sign"] else "", "," if config["group_with_commas"] else "", - ".", str(config["num_decimal_places"]), "f", + f".{ndp}f", "}", ]) @@ -134,13 +134,15 @@ class DecimalNumber(VMobject): "i" ]) + def get_tex(self): + return self.num_string + def set_value(self, number: float | complex): move_to_point = self.get_edge_center(self.edge_to_fix) - old_submobjects = list(self.submobjects) + style = self.family_members_with_points()[0].get_style() self.set_submobjects_from_number(number) self.move_to(move_to_point, self.edge_to_fix) - for sm1, sm2 in zip(self.submobjects, old_submobjects): - sm1.match_style(sm2) + self.set_style(**style) return self def _handle_scale_side_effects(self, scale_factor: float) -> None: diff --git a/manimlib/mobject/shape_matchers.py b/manimlib/mobject/shape_matchers.py index 78cc8100..bd799aae 100644 --- a/manimlib/mobject/shape_matchers.py +++ b/manimlib/mobject/shape_matchers.py @@ -87,7 +87,7 @@ class Cross(VGroup): Line(UL, DR), Line(UR, DL), ) - self.insert_n_curves(2) + self.insert_n_curves(20) self.replace(mobject, stretch=True) self.set_stroke(self.stroke_color, width=self.stroke_width) diff --git a/manimlib/mobject/svg/drawings.py b/manimlib/mobject/svg/drawings.py index ed2102c3..b8d58d21 100644 --- a/manimlib/mobject/svg/drawings.py +++ b/manimlib/mobject/svg/drawings.py @@ -308,7 +308,7 @@ class Bubble(SVGMobject): } def __init__(self, **kwargs): - digest_config(self, kwargs, locals()) + digest_config(self, kwargs) if self.file_name is None: raise Exception("Must invoke Bubble subclass") SVGMobject.__init__(self, self.file_name, **kwargs) @@ -317,7 +317,11 @@ class Bubble(SVGMobject): self.stretch_to_fit_width(self.width) if self.direction[0] > 0: self.flip() - self.direction_was_specified = ("direction" in kwargs) + if "direction" in kwargs: + self.direction = kwargs["direction"] + self.direction_was_specified = True + else: + self.direction_was_specified = False self.content = Mobject() self.refresh_triangulation() diff --git a/manimlib/mobject/svg/tex_mobject.py b/manimlib/mobject/svg/tex_mobject.py index f1171e9c..0906fc03 100644 --- a/manimlib/mobject/svg/tex_mobject.py +++ b/manimlib/mobject/svg/tex_mobject.py @@ -33,7 +33,7 @@ class SingleStringTex(SVGMobject): "fill_opacity": 1.0, "stroke_width": 0, "svg_default": { - "color": WHITE, + "fill_color": WHITE, }, "path_string_config": { "should_subdivide_sharp_curves": True, diff --git a/manimlib/mobject/svg/text_mobject.py b/manimlib/mobject/svg/text_mobject.py index 3e45753c..474ac7e3 100644 --- a/manimlib/mobject/svg/text_mobject.py +++ b/manimlib/mobject/svg/text_mobject.py @@ -68,7 +68,7 @@ class MarkupText(StringMobject): "lsh": None, "justify": False, "indent": 0, - "alignment": "LEFT", + "alignment": "", "line_width": None, "font": "", "slant": NORMAL, @@ -114,6 +114,8 @@ class MarkupText(StringMobject): if not self.font: self.font = get_customization()["style"]["font"] + if not self.alignment: + self.alignment = get_customization()["style"]["text_alignment"] if self.is_markup: self.validate_markup_string(text) diff --git a/manimlib/mobject/types/vectorized_mobject.py b/manimlib/mobject/types/vectorized_mobject.py index 92363ae0..d58def01 100644 --- a/manimlib/mobject/types/vectorized_mobject.py +++ b/manimlib/mobject/types/vectorized_mobject.py @@ -203,36 +203,38 @@ class VMobject(Mobject): shadow: float | None = None, recurse: bool = True ): - if fill_rgba is not None: - self.data['fill_rgba'] = resize_with_interpolation(fill_rgba, len(fill_rgba)) - else: - self.set_fill( - color=fill_color, - opacity=fill_opacity, - recurse=recurse - ) + for mob in self.get_family(recurse): + if fill_rgba is not None: + mob.data['fill_rgba'] = resize_with_interpolation(fill_rgba, len(fill_rgba)) + else: + mob.set_fill( + color=fill_color, + opacity=fill_opacity, + recurse=False + ) - if stroke_rgba is not None: - self.data['stroke_rgba'] = resize_with_interpolation(stroke_rgba, len(fill_rgba)) - self.set_stroke( - width=stroke_width, - background=stroke_background, - ) - else: - self.set_stroke( - color=stroke_color, - width=stroke_width, - opacity=stroke_opacity, - recurse=recurse, - background=stroke_background, - ) + if stroke_rgba is not None: + mob.data['stroke_rgba'] = resize_with_interpolation(stroke_rgba, len(fill_rgba)) + mob.set_stroke( + width=stroke_width, + background=stroke_background, + recurse=False, + ) + else: + mob.set_stroke( + color=stroke_color, + width=stroke_width, + opacity=stroke_opacity, + recurse=False, + background=stroke_background, + ) - if reflectiveness is not None: - self.set_reflectiveness(reflectiveness, recurse=recurse) - if gloss is not None: - self.set_gloss(gloss, recurse=recurse) - if shadow is not None: - self.set_shadow(shadow, recurse=recurse) + if reflectiveness is not None: + mob.set_reflectiveness(reflectiveness, recurse=False) + if gloss is not None: + mob.set_gloss(gloss, recurse=False) + if shadow is not None: + mob.set_shadow(shadow, recurse=False) return self def get_style(self): @@ -1202,17 +1204,17 @@ class VHighlight(VGroup): def __init__( self, vmobject: VMobject, - n_layers: int = 3, + n_layers: int = 5, color_bounds: tuple[ManimColor] = (GREY_C, GREY_E), - max_stroke_width: float = 10.0, + max_stroke_addition: float = 5.0, ): outline = vmobject.replicate(n_layers) outline.set_fill(opacity=0) - added_widths = np.linspace(0, max_stroke_width, n_layers + 1)[1:] + added_widths = np.linspace(0, max_stroke_addition, n_layers + 1)[1:] colors = color_gradient(color_bounds, n_layers) for part, added_width, color in zip(reversed(outline), added_widths, colors): for sm in part.family_members_with_points(): - part.set_stroke( + sm.set_stroke( width=sm.get_stroke_width() + added_width, color=color, ) diff --git a/manimlib/scene/interactive_scene.py b/manimlib/scene/interactive_scene.py index 3b7397b8..044bc39c 100644 --- a/manimlib/scene/interactive_scene.py +++ b/manimlib/scene/interactive_scene.py @@ -9,7 +9,9 @@ from manimlib.constants import ARROW_SYMBOLS, CTRL_SYMBOL, DELETE_SYMBOL, SHIFT_ from manimlib.constants import COMMAND_MODIFIER, SHIFT_MODIFIER from manimlib.constants import DL, DOWN, DR, LEFT, ORIGIN, RIGHT, UL, UP, UR from manimlib.constants import FRAME_WIDTH, SMALL_BUFF -from manimlib.constants import MANIM_COLORS, WHITE, GREY_C +from manimlib.constants import PI +from manimlib.constants import MANIM_COLORS, WHITE, GREY_A, GREY_C +from manimlib.mobject.geometry import Line from manimlib.mobject.geometry import Rectangle from manimlib.mobject.geometry import Square from manimlib.mobject.mobject import Group @@ -22,6 +24,7 @@ from manimlib.mobject.types.vectorized_mobject import VGroup from manimlib.mobject.types.vectorized_mobject import VHighlight from manimlib.mobject.types.vectorized_mobject import VMobject from manimlib.scene.scene import Scene +from manimlib.scene.scene import SceneState from manimlib.utils.family_ops import extract_mobject_family_members from manimlib.utils.space_ops import get_norm from manimlib.utils.tex_file_writing import LatexError @@ -34,7 +37,8 @@ Y_GRAB_KEY = 'v' GRAB_KEYS = [GRAB_KEY, X_GRAB_KEY, Y_GRAB_KEY] RESIZE_KEY = 't' COLOR_KEY = 'c' -CURSOR_LOCATION_KEY = 'l' +INFORMATION_KEY = 'i' +CURSOR_KEY = 'k' # Note, a lot of the functionality here is still buggy and very much a work in progress. @@ -68,25 +72,34 @@ class InteractiveScene(Scene): ) selection_rectangle_stroke_color = WHITE selection_rectangle_stroke_width = 1.0 - colors = MANIM_COLORS + palette_colors = MANIM_COLORS selection_nudge_size = 0.05 cursor_location_config = dict( - font_size=14, + font_size=24, fill_color=GREY_C, num_decimal_places=3, ) + time_label_config = dict( + font_size=24, + fill_color=GREY_C, + num_decimal_places=1, + ) + crosshair_width = 0.2 + crosshair_color = GREY_A def setup(self): self.selection = Group() - self.selection_highlight = Group() + self.selection_highlight = self.get_selection_highlight() self.selection_rectangle = self.get_selection_rectangle() + self.crosshair = self.get_crosshair() + self.information_label = self.get_information_label() self.color_palette = self.get_color_palette() - self.cursor_location_label = self.get_cursor_location_label() self.unselectables = [ self.selection, self.selection_highlight, self.selection_rectangle, - self.cursor_location_label, + self.crosshair, + self.information_label, self.camera.frame ] self.select_top_level_mobs = True @@ -94,6 +107,7 @@ class InteractiveScene(Scene): self.is_selecting = False self.is_grabbing = False + self.add(self.selection_highlight) def get_selection_rectangle(self): @@ -106,7 +120,7 @@ class InteractiveScene(Scene): rect.add_updater(self.update_selection_rectangle) return rect - def update_selection_rectangle(self, rect): + def update_selection_rectangle(self, rect: Rectangle): p1 = rect.fixed_corner p2 = self.mouse_point.get_center() rect.set_points_as_corners([ @@ -116,10 +130,50 @@ class InteractiveScene(Scene): ]) return rect + def get_selection_highlight(self): + result = Group() + result.tracked_mobjects = [] + result.add_updater(self.update_selection_highlight) + return result + + def update_selection_highlight(self, highlight: Mobject): + if set(highlight.tracked_mobjects) == set(self.selection): + return + + # Otherwise, refresh contents of highlight + highlight.tracked_mobjects = list(self.selection) + highlight.set_submobjects([ + self.get_highlight(mob) + for mob in self.selection + ]) + try: + index = min(( + i for i, mob in enumerate(self.mobjects) + for sm in self.selection + if sm in mob.get_family() + )) + self.mobjects.remove(highlight) + self.mobjects.insert(index - 1, highlight) + except ValueError: + pass + + def get_crosshair(self): + line = Line(LEFT, RIGHT) + line.insert_n_curves(1) + lines = line.replicate(2) + lines[1].rotate(PI / 2) + crosshair = VMobject() + crosshair.set_points([*lines[0].get_points(), *lines[1].get_points()]) + crosshair.set_width(self.crosshair_width) + crosshair.set_stroke(self.crosshair_color, width=[2, 0, 2, 2, 0, 2]) + crosshair.set_animating_status(True) + crosshair.fix_in_frame() + return crosshair + def get_color_palette(self): palette = VGroup(*( Square(fill_color=color, fill_opacity=1, side_length=1) - for color in self.colors + for color in self.palette_colors )) palette.set_stroke(width=0) palette.arrange(RIGHT, buff=0.5) @@ -128,22 +182,51 @@ class InteractiveScene(Scene): palette.fix_in_frame() return palette - def get_cursor_location_label(self): - decimals = VGroup(*( + def get_information_label(self): + loc_label = VGroup(*( DecimalNumber(**self.cursor_location_config) for n in range(3) )) - def update_coords(decimals): - for mob, coord in zip(decimals, self.mouse_point.get_location()): + def update_coords(loc_label): + for mob, coord in zip(loc_label, self.mouse_point.get_location()): mob.set_value(coord) - decimals.arrange(RIGHT, buff=decimals.get_height()) - decimals.to_corner(DR, buff=SMALL_BUFF) - decimals.fix_in_frame() - return decimals + loc_label.arrange(RIGHT, buff=loc_label.get_height()) + loc_label.to_corner(DR, buff=SMALL_BUFF) + loc_label.fix_in_frame() + return loc_label - decimals.add_updater(update_coords) - return decimals + loc_label.add_updater(update_coords) + + time_label = DecimalNumber(0, **self.time_label_config) + time_label.to_corner(DL, buff=SMALL_BUFF) + time_label.fix_in_frame() + time_label.add_updater(lambda m, dt: m.increment_value(dt)) + + return VGroup(loc_label, time_label) + + # Overrides + def get_state(self): + return SceneState(self, ignore=[ + self.selection_highlight, + self.selection_rectangle, + self.crosshair, + ]) + + def restore_state(self, scene_state: SceneState): + super().restore_state(scene_state) + self.mobjects.insert(0, self.selection_highlight) + + def add(self, *mobjects: Mobject): + super().add(*mobjects) + self.regenerate_selection_search_set() + + def remove(self, *mobjects: Mobject): + super().remove(*mobjects) + self.regenerate_selection_search_set() + + # def increment_time(self, dt: float) -> None: + # super().increment_time(dt) # Related to selection @@ -184,7 +267,6 @@ class InteractiveScene(Scene): curr, exclude_pointless=True, ) ) - self.refresh_selection_highlight() def get_corner_dots(self, mobject: Mobject) -> Mobject: dots = DotCloud(**self.corner_dot_config) @@ -192,7 +274,7 @@ class InteractiveScene(Scene): if mobject.get_depth() < 1e-2: vects = [DL, UL, UR, DR] else: - vects = list(it.product(*3 * [[-1, 1]])) + vects = np.array(list(it.product(*3 * [[-1, 1]]))) dots.add_updater(lambda d: d.set_points([ mobject.get_corner(v) + v * radius for v in vects @@ -201,27 +283,19 @@ class InteractiveScene(Scene): def get_highlight(self, mobject: Mobject) -> Mobject: if isinstance(mobject, VMobject) and mobject.has_points() and not self.select_top_level_mobs: - result = VHighlight(mobject) - result.add_updater(lambda m: m.replace(mobject)) + length = max([mobject.get_height(), mobject.get_width()]) + result = VHighlight( + mobject, + max_stroke_addition=min([50 * length, 10]), + ) + result.add_updater(lambda m: m.replace(mobject, stretch=True)) return result + elif isinstance(mobject, DotCloud): + return Mobject() else: return self.get_corner_dots(mobject) - def refresh_selection_highlight(self): - if len(self.selection) > 0: - self.remove(self.selection_highlight) - self.selection_highlight.set_submobjects([ - self.get_highlight(mob) - for mob in self.selection - ]) - index = min(( - i for i, mob in enumerate(self.mobjects) - for sm in self.selection - if sm in mob.get_family() - )) - self.mobjects.insert(index, self.selection_highlight) - - def add_to_selection(self, *mobjects): + def add_to_selection(self, *mobjects: Mobject): mobs = list(filter( lambda m: m not in self.unselectables and m not in self.selection, mobjects @@ -229,46 +303,34 @@ class InteractiveScene(Scene): if len(mobs) == 0: return self.selection.add(*mobs) - self.refresh_selection_highlight() - for sm in mobs: - for mob in self.mobjects: - if sm in mob.get_family(): - mob.set_animating_status(True) - self.refresh_static_mobjects() + self.selection.set_animating_status(True) - def toggle_from_selection(self, *mobjects): + def toggle_from_selection(self, *mobjects: Mobject): for mob in mobjects: if mob in self.selection: self.selection.remove(mob) mob.set_animating_status(False) else: self.add_to_selection(mob) - self.refresh_selection_highlight() + self.refresh_static_mobjects() def clear_selection(self): for mob in self.selection: mob.set_animating_status(False) self.selection.set_submobjects([]) - self.selection_highlight.set_submobjects([]) self.refresh_static_mobjects() - def add(self, *new_mobjects: Mobject): - super().add(*new_mobjects) - self.regenerate_selection_search_set() - - def remove(self, *mobjects: Mobject): - super().remove(*mobjects) - self.regenerate_selection_search_set() - def disable_interaction(self, *mobjects: Mobject): for mob in mobjects: - self.unselectables.append(mob) + for sm in mob.get_family(): + self.unselectables.append(sm) self.regenerate_selection_search_set() def enable_interaction(self, *mobjects: Mobject): for mob in mobjects: - if mob in self.unselectables: - self.unselectables.remove(mob) + for sm in mob.get_family(): + if sm in self.unselectables: + self.unselectables.remove(sm) # Functions for keyboard actions @@ -308,10 +370,6 @@ class InteractiveScene(Scene): self.remove(*self.selection) self.clear_selection() - def restore_state(self, mobject_states: list[tuple[Mobject, Mobject]]): - super().restore_state(mobject_states) - self.refresh_selection_highlight() - def enable_selection(self): self.is_selecting = True self.add(self.selection_rectangle) @@ -352,6 +410,12 @@ class InteractiveScene(Scene): else: self.remove(self.color_palette) + def display_information(self, show=True): + if show: + self.add(self.information_label) + else: + self.remove(self.information_label) + def group_selection(self): group = self.get_group(*self.selection) self.add(group) @@ -393,8 +457,8 @@ class InteractiveScene(Scene): self.prepare_resizing(about_corner=True) elif char == COLOR_KEY and modifiers == 0: self.toggle_color_palette() - elif char == CURSOR_LOCATION_KEY and modifiers == 0: - self.add(self.cursor_location_label) + elif char == INFORMATION_KEY and modifiers == 0: + self.display_information() elif char == "c" and modifiers == COMMAND_MODIFIER: self.copy_selection() elif char == "v" and modifiers == COMMAND_MODIFIER: @@ -420,6 +484,14 @@ class InteractiveScene(Scene): vect=[LEFT, UP, RIGHT, DOWN][ARROW_SYMBOLS.index(symbol)], large=(modifiers & SHIFT_MODIFIER), ) + # Adding crosshair + if char == CURSOR_KEY: + if self.crosshair in self.mobjects: + self.remove(self.crosshair) + else: + self.add(self.crosshair) + if char == SELECT_KEY: + self.add(self.crosshair) # Conditions for saving state if char in [GRAB_KEY, X_GRAB_KEY, Y_GRAB_KEY, RESIZE_KEY]: @@ -429,10 +501,11 @@ class InteractiveScene(Scene): super().on_key_release(symbol, modifiers) if chr(symbol) == SELECT_KEY: self.gather_new_selection() + # self.remove(self.crosshair) if chr(symbol) in GRAB_KEYS: self.is_grabbing = False - elif chr(symbol) == CURSOR_LOCATION_KEY: - self.remove(self.cursor_location_label) + elif chr(symbol) == INFORMATION_KEY: + self.display_information(False) elif symbol == SHIFT_SYMBOL and self.window.is_key_pressed(ord(RESIZE_KEY)): self.prepare_resizing(about_corner=False) @@ -447,6 +520,8 @@ class InteractiveScene(Scene): self.selection.set_y(diff[1]) def handle_resizing(self, point: np.ndarray): + if not hasattr(self, "scale_about_point"): + return vect = point - self.scale_about_point if self.window.is_key_pressed(CTRL_SYMBOL): for i in (0, 1): @@ -485,17 +560,9 @@ class InteractiveScene(Scene): self.selection.set_color(mob.get_color()) self.remove(self.color_palette) - def toggle_clicked_mobject_from_selection(self, point: np.ndarray): - mob = self.point_to_mobject( - point, - search_set=self.get_selection_search_set(), - buff=SMALL_BUFF - ) - if mob is not None: - self.toggle_from_selection(mob) - def on_mouse_motion(self, point: np.ndarray, d_point: np.ndarray) -> None: super().on_mouse_motion(point, d_point) + self.crosshair.move_to(point) if self.is_grabbing: self.handle_grabbing(point) elif self.window.is_key_pressed(ord(RESIZE_KEY)): @@ -507,7 +574,13 @@ class InteractiveScene(Scene): super().on_mouse_release(point, button, mods) if self.color_palette in self.mobjects: self.choose_color(point) - elif self.window.is_key_pressed(SHIFT_SYMBOL): - self.toggle_clicked_mobject_from_selection(point) + return + mobject = self.point_to_mobject( + point, + search_set=self.get_selection_search_set(), + buff=1e-4, + ) + if mobject is not None: + self.toggle_from_selection(mobject) else: self.clear_selection() diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index a325bd49..73ae7d1d 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -1,9 +1,11 @@ from __future__ import annotations +from collections import OrderedDict from functools import wraps import inspect import os import platform +import pyperclip import random import time @@ -11,7 +13,6 @@ import numpy as np from tqdm import tqdm as ProgressDisplay from manimlib.animation.animation import prepare_animation -from manimlib.animation.transform import MoveToTarget from manimlib.camera.camera import Camera from manimlib.constants import ARROW_SYMBOLS from manimlib.constants import DEFAULT_WAIT_TIME @@ -20,6 +21,7 @@ from manimlib.constants import SHIFT_MODIFIER from manimlib.event_handler import EVENT_DISPATCHER from manimlib.event_handler.event_type import EventType from manimlib.logger import log +from manimlib.mobject.mobject import _AnimationBuilder from manimlib.mobject.mobject import Group from manimlib.mobject.mobject import Mobject from manimlib.mobject.mobject import Point @@ -61,7 +63,7 @@ class Scene(object): "leave_progress_bars": False, "preview": True, "presenter_mode": False, - "linger_after_completion": True, + "show_animation_progress": False, "pan_sensitivity": 3, "max_num_saved_states": 50, } @@ -72,7 +74,7 @@ class Scene(object): from manimlib.window import Window self.window = Window(scene=self, **self.window_config) self.camera_config["ctx"] = self.window.ctx - self.camera_config["frame_rate"] = 30 # Where's that 30 from? + self.camera_config["fps"] = 30 # Where's that 30 from? self.undo_stack = [] self.redo_stack = [] else: @@ -86,14 +88,19 @@ class Scene(object): self.time: float = 0 self.skip_time: float = 0 self.original_skipping_status: bool = self.skip_animations + self.checkpoint_states: dict[str, list[tuple[Mobject, Mobject]]] = dict() + if self.start_at_animation_number is not None: self.skip_animations = True + if self.file_writer.has_progress_display: + self.show_animation_progress = False # Items associated with interaction self.mouse_point = Point() self.mouse_drag_point = Point() self.hold_on_wait = self.presenter_mode self.inside_embed = False + self.quit_interaction = False # Much nicer to work with deterministic scenes if self.random_seed is not None: @@ -111,8 +118,13 @@ class Scene(object): self.setup() try: self.construct() - except EndSceneEarlyException: + self.interact() + except EndScene: pass + except KeyboardInterrupt: + # Get rid keyboard interupt symbols + print("", end="\r") + self.file_writer.ended_with_interrupt = True self.tear_down() def setup(self) -> None: @@ -131,32 +143,31 @@ class Scene(object): def tear_down(self) -> None: self.stop_skipping() self.file_writer.finish() - if self.window and self.linger_after_completion: - self.interact() + if self.window: + self.window.destroy() + self.window = None def interact(self) -> None: # If there is a window, enter a loop # which updates the frame while under # the hood calling the pyglet event loop + if self.window is None: + return log.info( "Tips: You are now in the interactive mode. Now you can use the keyboard" " and the mouse to interact with the scene. Just press `command + q` or `esc`" " if you want to quit." ) - self.quit_interaction = False + self.skip_animations = False self.refresh_static_mobjects() - while not (self.window.is_closing or self.quit_interaction): - self.update_frame(1 / self.camera.frame_rate) - if self.window.is_closing: - self.window.destroy() + while not self.is_window_closing(): + self.update_frame(1 / self.camera.fps) def embed(self, close_scene_on_exit: bool = True) -> None: if not self.preview: - # Ignore embed calls when there is no preview - return + return # Embed is only relevant with a preview self.inside_embed = True self.stop_skipping() - self.linger_after_completion = False self.update_frame() self.save_state() @@ -175,29 +186,70 @@ class Scene(object): ] }) + # This is useful if one wants to re-run a block of scene + # code, while developing, tweaking it each time. + # As long as the copied selection starts with a comment, + # this will revert to the state of the scene at the first + # point of running. + def checkpoint_paste(skip=False, show_progress=True): + pasted = pyperclip.paste() + line0 = pasted.lstrip().split("\n")[0] + if line0.startswith("#"): + if line0 not in self.checkpoint_states: + self.checkpoint(line0) + else: + self.revert_to_checkpoint(line0) + self.update_frame(dt=0) + if skip: + originally_skip = self.skip_animations + self.skip_animations = True + if show_progress: + originally_show_animation_progress = self.show_animation_progress + self.show_animation_progress = True + shell.run_line_magic("paste", "") + if skip: + self.skip_animations = originally_skip + if show_progress: + self.show_animation_progress = originally_show_animation_progress + + local_ns['checkpoint_paste'] = checkpoint_paste + # Enables gui interactions during the embed def inputhook(context): while not context.input_is_ready(): - if not self.window.is_closing: + 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") + # This is hacky, but there's an issue with ipython which is that + # when you define lambda's or list comprehensions during a shell session, + # they are not aware of local variables in the surrounding scope. Because + # That comes up a fair bit during scene construction, to get around this, + # we (admittedly sketchily) update the global namespace to match the local + # namespace, since this is just a shell session anyway. + shell.events.register( + "pre_run_cell", + lambda: shell.user_global_ns.update(shell.user_ns) + ) + # Operation to run after each ipython command - def post_cell_func(*args, **kwargs): + def post_cell_func(): self.refresh_static_mobjects() + if not self.is_window_closing(): + self.update_frame(dt=0, ignore_skipping=True) self.save_state() shell.events.register("post_run_cell", post_cell_func) - # Launch shell, with stack_depth=2 indicating we should use caller globals/locals shell(local_ns=local_ns, stack_depth=2) - self.inside_embed = False # End scene when exiting an embed if close_scene_on_exit: - raise EndSceneEarlyException() + raise EndScene() # Only these methods should touch the camera @@ -214,6 +266,9 @@ class Scene(object): if self.skip_animations and not ignore_skipping: return + if self.is_window_closing(): + raise EndScene() + if self.window: self.window.clear() self.camera.clear() @@ -396,7 +451,7 @@ class Scene(object): self.stop_skipping() if self.end_at_animation_number is not None: if self.num_plays >= self.end_at_animation_number: - raise EndSceneEarlyException() + raise EndScene() def stop_skipping(self) -> None: self.virtual_animation_start_time = self.time @@ -413,24 +468,25 @@ class Scene(object): ) -> list[float] | np.ndarray | ProgressDisplay: if self.skip_animations and not override_skip_animations: return [run_time] - else: - step = 1 / self.camera.frame_rate - times = np.arange(0, run_time, step) + + times = np.arange(0, run_time, 1 / self.camera.fps) if self.file_writer.has_progress_display: self.file_writer.set_progress_display_subdescription(desc) + + if self.show_animation_progress: + return ProgressDisplay( + times, + total=n_iterations, + leave=self.leave_progress_bars, + ascii=True if platform.system() == 'Windows' else None, + desc=desc, + ) + else: return times - return ProgressDisplay( - times, - total=n_iterations, - leave=self.leave_progress_bars, - ascii=True if platform.system() == 'Windows' else None, - desc=desc, - ) - def get_run_time(self, animations: Iterable[Animation]) -> float: - return np.max([animation.run_time for animation in animations]) + return np.max([animation.get_run_time() for animation in animations]) def get_animation_time_progression( self, @@ -454,74 +510,16 @@ class Scene(object): kw["override_skip_animations"] = True return self.get_time_progression(duration, **kw) - def anims_from_play_args(self, *args, **kwargs) -> list[Animation]: - """ - Each arg can either be an animation, or a mobject method - followed by that methods arguments (and potentially follow - by a dict of kwargs for that method). - This animation list is built by going through the args list, - and each animation is simply added, but when a mobject method - s hit, a MoveToTarget animation is built using the args that - follow up until either another animation is hit, another method - is hit, or the args list runs out. - """ - animations = [] - state = { - "curr_method": None, - "last_method": None, - "method_args": [], - } - - def compile_method(state): - if state["curr_method"] is None: - return - mobject = state["curr_method"].__self__ - if state["last_method"] and state["last_method"].__self__ is mobject: - animations.pop() - # method should already have target then. - else: - mobject.generate_target() - # - if len(state["method_args"]) > 0 and isinstance(state["method_args"][-1], dict): - method_kwargs = state["method_args"].pop() - else: - method_kwargs = {} - state["curr_method"].__func__( - mobject.target, - *state["method_args"], - **method_kwargs - ) - animations.append(MoveToTarget(mobject)) - state["last_method"] = state["curr_method"] - state["curr_method"] = None - state["method_args"] = [] - - for arg in args: - if inspect.ismethod(arg): - compile_method(state) - state["curr_method"] = arg - elif state["curr_method"] is not None: - state["method_args"].append(arg) - elif isinstance(arg, Mobject): - raise Exception(""" - I think you may have invoked a method - you meant to pass in as a Scene.play argument - """) - else: - try: - anim = prepare_animation(arg) - except TypeError: - raise TypeError(f"Unexpected argument {arg} passed to Scene.play()") - - compile_method(state) - animations.append(anim) - compile_method(state) - - for animation in animations: + def prepare_animations( + self, + proto_animations: list[Animation | _AnimationBuilder], + animation_config: dict, + ): + animations = list(map(prepare_animation, proto_animations)) + for anim in animations: # This is where kwargs to play like run_time and rate_func # get applied to all animations - animation.update_config(**kwargs) - + anim.update_config(**animation_config) return animations def handle_play_like_call(func): @@ -529,6 +527,8 @@ class Scene(object): def wrapper(self, *args, **kwargs): if self.inside_embed: self.save_state() + if self.presenter_mode and self.num_plays == 0: + self.hold_loop() self.update_skipping_status() should_write = not self.skip_animations @@ -548,6 +548,10 @@ class Scene(object): if self.inside_embed: self.save_state() + if self.skip_animations and self.window is not None: + # Show some quick frames along the way + self.update_frame(dt=0, ignore_skipping=True) + self.num_plays += 1 return wrapper @@ -587,11 +591,11 @@ class Scene(object): self.update_mobjects(0) @handle_play_like_call - def play(self, *args, **kwargs) -> None: - if len(args) == 0: + def play(self, *proto_animations, **animation_config) -> None: + if len(proto_animations) == 0: log.warning("Called Scene.play with no animations") return - animations = self.anims_from_play_args(*args, **kwargs) + animations = self.prepare_animations(proto_animations, animation_config) self.begin_animations(animations) self.progress_through_animations(animations) self.finish_animations(animations) @@ -608,9 +612,7 @@ class Scene(object): if self.presenter_mode and not self.skip_animations and not ignore_presenter_mode: if note: log.info(note) - while self.hold_on_wait: - self.update_frame(dt=1 / self.camera.frame_rate) - self.hold_on_wait = True + self.hold_loop() else: time_progression = self.get_wait_time_progression(duration, stop_condition) last_t = 0 @@ -624,6 +626,11 @@ class Scene(object): self.refresh_static_mobjects() return self + def hold_loop(self): + while self.hold_on_wait: + self.update_frame(dt=1 / self.camera.fps) + self.hold_on_wait = True + def wait_until( self, stop_condition: Callable[[], bool], @@ -655,50 +662,52 @@ class Scene(object): # Helpers for interactive development - def get_state(self) -> tuple[list[tuple[Mobject, Mobject]], int]: - if self.undo_stack: - last_state = dict(self.undo_stack[-1]) - else: - last_state = {} - result = [] - n_changes = 0 - for mob in self.mobjects: - # If it hasn't changed since the last state, just point to the - # same copy as before - if mob in last_state and last_state[mob].looks_identical(mob): - result.append((mob, last_state[mob])) - else: - result.append((mob, mob.copy())) - n_changes += 1 - return result, n_changes + def get_state(self) -> SceneState: + return SceneState(self) - def restore_state(self, mobject_states: list[tuple[Mobject, Mobject]]): - self.mobjects = [mob.become(mob_copy) for mob, mob_copy in mobject_states] + def restore_state(self, scene_state: SceneState): + 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 self.redo_stack = [] - state, n_changes = self.get_state() - if n_changes > 0: - self.undo_stack.append(state) - if len(self.undo_stack) > self.max_num_saved_states: - self.undo_stack.pop(0) + self.undo_stack.append(state) + if len(self.undo_stack) > self.max_num_saved_states: + self.undo_stack.pop(0) def undo(self): if self.undo_stack: - state, n_changes = self.get_state() - self.redo_stack.append(state) + self.redo_stack.append(self.get_state()) self.restore_state(self.undo_stack.pop()) self.refresh_static_mobjects() def redo(self): if self.redo_stack: - state, n_changes = self.get_state() - self.undo_stack.append(state) + self.undo_stack.append(self.get_state()) self.restore_state(self.redo_stack.pop()) self.refresh_static_mobjects() + 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) @@ -714,6 +723,9 @@ class Scene(object): path = os.path.join(directory, file_name) return Mobject.load(path) + def is_window_closing(self): + return self.window and (self.window.is_closing or self.quit_interaction) + # Event handling def on_mouse_motion( @@ -850,5 +862,49 @@ class Scene(object): pass -class EndSceneEarlyException(Exception): +class SceneState(): + def __init__(self, scene: Scene, ignore: list[Mobject] | None = None): + self.time = scene.time + self.num_plays = scene.num_plays + self.mobjects_to_copies = OrderedDict.fromkeys(scene.mobjects) + if ignore: + for mob in ignore: + self.mobjects_to_copies.pop(mob, None) + + last_m2c = scene.undo_stack[-1].mobjects_to_copies if scene.undo_stack else dict() + for mob in self.mobjects_to_copies: + # If it hasn't changed since the last state, just point to the + # same copy as before + if mob in last_m2c and last_m2c[mob].looks_identical(mob): + self.mobjects_to_copies[mob] = last_m2c[mob] + else: + self.mobjects_to_copies[mob] = mob.copy() + + def __eq__(self, state: SceneState): + return all(( + self.time == state.time, + self.num_plays == state.num_plays, + self.mobjects_to_copies == state.mobjects_to_copies + )) + + def mobjects_match(self, state: SceneState): + return self.mobjects_to_copies == state.mobjects_to_copies + + def n_changes(self, state: SceneState): + m2c = state.mobjects_to_copies + return sum( + 1 - int(mob in m2c and mob.looks_identical(m2c[mob])) + for mob in self.mobjects_to_copies + ) + + def restore_scene(self, scene: Scene): + scene.time = self.time + scene.num_plays = self.num_plays + scene.mobjects = [ + mob.become(mob_copy) + for mob, mob_copy in self.mobjects_to_copies.items() + ] + + +class EndScene(Exception): pass diff --git a/manimlib/scene/scene_file_writer.py b/manimlib/scene/scene_file_writer.py index 3a647fe9..33a8e610 100644 --- a/manimlib/scene/scene_file_writer.py +++ b/manimlib/scene/scene_file_writer.py @@ -37,9 +37,6 @@ class SceneFileWriter(object): "png_mode": "RGBA", "save_last_frame": False, "movie_file_extension": ".mp4", - # Should the path of output files mirror the directory - # structure of the module holding the scene? - "mirror_module_path": False, # What python file is generating this scene "input_file_path": "", # Where should this be written @@ -57,16 +54,13 @@ class SceneFileWriter(object): self.scene: Scene = scene self.writing_process: sp.Popen | None = None self.has_progress_display: bool = False + self.ended_with_interrupt: bool = False self.init_output_directories() self.init_audio() # Output directories and files def init_output_directories(self) -> None: out_dir = self.output_directory or "" - if self.mirror_module_path: - module_dir = self.get_default_module_directory() - out_dir = os.path.join(out_dir, module_dir) - scene_name = self.file_name or self.get_default_scene_name() if self.save_last_frame: image_dir = guarantee_existence(os.path.join(out_dir, "images")) @@ -81,7 +75,9 @@ class SceneFileWriter(object): movie_dir, "partial_movie_files", scene_name, )) # A place to save mobjects - self.saved_mobject_directory = os.path.join(out_dir, "mobjects") + self.saved_mobject_directory = os.path.join( + out_dir, "mobjects", str(self.scene) + ) def get_default_module_directory(self) -> str: path, _ = os.path.splitext(self.input_file_path) @@ -101,9 +97,9 @@ class SceneFileWriter(object): def get_resolution_directory(self) -> str: pixel_height = self.scene.camera.pixel_height - frame_rate = self.scene.camera.frame_rate + fps = self.scene.camera.fps return "{}p{}".format( - pixel_height, frame_rate + pixel_height, fps ) # Directory getters @@ -124,10 +120,7 @@ class SceneFileWriter(object): return self.movie_file_path def get_saved_mobject_directory(self) -> str: - return guarantee_existence(os.path.join( - self.saved_mobject_directory, - str(self.scene), - )) + return guarantee_existence(self.saved_mobject_directory) def get_saved_mobject_path(self, mobject: Mobject) -> str | None: directory = self.get_saved_mobject_directory() @@ -241,7 +234,7 @@ class SceneFileWriter(object): self.final_file_path = file_path self.temp_file_path = stem + "_temp" + ext - fps = self.scene.camera.frame_rate + fps = self.scene.camera.fps width, height = self.scene.camera.get_pixel_shape() command = [ @@ -305,7 +298,11 @@ class SceneFileWriter(object): self.writing_process.terminate() if self.has_progress_display: self.progress_display.close() - shutil.move(self.temp_file_path, self.final_file_path) + + if not self.ended_with_interrupt: + shutil.move(self.temp_file_path, self.final_file_path) + else: + self.movie_file_path = self.temp_file_path def combine_movie_files(self) -> None: kwargs = { diff --git a/manimlib/utils/init_config.py b/manimlib/utils/init_config.py index 323bda37..f71d4039 100644 --- a/manimlib/utils/init_config.py +++ b/manimlib/utils/init_config.py @@ -52,25 +52,14 @@ def init_customization() -> None: "window_monitor": 0, "full_screen": False, "break_into_partial_movies": False, - "camera_qualities": { - "low": { - "resolution": "854x480", - "frame_rate": 15, - }, - "medium": { - "resolution": "1280x720", - "frame_rate": 30, - }, - "high": { - "resolution": "1920x1080", - "frame_rate": 60, - }, - "ultra_high": { - "resolution": "3840x2160", - "frame_rate": 60, - }, - "default_quality": "", - } + "camera_resolutions": { + "low": "854x480", + "medium": "1280x720", + "high": "1920x1080", + "4k": "3840x2160", + "default_resolution": "high", + }, + "fps": 30, } console = Console() diff --git a/manimlib/utils/iterables.py b/manimlib/utils/iterables.py index fa54e68d..ed11fcbb 100644 --- a/manimlib/utils/iterables.py +++ b/manimlib/utils/iterables.py @@ -13,19 +13,12 @@ if TYPE_CHECKING: S = TypeVar("S") -def remove_list_redundancies(l: Sequence[T]) -> list[T]: +def remove_list_redundancies(lst: Sequence[T]) -> list[T]: """ Used instead of list(set(l)) to maintain order Keeps the last occurrence of each element """ - reversed_result = [] - used = set() - for x in reversed(l): - if x not in used: - reversed_result.append(x) - used.add(x) - reversed_result.reverse() - return reversed_result + return list(reversed(dict.fromkeys(reversed(lst)))) def list_update(l1: Iterable[T], l2: Iterable[T]) -> list[T]: @@ -33,7 +26,7 @@ def list_update(l1: Iterable[T], l2: Iterable[T]) -> list[T]: Used instead of list(set(l1).update(l2)) to maintain order, making sure duplicates are removed from l1, not l2. """ - return [e for e in l1 if e not in l2] + list(l2) + return remove_list_redundancies([*l1, *l2]) def list_difference_update(l1: Iterable[T], l2: Iterable[T]) -> list[T]: @@ -119,7 +112,7 @@ def resize_with_interpolation(nparray: np.ndarray, length: int) -> np.ndarray: def make_even( - iterable_1: Sequence[T], + iterable_1: Sequence[T], iterable_2: Sequence[S] ) -> tuple[list[T], list[S]]: len1 = len(iterable_1)