From 744e695340c5691551f2650d99db71964cb292c7 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 12 Dec 2024 10:39:54 -0600 Subject: [PATCH] Misc. clean up (#2269) * Comment tweak * Directly print traceback Since the shell.showtraceback is giving some issues * Make InteracrtiveSceneEmbed into a class This way it can keep track of it's internal shell; use of get_ipython has a finicky relationship with reloading. * Move remaining checkpoint_paste logic into scene_embed.py This involved making a few context managers for Scene: temp_record, temp_skip, temp_progress_bar, which seem useful in and of themselves. * Change null key to be the empty string * Ensure temporary svg paths for Text are deleted * Remove unused dict_ops.py functions * Remove break_into_partial_movies from file_writer configuration * Rewrite guarantee_existence using Path * Clean up SceneFileWriter It had a number of vestigial functions no longer used, and some setup that could be made more organized. * Remove --save_pngs CLI arg (which did nothing) * Add --subdivide CLI arg * Remove add_extension_if_not_present * Remove get_sorted_integer_files * Have find_file return Path * Minor clean up * Clean up num_tex_symbols * Fix find_file * Minor cleanup for extract_scene.py * Add preview_frame_while_skipping option to scene config * Use shell.showtraceback function * Move keybindings to config, instead of in-place constants * Replace DEGREES -> DEG --- docs/source/documentation/constants.rst | 2 +- .../source/getting_started/example_scenes.rst | 12 +- example_scenes.py | 10 +- manimlib/animation/indication.py | 4 +- manimlib/animation/transform.py | 4 +- manimlib/camera/camera_frame.py | 8 +- manimlib/config.py | 13 +- manimlib/constants.py | 5 +- manimlib/default_config.yml | 24 ++- manimlib/extract_scene.py | 56 +++---- manimlib/mobject/coordinate_systems.py | 6 +- manimlib/mobject/geometry.py | 4 +- manimlib/mobject/matrix.py | 4 +- manimlib/mobject/mobject_update_utils.py | 4 +- manimlib/mobject/svg/text_mobject.py | 52 +++--- manimlib/mobject/types/vectorized_mobject.py | 8 +- manimlib/scene/interactive_scene.py | 24 +-- manimlib/scene/scene.py | 18 +-- manimlib/scene/scene_embed.py | 2 +- manimlib/scene/scene_file_writer.py | 148 +++++------------- manimlib/utils/dict_ops.py | 26 --- manimlib/utils/file_ops.py | 56 +------ manimlib/utils/simple_functions.py | 22 +-- manimlib/utils/tex.py | 50 +++--- 24 files changed, 209 insertions(+), 353 deletions(-) diff --git a/docs/source/documentation/constants.rst b/docs/source/documentation/constants.rst index fb0f4520..c27515b0 100644 --- a/docs/source/documentation/constants.rst +++ b/docs/source/documentation/constants.rst @@ -74,7 +74,7 @@ Mathematical constant PI = np.pi TAU = 2 * PI - DEGREES = TAU / 360 + DEG = TAU / 360 Text ---- diff --git a/docs/source/getting_started/example_scenes.rst b/docs/source/getting_started/example_scenes.rst index 1caeb889..e6cdee06 100644 --- a/docs/source/getting_started/example_scenes.rst +++ b/docs/source/getting_started/example_scenes.rst @@ -34,7 +34,7 @@ InteractiveDevlopment self.play(ReplacementTransform(square, circle)) self.wait() self.play(circle.animate.stretch(4, 0)) - self.play(Rotate(circle, 90 * DEGREES)) + self.play(Rotate(circle, 90 * DEG)) self.play(circle.animate.shift(2 * RIGHT).scale(0.25)) text = Text(""" @@ -221,7 +221,7 @@ TexTransformExample self.play( TransformMatchingTex( lines[0].copy(), lines[1], - path_arc=90 * DEGREES, + path_arc=90 * DEG, ), **play_kw ) @@ -599,8 +599,8 @@ SurfaceExample # Set perspective frame = self.camera.frame frame.set_euler_angles( - theta=-30 * DEGREES, - phi=70 * DEGREES, + theta=-30 * DEG, + phi=70 * DEG, ) surface = surfaces[0] @@ -624,8 +624,8 @@ SurfaceExample self.play( Transform(surface, surfaces[2]), # Move camera frame during the transition - frame.animate.increment_phi(-10 * DEGREES), - frame.animate.increment_theta(-20 * DEGREES), + frame.animate.increment_phi(-10 * DEG), + frame.animate.increment_theta(-20 * DEG), run_time=3 ) # Add ambient rotation diff --git a/example_scenes.py b/example_scenes.py index 421a2e73..4646c857 100644 --- a/example_scenes.py +++ b/example_scenes.py @@ -190,7 +190,7 @@ class TexTransformExample(Scene): # to go to a non-equal substring from the target, # use the key map. key_map={"+": "-"}, - path_arc=90 * DEGREES, + path_arc=90 * DEG, ), ) self.wait() @@ -203,7 +203,7 @@ class TexTransformExample(Scene): TransformMatchingStrings( lines[2].copy(), lines[3], key_map={"2": R"\sqrt"}, - path_arc=-30 * DEGREES, + path_arc=-30 * DEG, ), ) self.wait(2) @@ -616,8 +616,8 @@ class SurfaceExample(ThreeDScene): self.play( Transform(surface, surfaces[2]), # Move camera frame during the transition - self.frame.animate.increment_phi(-10 * DEGREES), - self.frame.animate.increment_theta(-20 * DEGREES), + self.frame.animate.increment_phi(-10 * DEG), + self.frame.animate.increment_theta(-20 * DEG), run_time=3 ) # Add ambient rotation @@ -666,7 +666,7 @@ class InteractiveDevelopment(Scene): self.play(ReplacementTransform(square, circle)) self.wait() self.play(circle.animate.stretch(4, 0)) - self.play(Rotate(circle, 90 * DEGREES)) + self.play(Rotate(circle, 90 * DEG)) self.play(circle.animate.shift(2 * RIGHT).scale(0.25)) text = Text(""" diff --git a/manimlib/animation/indication.py b/manimlib/animation/indication.py index 05afc1ec..d82cdcae 100644 --- a/manimlib/animation/indication.py +++ b/manimlib/animation/indication.py @@ -14,7 +14,7 @@ from manimlib.animation.transform import Transform from manimlib.constants import FRAME_X_RADIUS, FRAME_Y_RADIUS from manimlib.constants import ORIGIN, RIGHT, UP from manimlib.constants import SMALL_BUFF -from manimlib.constants import DEGREES +from manimlib.constants import DEG from manimlib.constants import TAU from manimlib.constants import GREY, YELLOW from manimlib.mobject.geometry import Circle @@ -395,7 +395,7 @@ class WiggleOutThenIn(Animation): class TurnInsideOut(Transform): - def __init__(self, mobject: Mobject, path_arc: float = 90 * DEGREES, **kwargs): + def __init__(self, mobject: Mobject, path_arc: float = 90 * DEG, **kwargs): super().__init__(mobject, path_arc=path_arc, **kwargs) def create_target(self) -> Mobject: diff --git a/manimlib/animation/transform.py b/manimlib/animation/transform.py index 363445ea..994e4dfa 100644 --- a/manimlib/animation/transform.py +++ b/manimlib/animation/transform.py @@ -5,7 +5,7 @@ import inspect import numpy as np from manimlib.animation.animation import Animation -from manimlib.constants import DEGREES +from manimlib.constants import DEG from manimlib.constants import OUT from manimlib.mobject.mobject import Group from manimlib.mobject.mobject import Mobject @@ -314,7 +314,7 @@ class ApplyComplexFunction(ApplyMethod): class CyclicReplace(Transform): - def __init__(self, *mobjects: Mobject, path_arc=90 * DEGREES, **kwargs): + def __init__(self, *mobjects: Mobject, path_arc=90 * DEG, **kwargs): super().__init__(Group(*mobjects), path_arc=path_arc, **kwargs) def create_target(self) -> Mobject: diff --git a/manimlib/camera/camera_frame.py b/manimlib/camera/camera_frame.py index 62ca4935..e1982599 100644 --- a/manimlib/camera/camera_frame.py +++ b/manimlib/camera/camera_frame.py @@ -6,7 +6,7 @@ import warnings import numpy as np from scipy.spatial.transform import Rotation -from manimlib.constants import DEGREES, RADIANS +from manimlib.constants import DEG, RADIANS from manimlib.constants import FRAME_SHAPE from manimlib.constants import DOWN, LEFT, ORIGIN, OUT, RIGHT, UP from manimlib.constants import PI @@ -26,7 +26,7 @@ class CameraFrame(Mobject): frame_shape: tuple[float, float] = FRAME_SHAPE, center_point: Vect3 = ORIGIN, # Field of view in the y direction - fovy: float = 45 * DEGREES, + fovy: float = 45 * DEG, euler_axes: str = "zxz", # This keeps it ordered first in a scene z_index=-1, @@ -181,7 +181,7 @@ class CameraFrame(Mobject): Shortcut for set_euler_angles, defaulting to taking in angles in degrees """ - self.set_euler_angles(theta_degrees, phi_degrees, gamma_degrees, units=DEGREES) + self.set_euler_angles(theta_degrees, phi_degrees, gamma_degrees, units=DEG) if center is not None: self.move_to(np.array(center)) if height is not None: @@ -209,7 +209,7 @@ class CameraFrame(Mobject): self.increment_euler_angles(dgamma=dgamma, units=units) return self - def add_ambient_rotation(self, angular_speed=1 * DEGREES): + def add_ambient_rotation(self, angular_speed=1 * DEG): self.add_updater(lambda m, dt: m.increment_theta(angular_speed * dt)) return self diff --git a/manimlib/config.py b/manimlib/config.py index e91581d6..37872ce8 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -105,11 +105,6 @@ def parse_cli(): help="Scene will stay paused during wait calls until " + \ "space bar or right arrow is hit, like a slide show" ) - parser.add_argument( - "-g", "--save_pngs", - action="store_true", - help="Save each frame as a png", - ) parser.add_argument( "-i", "--gif", action="store_true", @@ -148,6 +143,12 @@ def parse_cli(): action="store_true", help="Show the output file in finder", ) + parser.add_argument( + "--subdivide", + action="store_true", + help="Divide the output animation into individual movie files " + + "for each animation", + ) parser.add_argument( "--file_name", help="Name for the movie or image file", @@ -261,8 +262,8 @@ def update_file_writer_config(config: dict, args: Namespace): file_writer_config = config["file_writer"] file_writer_config.update( write_to_movie=(not args.skip_animations and args.write_file), + subdivide_output=args.subdivide, save_last_frame=(args.skip_animations and args.write_file), - save_pngs=args.save_pngs, png_mode=("RGBA" if args.transparent else "RGB"), movie_file_extension=(get_file_ext(args)), output_directory=get_output_directory(args, config), diff --git a/manimlib/constants.py b/manimlib/constants.py index 5c4d1b71..117ee075 100644 --- a/manimlib/constants.py +++ b/manimlib/constants.py @@ -61,9 +61,10 @@ RIGHT_SIDE: Vect3 = FRAME_X_RADIUS * RIGHT # Angles PI: float = np.pi TAU: float = 2 * PI -DEGREES: float = TAU / 360 +DEG: float = TAU / 360 +DEGREES = DEG # Many older animations use teh full name # Nice to have a constant for readability -# when juxtaposed with expressions like 30 * DEGREES +# when juxtaposed with expressions like 30 * DEG RADIANS: float = 1 # Related to Text diff --git a/manimlib/default_config.yml b/manimlib/default_config.yml index fe6cfd05..581e6db5 100644 --- a/manimlib/default_config.yml +++ b/manimlib/default_config.yml @@ -52,13 +52,6 @@ camera: fps: 30 background_opacity: 1.0 file_writer: - # If break_into_partial_movies is set to True, then many small - # files will be written corresponding to each Scene.play and - # Scene.wait call, and these files will then be combined - # to form the full scene. Sometimes video-editing is made - # easier when working with the broken up scene, which - # effectively has cuts at all the places you might want. - break_into_partial_movies: False # What command to use for ffmpeg ffmpeg_bin: "ffmpeg" # Parameters to pass into ffmpeg @@ -71,6 +64,9 @@ file_writer: scene: show_animation_progress: False leave_progress_bars: False + # When skipping animations, should a single frame be rendered + # at the end of each play call? + preview_while_skipping: True # How long does a scene pause on Scene.wait calls default_wait_time: 1.0 vmobject: @@ -104,6 +100,20 @@ sizes: # Default buffers used in Mobject.next_to or Mobject.to_edge default_mobject_to_edge_buff: 0.5 default_mobject_to_mobject_buff: 0.25 +key_bindings: + pan_3d: 'd' + pan: 'f' + reset: 'r' + quit: 'q' # Together with command + select: 's' + unselect: 'u' + grab: 'g' + x_grab: 'h' + y_grab: 'v' + resize: 't' + color: 'c' + information: 'i' + cursor: 'k' colors: blue_e: "#1C758A" blue_d: "#29ABCA" diff --git a/manimlib/extract_scene.py b/manimlib/extract_scene.py index 80c61960..6ebb4888 100644 --- a/manimlib/extract_scene.py +++ b/manimlib/extract_scene.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: Module = importlib.util.types.ModuleType from typing import Optional + from addict import Dict class BlankScene(InteractiveScene): @@ -43,11 +44,7 @@ def prompt_user_for_choice(scene_classes): print(f"{str(idx).zfill(max_digits)}: {name}") name_to_class[name] = scene_class try: - user_input = input( - "\nThat module has multiple scenes, " + \ - "which ones would you like to render?" + \ - "\nScene Name or Number: " - ) + user_input = input("\nSelect which scene to render (by name or number): ") return [ name_to_class[split_str] if not split_str.isnumeric() else scene_classes[int(split_str) - 1] for split_str in user_input.replace(" ", "").split(",") @@ -80,29 +77,29 @@ def compute_total_frames(scene_class, scene_config): return int(total_time * manim_config.camera.fps) -def scene_from_class(scene_class, scene_config, run_config): +def scene_from_class(scene_class, scene_config: Dict, run_config: Dict): fw_config = manim_config.file_writer if fw_config.write_to_movie and run_config.prerun: scene_config.file_writer_config.total_frames = compute_total_frames(scene_class, scene_config) return scene_class(**scene_config) -def get_scenes_to_render(all_scene_classes, scene_config, run_config): - if run_config["write_all"]: - return [sc(**scene_config) for sc in all_scene_classes] +def note_missing_scenes(arg_names, module_names): + for name in arg_names: + if name not in module_names: + log.error(f"No scene named {name} found") - names_to_classes = {sc.__name__: sc for sc in all_scene_classes} - scene_names = run_config["scene_names"] - for name in set.difference(set(scene_names), names_to_classes): - log.error(f"No scene named {name} found") - scene_names.remove(name) - - if scene_names: - classes_to_run = [names_to_classes[name] for name in scene_names] - elif len(all_scene_classes) == 1: - classes_to_run = [all_scene_classes[0]] +def get_scenes_to_render(all_scene_classes: list, scene_config: Dict, run_config: Dict): + if run_config["write_all"] or len(all_scene_classes) == 1: + classes_to_run = all_scene_classes else: + name_to_class = {sc.__name__: sc for sc in all_scene_classes} + classes_to_run = [name_to_class.get(name) for name in run_config.scene_names] + classes_to_run = list(filter(lambda x: x, classes_to_run)) # Remove Nones + note_missing_scenes(run_config.scene_names, name_to_class.keys()) + + if len(classes_to_run) == 0: classes_to_run = prompt_user_for_choice(all_scene_classes) return [ @@ -111,7 +108,10 @@ def get_scenes_to_render(all_scene_classes, scene_config, run_config): ] -def get_scene_classes_from_module(module): +def get_scene_classes(module: Optional[Module]): + if module is None: + # If no module was passed in, just play the blank scene + return [BlankScene(**scene_config)] if hasattr(module, "SCENES_IN_ORDER"): return module.SCENES_IN_ORDER else: @@ -162,24 +162,16 @@ def insert_embed_line_to_module(module: Module, line_number: int): exec(code_object, module.__dict__) -def get_scene_module(file_name: Optional[str], embed_line: Optional[int], is_reload: bool = False) -> Module: +def get_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): - 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(module) +def main(scene_config: Dict, run_config: Dict): + module = get_module(run_config.file_name, run_config.embed_line, run_config.is_reload) + all_scene_classes = get_scene_classes(module) scenes = get_scenes_to_render(all_scene_classes, scene_config, run_config) if len(scenes) == 0: print("No scenes found to run") diff --git a/manimlib/mobject/coordinate_systems.py b/manimlib/mobject/coordinate_systems.py index 4846aaaa..c8352532 100644 --- a/manimlib/mobject/coordinate_systems.py +++ b/manimlib/mobject/coordinate_systems.py @@ -7,7 +7,7 @@ import numpy as np import itertools as it from manimlib.constants import BLACK, BLUE, BLUE_D, BLUE_E, GREEN, GREY_A, WHITE, RED -from manimlib.constants import DEGREES, PI +from manimlib.constants import DEG, PI from manimlib.constants import DL, UL, DOWN, DR, LEFT, ORIGIN, OUT, RIGHT, UP from manimlib.constants import FRAME_X_RADIUS, FRAME_Y_RADIUS from manimlib.constants import MED_SMALL_BUFF, SMALL_BUFF @@ -307,7 +307,7 @@ class CoordinateSystem(ABC): point = self.input_to_graph_point(x, graph) angle = self.angle_of_tangent(x, graph) - normal = rotate_vector(RIGHT, angle + 90 * DEGREES) + normal = rotate_vector(RIGHT, angle + 90 * DEG) if normal[1] < 0: normal *= -1 label.next_to(point, normal, buff=buff) @@ -474,7 +474,7 @@ class Axes(VGroup, CoordinateSystem): ), length=height, ) - self.y_axis.rotate(90 * DEGREES, about_point=ORIGIN) + self.y_axis.rotate(90 * DEG, about_point=ORIGIN) # Add as a separate group in case various other # mobjects are added to self, as for example in # NumberPlane below diff --git a/manimlib/mobject/geometry.py b/manimlib/mobject/geometry.py index 375769e0..ae54ed7e 100644 --- a/manimlib/mobject/geometry.py +++ b/manimlib/mobject/geometry.py @@ -7,7 +7,7 @@ import numpy as np from manimlib.constants import DL, DOWN, DR, LEFT, ORIGIN, OUT, RIGHT, UL, UP, UR from manimlib.constants import GREY_A, RED, WHITE, BLACK from manimlib.constants import MED_SMALL_BUFF, SMALL_BUFF -from manimlib.constants import DEGREES, PI, TAU +from manimlib.constants import DEG, PI, TAU from manimlib.mobject.mobject import Mobject from manimlib.mobject.types.vectorized_mobject import DashedVMobject from manimlib.mobject.types.vectorized_mobject import VGroup @@ -983,7 +983,7 @@ class RegularPolygon(Polygon): ): # Defaults to 0 for odd, 90 for even if start_angle is None: - start_angle = (n % 2) * 90 * DEGREES + start_angle = (n % 2) * 90 * DEG start_vect = rotate_vector(radius * RIGHT, start_angle) vertices = compass_directions(n, start_vect) super().__init__(*vertices, **kwargs) diff --git a/manimlib/mobject/matrix.py b/manimlib/mobject/matrix.py index 84c8ccc6..05945f0e 100644 --- a/manimlib/mobject/matrix.py +++ b/manimlib/mobject/matrix.py @@ -3,7 +3,7 @@ from __future__ import annotations import numpy as np from manimlib.constants import DOWN, LEFT, RIGHT, ORIGIN -from manimlib.constants import DEGREES +from manimlib.constants import DEG from manimlib.mobject.numbers import DecimalNumber from manimlib.mobject.svg.tex_mobject import Tex from manimlib.mobject.types.vectorized_mobject import VGroup @@ -196,7 +196,7 @@ class Matrix(VMobject): dots.set_width(hdots_width) self.swap_entry_for_dots(row[col_index], dots) if use_vdots and use_hdots: - rows[row_index][col_index].rotate(-45 * DEGREES) + rows[row_index][col_index].rotate(-45 * DEG) return self def get_mob_matrix(self) -> VMobjectMatrixType: diff --git a/manimlib/mobject/mobject_update_utils.py b/manimlib/mobject/mobject_update_utils.py index ff4af379..b76a1fb5 100644 --- a/manimlib/mobject/mobject_update_utils.py +++ b/manimlib/mobject/mobject_update_utils.py @@ -2,7 +2,7 @@ from __future__ import annotations import inspect -from manimlib.constants import DEGREES +from manimlib.constants import DEG from manimlib.constants import RIGHT from manimlib.mobject.mobject import Mobject from manimlib.utils.simple_functions import clip @@ -71,7 +71,7 @@ def always_shift( def always_rotate( mobject: Mobject, - rate: float = 20 * DEGREES, + rate: float = 20 * DEG, **kwargs ) -> Mobject: mobject.add_updater( diff --git a/manimlib/mobject/svg/text_mobject.py b/manimlib/mobject/svg/text_mobject.py index 900a6fc5..ac9b7a7a 100644 --- a/manimlib/mobject/svg/text_mobject.py +++ b/manimlib/mobject/svg/text_mobject.py @@ -20,6 +20,7 @@ from manimlib.mobject.svg.string_mobject import StringMobject from manimlib.utils.cache import cache_on_disk from manimlib.utils.color import color_to_hex from manimlib.utils.color import int_to_hex +from manimlib.utils.simple_functions import hash_string from typing import TYPE_CHECKING @@ -74,34 +75,29 @@ def markup_to_svg( pango_width = line_width / FRAME_WIDTH * DEFAULT_PIXEL_WIDTH # Write the result to a temporary svg file, and return it's contents. - # TODO, better would be to have this not write to file at all - # - # To avoid CAIRO_STATUS_WRITE_ERROR: b'error while writing to - # output stream' on Windows, we need to pass 'delete=False'. - with tempfile.NamedTemporaryFile(suffix='.svg', mode='r+', delete=False) as tmp: - manimpango.MarkupUtils.text2svg( - text=markup_str, - font="", # Already handled - slant="NORMAL", # Already handled - weight="NORMAL", # Already handled - size=1, # Already handled - _=0, # Empty parameter - disable_liga=False, - file_name=tmp.name, - START_X=0, - START_Y=0, - width=DEFAULT_CANVAS_WIDTH, - height=DEFAULT_CANVAS_HEIGHT, - justify=justify, - indent=indent, - line_spacing=None, # Already handled - alignment=alignment, - pango_width=pango_width - ) - - # Read the contents - tmp.seek(0) - return tmp.read() + temp_file = Path(tempfile.gettempdir(), hash_string(markup_str)).with_suffix(".svg") + manimpango.MarkupUtils.text2svg( + text=markup_str, + font="", # Already handled + slant="NORMAL", # Already handled + weight="NORMAL", # Already handled + size=1, # Already handled + _=0, # Empty parameter + disable_liga=False, + file_name=str(temp_file), + START_X=0, + START_Y=0, + width=DEFAULT_CANVAS_WIDTH, + height=DEFAULT_CANVAS_HEIGHT, + justify=justify, + indent=indent, + line_spacing=None, # Already handled + alignment=alignment, + pango_width=pango_width + ) + result = temp_file.read_text() + os.remove(temp_file) + return result class MarkupText(StringMobject): diff --git a/manimlib/mobject/types/vectorized_mobject.py b/manimlib/mobject/types/vectorized_mobject.py index 2c0f0213..607951fd 100644 --- a/manimlib/mobject/types/vectorized_mobject.py +++ b/manimlib/mobject/types/vectorized_mobject.py @@ -7,7 +7,7 @@ import numpy as np from manimlib.constants import GREY_A, GREY_C, GREY_E from manimlib.constants import BLACK from manimlib.constants import DEFAULT_STROKE_WIDTH -from manimlib.constants import DEGREES +from manimlib.constants import DEG from manimlib.constants import ORIGIN, OUT from manimlib.constants import PI from manimlib.constants import TAU @@ -490,7 +490,7 @@ class VMobject(Mobject): v1 = handle1 - last v2 = anchor - handle2 angle = angle_between_vectors(v1, v2) - if self.use_simple_quadratic_approx and angle < 45 * DEGREES: + if self.use_simple_quadratic_approx and angle < 45 * DEG: quad_approx = [last, find_intersection(last, v1, anchor, -v2), anchor] else: quad_approx = get_quadratic_approximation_of_cubic( @@ -616,7 +616,7 @@ class VMobject(Mobject): def subdivide_sharp_curves( self, - angle_threshold: float = 30 * DEGREES, + angle_threshold: float = 30 * DEG, recurse: bool = True ) -> Self: def tuple_to_subdivisions(b0, b1, b2): @@ -656,7 +656,7 @@ class VMobject(Mobject): self.make_smooth(approx=approx) return self - def is_smooth(self, angle_tol=1 * DEGREES) -> bool: + def is_smooth(self, angle_tol=1 * DEG) -> bool: angles = np.abs(self.get_joint_angles()[0::2]) return (angles < angle_tol).all() diff --git a/manimlib/scene/interactive_scene.py b/manimlib/scene/interactive_scene.py index 56f620bd..e54fa989 100644 --- a/manimlib/scene/interactive_scene.py +++ b/manimlib/scene/interactive_scene.py @@ -7,10 +7,11 @@ from IPython.core.getipython import get_ipython from pyglet.window import key as PygletWindowKeys from manimlib.animation.fading import FadeIn +from manimlib.config import manim_config from manimlib.constants import DL, DOWN, DR, LEFT, ORIGIN, RIGHT, UL, UP, UR from manimlib.constants import FRAME_WIDTH, FRAME_HEIGHT, SMALL_BUFF from manimlib.constants import PI -from manimlib.constants import DEGREES +from manimlib.constants import DEG from manimlib.constants import MANIM_COLORS, WHITE, GREY_A, GREY_C from manimlib.mobject.geometry import Line from manimlib.mobject.geometry import Rectangle @@ -36,17 +37,16 @@ if TYPE_CHECKING: from manimlib.typing import Vect3 -SELECT_KEY = 's' -UNSELECT_KEY = 'u' -GRAB_KEY = 'g' -X_GRAB_KEY = 'h' -Y_GRAB_KEY = 'v' +SELECT_KEY = manim_config.key_bindings.select +UNSELECT_KEY = manim_config.key_bindings.unselect +GRAB_KEY = manim_config.key_bindings.grab +X_GRAB_KEY = manim_config.key_bindings.x_grab +Y_GRAB_KEY = manim_config.key_bindings.y_grab GRAB_KEYS = [GRAB_KEY, X_GRAB_KEY, Y_GRAB_KEY] -RESIZE_KEY = 't' -COLOR_KEY = 'c' -INFORMATION_KEY = 'i' -CURSOR_KEY = 'k' -COPY_FRAME_POSITION_KEY = 'p' +RESIZE_KEY = manim_config.key_bindings.resize # TODO +COLOR_KEY = manim_config.key_bindings.color +INFORMATION_KEY = manim_config.key_bindings.information +CURSOR_KEY = manim_config.key_bindings.cursor # For keyboard interactions @@ -625,7 +625,7 @@ class InteractiveScene(Scene): angles = frame.get_euler_angles() call = f"reorient(" - theta, phi, gamma = (angles / DEGREES).astype(int) + theta, phi, gamma = (angles / DEG).astype(int) call += f"{theta}, {phi}, {gamma}" if any(center != 0): call += f", {tuple(np.round(center, 2))}" diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index cb97eee3..f334eeac 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -48,12 +48,6 @@ if TYPE_CHECKING: from manimlib.animation.animation import Animation -PAN_3D_KEY = 'd' -FRAME_SHIFT_KEY = 'f' -RESET_FRAME_KEY = 'r' -QUIT_KEY = 'q' - - class Scene(object): random_seed: int = 0 pan_sensitivity: float = 0.5 @@ -77,6 +71,7 @@ class Scene(object): end_at_animation_number: int | None = None, show_animation_progress: bool = False, leave_progress_bars: bool = False, + preview_while_skipping: bool = True, presenter_mode: bool = False, default_wait_time: float = 1.0, ): @@ -86,6 +81,7 @@ class Scene(object): self.end_at_animation_number = end_at_animation_number self.show_animation_progress = show_animation_progress self.leave_progress_bars = leave_progress_bars + self.preview_while_skipping = preview_while_skipping self.presenter_mode = presenter_mode self.default_wait_time = default_wait_time @@ -531,7 +527,7 @@ class Scene(object): if not self.skip_animations: self.file_writer.end_animation() - if self.skip_animations and self.window is not None: + if self.preview_while_skipping and self.skip_animations and self.window is not None: # Show some quick frames along the way self.update_frame(dt=0, force_draw=True) @@ -746,13 +742,13 @@ class Scene(object): frame = self.camera.frame # Handle perspective changes - if self.window.is_key_pressed(ord(PAN_3D_KEY)): + if self.window.is_key_pressed(ord(manim_config.key_bindings.pan_3d)): ff_d_point = frame.to_fixed_frame_point(d_point, relative=True) ff_d_point *= self.pan_sensitivity frame.increment_theta(-ff_d_point[0]) frame.increment_phi(ff_d_point[1]) # Handle frame movements - elif self.window.is_key_pressed(ord(FRAME_SHIFT_KEY)): + elif self.window.is_key_pressed(ord(manim_config.key_bindings.pan)): frame.shift(-d_point) def on_mouse_drag( @@ -838,14 +834,14 @@ class Scene(object): if propagate_event is not None and propagate_event is False: return - if char == RESET_FRAME_KEY: + if char == manim_config.key_bindings.reset: self.play(self.camera.frame.animate.to_default_state()) elif char == "z" and (modifiers & (PygletWindowKeys.MOD_COMMAND | PygletWindowKeys.MOD_CTRL)): self.undo() elif char == "z" and (modifiers & (PygletWindowKeys.MOD_COMMAND | PygletWindowKeys.MOD_CTRL | PygletWindowKeys.MOD_SHIFT)): self.redo() # command + q - elif char == QUIT_KEY and (modifiers & (PygletWindowKeys.MOD_COMMAND | PygletWindowKeys.MOD_CTRL)): + elif char == manim_config.key_bindings.quit and (modifiers & (PygletWindowKeys.MOD_COMMAND | PygletWindowKeys.MOD_CTRL)): self.quit_interaction = True # Space or right arrow elif char == " " or symbol == PygletWindowKeys.RIGHT: diff --git a/manimlib/scene/scene_embed.py b/manimlib/scene/scene_embed.py index 4dad577a..281d9c72 100644 --- a/manimlib/scene/scene_embed.py +++ b/manimlib/scene/scene_embed.py @@ -102,7 +102,7 @@ class InteractiveSceneEmbed: """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 - print(''.join(traceback.format_exception(etype, evalue, tb))) + shell.showtraceback((etype, evalue, tb), tb_offset=tb_offset) rect = FullScreenRectangle().set_stroke(RED, 30).set_fill(opacity=0) rect.fix_in_frame() self.scene.play(VFadeInThenOut(rect, run_time=0.5)) diff --git a/manimlib/scene/scene_file_writer.py b/manimlib/scene/scene_file_writer.py index dcbf5e2f..a29b347f 100644 --- a/manimlib/scene/scene_file_writer.py +++ b/manimlib/scene/scene_file_writer.py @@ -13,8 +13,6 @@ from pathlib import Path from manimlib.logger import log from manimlib.mobject.mobject import Mobject -from manimlib.utils.file_ops import add_extension_if_not_present -from manimlib.utils.file_ops import get_sorted_integer_files from manimlib.utils.file_ops import guarantee_existence from manimlib.utils.sounds import get_full_sound_file_path @@ -32,17 +30,15 @@ class SceneFileWriter(object): self, scene: Scene, write_to_movie: bool = False, - break_into_partial_movies: bool = False, - save_pngs: bool = False, # TODO, this currently does nothing + subdivide_output: bool = False, png_mode: str = "RGBA", save_last_frame: bool = False, movie_file_extension: str = ".mp4", # What python file is generating this scene input_file_path: str = "", # Where should this be written - output_directory: str | None = None, + output_directory: str = "", file_name: str | None = None, - subdirectory_for_videos: bool = False, open_file_upon_completion: bool = False, show_file_location_upon_completion: bool = False, quiet: bool = False, @@ -57,8 +53,7 @@ class SceneFileWriter(object): ): self.scene: Scene = scene self.write_to_movie = write_to_movie - self.break_into_partial_movies = break_into_partial_movies - self.save_pngs = save_pngs + self.subdivide_output = subdivide_output self.png_mode = png_mode self.save_last_frame = save_last_frame self.movie_file_extension = movie_file_extension @@ -66,7 +61,6 @@ class SceneFileWriter(object): self.output_directory = output_directory self.file_name = file_name self.open_file_upon_completion = open_file_upon_completion - self.subdirectory_for_videos = subdirectory_for_videos self.show_file_location_upon_completion = show_file_location_upon_completion self.quiet = quiet self.total_frames = total_frames @@ -81,40 +75,39 @@ class SceneFileWriter(object): self.writing_process: sp.Popen | None = None self.progress_display: ProgressDisplay | None = None 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 "" - 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")) - image_file = add_extension_if_not_present(scene_name, ".png") - self.image_file_path = os.path.join(image_dir, image_file) + self.image_file_path = self.init_image_file_path() if self.write_to_movie: - if self.subdirectory_for_videos: - movie_dir = guarantee_existence(os.path.join(out_dir, "videos")) - else: - movie_dir = guarantee_existence(out_dir) - movie_file = add_extension_if_not_present(scene_name, self.movie_file_extension) - self.movie_file_path = os.path.join(movie_dir, movie_file) - if self.break_into_partial_movies: - self.partial_movie_directory = guarantee_existence(os.path.join( - movie_dir, "partial_movie_files", scene_name, - )) - # A place to save mobjects - self.saved_mobject_directory = os.path.join( - out_dir, "mobjects", str(self.scene) + self.movie_file_path = self.init_movie_file_path() + if self.subdivide_output: + self.partial_movie_directory = self.init_partial_movie_directory() + + def init_image_file_path(self) -> Path: + return self.get_output_file_rootname().with_suffix(".png") + + def init_movie_file_path(self) -> Path: + return self.get_output_file_rootname().with_suffix(self.movie_file_extension) + + def init_partial_movie_directory(self): + return guarantee_existence(self.get_output_file_rootname()) + + def get_output_file_rootname(self) -> Path: + return Path( + guarantee_existence(self.output_directory), + self.get_output_file_name() ) - def get_default_module_directory(self) -> str: - path, _ = os.path.splitext(self.input_file_path) - if path.startswith("_"): - path = path[1:] - return path - - def get_default_scene_name(self) -> str: + def get_output_file_name(self) -> str: + if self.file_name: + return self.file_name + # Otherwise, use the name of the scene, potentially + # appending animation numbers name = str(self.scene) saan = self.scene.start_at_animation_number eaan = self.scene.end_at_animation_number @@ -124,26 +117,13 @@ class SceneFileWriter(object): name += f"_{eaan}" return name - def get_resolution_directory(self) -> str: - pixel_height = self.scene.camera.pixel_height - fps = self.scene.camera.fps - return "{}p{}".format( - pixel_height, fps - ) - # Directory getters def get_image_file_path(self) -> str: return self.image_file_path def get_next_partial_movie_path(self) -> str: - result = os.path.join( - self.partial_movie_directory, - "{:05}{}".format( - self.scene.num_plays, - self.movie_file_extension, - ) - ) - return result + result = Path(self.partial_movie_directory, f"{self.scene.num_plays:05}") + return result.with_suffix(self.movie_file_extension) def get_movie_file_path(self) -> str: return self.movie_file_path @@ -199,23 +179,20 @@ class SceneFileWriter(object): # Writers def begin(self) -> None: - if not self.break_into_partial_movies and self.write_to_movie: + if not self.subdivide_output and self.write_to_movie: self.open_movie_pipe(self.get_movie_file_path()) def begin_animation(self) -> None: - if self.break_into_partial_movies and self.write_to_movie: + if self.subdivide_output and self.write_to_movie: self.open_movie_pipe(self.get_next_partial_movie_path()) def end_animation(self) -> None: - if self.break_into_partial_movies and self.write_to_movie: + if self.subdivide_output and self.write_to_movie: self.close_movie_pipe() def finish(self) -> None: - if self.write_to_movie: - if self.break_into_partial_movies: - self.combine_movie_files() - else: - self.close_movie_pipe() + if not self.subdivide_output and self.write_to_movie: + self.close_movie_pipe() if self.includes_sound: self.add_sound_to_video() self.print_file_ready_message(self.get_movie_file_path()) @@ -234,7 +211,6 @@ class SceneFileWriter(object): width, height = self.scene.camera.get_pixel_shape() vf_arg = 'vflip' - # if self.pixel_format.startswith("yuv"): vf_arg += f',eq=saturation={self.saturation}:gamma={self.gamma}' command = [ @@ -246,7 +222,7 @@ class SceneFileWriter(object): '-r', str(fps), # frames per second '-i', '-', # The input comes from a pipe '-vf', vf_arg, - '-an', # Tells FFMPEG not to expect any audio + '-an', # Tells ffmpeg not to expect any audio '-loglevel', 'error', ] if self.video_codec: @@ -273,8 +249,8 @@ class SceneFileWriter(object): movie_path = Path(self.get_movie_file_path()) scene_name = movie_path.stem insert_dir = Path(movie_path.parent, "inserts") - guarantee_existence(str(insert_dir)) - return Path(insert_dir, f"{scene_name}_{index}{movie_path.suffix}") + guarantee_existence(insert_dir) + return Path(insert_dir, f"{scene_name}_{index}").with_suffix(self.movie_file_extension) def begin_insert(self): # Begin writing process @@ -283,7 +259,7 @@ class SceneFileWriter(object): index = 0 while (insert_path := self.get_insert_file_path(index)).exists(): index += 1 - self.inserted_file_path = str(insert_path) + self.inserted_file_path = insert_path self.open_movie_pipe(self.inserted_file_path) def end_insert(self): @@ -327,54 +303,6 @@ class SceneFileWriter(object): else: self.movie_file_path = self.temp_file_path - def combine_movie_files(self) -> None: - kwargs = { - "remove_non_integer_files": True, - "extension": self.movie_file_extension, - } - if self.scene.start_at_animation_number is not None: - kwargs["min_index"] = self.scene.start_at_animation_number - if self.scene.end_at_animation_number is not None: - kwargs["max_index"] = self.scene.end_at_animation_number - else: - kwargs["remove_indices_greater_than"] = self.scene.num_plays - 1 - partial_movie_files = get_sorted_integer_files( - self.partial_movie_directory, - **kwargs - ) - if len(partial_movie_files) == 0: - log.warning("No animations in this scene") - return - - # Write a file partial_file_list.txt containing all - # partial movie files - file_list = os.path.join( - self.partial_movie_directory, - "partial_movie_file_list.txt" - ) - with open(file_list, 'w') as fp: - for pf_path in partial_movie_files: - if os.name == 'nt': - pf_path = pf_path.replace('\\', '/') - fp.write(f"file \'{pf_path}\'\n") - - movie_file_path = self.get_movie_file_path() - commands = [ - self.ffmpeg_bin, - '-y', # overwrite output file if it exists - '-f', 'concat', - '-safe', '0', - '-i', file_list, - '-loglevel', 'error', - '-c', 'copy', - movie_file_path - ] - if not self.includes_sound: - commands.insert(-1, '-an') - - combine_process = sp.Popen(commands) - combine_process.wait() - def add_sound_to_video(self) -> None: movie_file_path = self.get_movie_file_path() stem, ext = os.path.splitext(movie_file_path) diff --git a/manimlib/utils/dict_ops.py b/manimlib/utils/dict_ops.py index 36d27d7b..aa0a68a9 100644 --- a/manimlib/utils/dict_ops.py +++ b/manimlib/utils/dict_ops.py @@ -20,29 +20,3 @@ def merge_dicts_recursively(*dicts): else: result[key] = value return result - - -def soft_dict_update(d1, d2): - """ - Adds key values pairs of d2 to d1 only when d1 doesn't - already have that key - """ - for key, value in list(d2.items()): - if key not in d1: - d1[key] = value - - -def dict_eq(d1, d2): - if len(d1) != len(d2): - return False - for key in d1: - value1 = d1[key] - value2 = d2[key] - if type(value1) != type(value2): - return False - if type(d1[key]) == np.ndarray: - if any(d1[key] != d2[key]): - return False - elif d1[key] != d2[key]: - return False - return True diff --git a/manimlib/utils/file_ops.py b/manimlib/utils/file_ops.py index 26faa449..86e9a009 100644 --- a/manimlib/utils/file_ops.py +++ b/manimlib/utils/file_ops.py @@ -17,25 +17,17 @@ if TYPE_CHECKING: from typing import Iterable -def add_extension_if_not_present(file_name: str, extension: str) -> str: - # This could conceivably be smarter about handling existing differing extensions - if(file_name[-len(extension):] != extension): - return file_name + extension - else: - return file_name - - -def guarantee_existence(path: str) -> str: - if not os.path.exists(path): - os.makedirs(path) - return os.path.abspath(path) +def guarantee_existence(path: str | Path) -> Path: + path = Path(path) + path.mkdir(parents=True, exist_ok=True) + return path.absolute() def find_file( file_name: str, directories: Iterable[str] | None = None, extensions: Iterable[str] | None = None -) -> str: +) -> Path: # Check if this is a file online first, and if so, download # it to a temporary directory if validators.url(file_name): @@ -49,49 +41,17 @@ def find_file( # Check if what was passed in is already a valid path to a file if os.path.exists(file_name): - return file_name + return Path(file_name) # Otherwise look in local file system directories = directories or [""] extensions = extensions or [""] possible_paths = ( - os.path.join(directory, file_name + extension) + Path(directory, file_name + extension) for directory in directories for extension in extensions ) for path in possible_paths: - if os.path.exists(path): + if path.exists(): return path raise IOError(f"{file_name} not Found") - - -def get_sorted_integer_files( - directory: str, - min_index: float = 0, - max_index: float = np.inf, - remove_non_integer_files: bool = False, - remove_indices_greater_than: float | None = None, - extension: str | None = None, -) -> list[str]: - indexed_files = [] - for file in os.listdir(directory): - if '.' in file: - index_str = file[:file.index('.')] - else: - index_str = file - - full_path = os.path.join(directory, file) - if index_str.isdigit(): - index = int(index_str) - if remove_indices_greater_than is not None: - if index > remove_indices_greater_than: - os.remove(full_path) - continue - if extension is not None and not file.endswith(extension): - continue - if index >= min_index and index < max_index: - indexed_files.append((index, file)) - elif remove_non_integer_files: - os.remove(full_path) - indexed_files.sort(key=lambda p: p[0]) - return list(map(lambda p: os.path.join(directory, p[1]), indexed_files)) diff --git a/manimlib/utils/simple_functions.py b/manimlib/utils/simple_functions.py index 43976a0b..3336ee68 100644 --- a/manimlib/utils/simple_functions.py +++ b/manimlib/utils/simple_functions.py @@ -36,12 +36,6 @@ def get_num_args(function: Callable) -> int: def get_parameters(function: Callable) -> Iterable[str]: return inspect.signature(function).parameters.keys() -# Just to have a less heavyweight name for this extremely common operation -# -# We may wish to have more fine-grained control over division by zero behavior -# in the future (separate specifiable values for 0/0 and x/0 with x != 0), -# but for now, we just allow the option to handle indeterminate 0/0. - def clip(a: float, min_a: float, max_a: float) -> float: if a < min_a: @@ -58,6 +52,10 @@ def arr_clip(arr: np.ndarray, min_a: float, max_a: float) -> np.ndarray: def fdiv(a: Scalable, b: Scalable, zero_over_zero_value: Scalable | None = None) -> Scalable: + """ + Less heavyweight name for np.true_divide, enabling + default behavior for 0/0 + """ if zero_over_zero_value is not None: out = np.full_like(a, zero_over_zero_value) where = np.logical_or(a != 0, b != 0) @@ -68,11 +66,13 @@ def fdiv(a: Scalable, b: Scalable, zero_over_zero_value: Scalable | None = None) return np.true_divide(a, b, out=out, where=where) -def binary_search(function: Callable[[float], float], - target: float, - lower_bound: float, - upper_bound: float, - tolerance:float = 1e-4) -> float | None: +def binary_search( + function: Callable[[float], float], + target: float, + lower_bound: float, + upper_bound: float, + tolerance:float = 1e-4 +) -> float | None: lh = lower_bound rh = upper_bound mh = (lh + rh) / 2 diff --git a/manimlib/utils/tex.py b/manimlib/utils/tex.py index 719d228a..7c2d7ecc 100644 --- a/manimlib/utils/tex.py +++ b/manimlib/utils/tex.py @@ -1,40 +1,38 @@ from __future__ import annotations import re +from functools import lru_cache from manimlib.utils.tex_to_symbol_count import TEX_TO_SYMBOL_COUNT +@lru_cache def num_tex_symbols(tex: str) -> int: + tex = remove_tex_environments(tex) + commands_pattern = r""" + (?P\\sqrt\[[0-9]+\])| # Special sqrt with number + (?P\\[a-zA-Z!,-/:;<>]+) # Regular commands """ - This function attempts to estimate the number of symbols that - a given string of tex would produce. - - Warning, it may not behave perfectly - """ - # First, remove patterns like \begin{align}, \phantom{thing}, - # \begin{array}{cc}, etc. - pattern = "|".join( - rf"(\\{s})" + r"(\{\w+\})?(\{\w+\})?(\[\w+\])?" - for s in ["begin", "end", "phantom"] - ) - tex = re.sub(pattern, "", tex) - - # Progressively count the symbols associated with certain tex commands, - # and remove those commands from the string, adding the number of symbols - # that command creates total = 0 + pos = 0 + for match in re.finditer(commands_pattern, tex, re.VERBOSE): + # Count normal characters up to this command + total += sum(1 for c in tex[pos:match.start()] if c not in "^{} \n\t_$\\&") - # Start with the special case \sqrt[number] - for substr in re.findall(r"\\sqrt\[[0-9]+\]", tex): - total += len(substr) - 5 # e.g. \sqrt[3] is 3 symbols - tex = tex.replace(substr, " ") - - general_command = r"\\[a-zA-Z!,-/:;<>]+" - for substr in re.findall(general_command, tex): - total += TEX_TO_SYMBOL_COUNT.get(substr, 1) - tex = tex.replace(substr, " ") + if match.group("sqrt"): + total += len(match.group()) - 5 + else: + total += TEX_TO_SYMBOL_COUNT.get(match.group(), 1) + pos = match.end() # Count remaining characters - total += sum(map(lambda c: c not in "^{} \n\t_$\\&", tex)) + total += sum(1 for c in tex[pos:] if c not in "^{} \n\t_$\\&") return total + + +def remove_tex_environments(tex: str) -> str: + # Handle \phantom{...} with any content + tex = re.sub(r"\\phantom\{[^}]*\}", "", tex) + # Handle other environment commands + tex = re.sub(r"\\(begin|end)(\{\w+\})?(\{\w+\})?(\[\w+\])?", "", tex) + return tex